Feature Request: call-workflow safe output for workflow_call chaining
Summary
Request for a new call-workflow safe output type that enables an engine: copilot (gh-aw) gateway workflow to chain to a worker workflow via workflow_call. This would enable the orchestrator/dispatcher pattern to work within workflow_call chains — without requiring additional tokens, without changing the security model, and without any of the billing/identity/secrets problems caused by repository_dispatch.
What we're building
An internal developer platform that provides standardised, opinionated coding agent workflows to application teams across the enterprise. The platform is hosted in a single central repository and serves multiple consumer teams.
The platform provides:
- Gateway: an
engine: copilot (gh-aw) workflow that receives triggers, runs preflight checks, and dispatches to the appropriate agentic workflow for the task.
- Workers: specialised
engine: copilot (gh-aw) workflows — one per application archetype and task combination (e.g., spring-boot/bug-fix, frontend/dep-upgrade). Each worker runs a coding agent session to execute the task, validates its own output via post-steps:, and creates a pull request via the create-pull-request safe output.
- Custom agents: agent instruction files imported via
imports: at compile time.
- Quality gates: deterministic and LLM-based checks at input (gateway) and output (worker
post-steps:, scope review).
Consumer teams provide:
- Their repository (the codebase the agent works on).
- An issue describing the work.
- A trigger (slash command on the issue).
The platform must support thousands of potential users across hundreds of teams. Costs (premium requests, action minutes) should be attributed to the team member triggering the platform, not to the platform.
The orchestrator/dispatcher pattern
The platform uses an orchestrator/dispatcher pattern: the gateway determines which worker to run, then chains to the appropriate worker. This gateway → worker chaining is essential — it's how the platform routes different task types to specialised agentic workflows.
Today, the only chaining mechanism in gh-aw is dispatch-workflow, which uses workflow_dispatch and is same-repository only. This is the fundamental constraint that motivates this feature request.
Why workflow_call matters for us
We want consumer teams to invoke the platform via cross-repo workflow_call (a thin caller workflow in the consumer repo using uses: org/platform/.github/workflows/gateway.lock.yml@main with secrets: inherit). This gives us:
- Correct billing: Actions minutes billed to the consumer, not the platform.
- Correct identity:
github.actor reflects the developer who triggered the workflow.
- Secret flow: Consumer's
COPILOT_GITHUB_TOKEN flows via secrets: inherit.
- Central control: Platform team maintains all workflow logic in one repo.
gh-aw recently added explicit support for cross-repo workflow_call in PR #20301, confirming this direction is aligned with the platform's trajectory.
The only missing piece is gateway → worker chaining within a workflow_call context.
Why dispatch-workflow doesn't work here
dispatch-workflow uses the GitHub Actions workflow_dispatch REST API at runtime. gh-aw's documentation explicitly restricts this to same-repository:
Same-repository only - Cannot dispatch workflows in other repositories. This prevents cross-repository workflow triggering which could be a security risk.
This restriction has genuine validity for runtime API dispatch:
| Concern |
Detail |
| Compute, not just data |
Unlike create-issue or add-comment (which already support cross-repo), dispatching a workflow causes code to run with whatever permissions the target workflow declares. |
| Token management |
Cross-repo dispatch requires a token with actions: write on the target repo. GITHUB_TOKEN in workflow_call context is scoped to the caller. |
| Billing reversal |
workflow_dispatch minutes are billed to the repo where the workflow runs (the target), not the caller. |
| Identity loss |
github.actor in a workflow_dispatch run is the token owner that called the API, not the original developer. |
Even if we made dispatch-workflow cross-repo, it would undo the billing, identity, and secret-flow benefits that workflow_call provides.
Proposed solution: compile-time workflow_call fan-out with runtime selection
The key insight
workflow_call is not an API call — it's a YAML uses: declaration resolved by the GitHub Actions scheduler before any code runs. Crucially, uses: cannot contain expressions — it must be a literal string. This is a GitHub Actions platform constraint. An agent cannot dynamically trigger workflow_call at runtime.
But the gh-aw compiler can generate uses: declarations at compile time, with runtime conditionals that let the gateway agent select which worker to activate. This is the only possible approach for workflow_call chaining given the platform constraint.
How the compiled workflow would look
The compiled .lock.yml follows gh-aw's standard job pipeline: activation → agent → safe_outputs. The call-workflow conditional jobs come after safe_outputs, which is the job that processes the agent's output and sets the call_workflow_name output variable.
Gateway workflow (compiled .lock.yml)
├── activation job (setup, sanitisation, prompt generation)
├── agent job (AI engine runs, selects worker via MCP tool)
├── safe_outputs job (processes agent output, sets call_workflow_name + payload)
├── worker-A job (if: selected == 'A') → uses: ./worker-A.lock.yml
├── worker-B job (if: selected == 'B') → uses: ./worker-B.lock.yml
└── worker-C job (if: selected == 'C') → uses: ./worker-C.lock.yml
All worker references are resolved and validated at compile time. The agent's only runtime decision is which pre-validated worker to activate. Only one conditional job runs per execution; the rest are skipped. GitHub Actions handles skipped jobs efficiently — they don't consume minutes or runner slots.
What the markdown workflow would look like
---
on:
workflow_call:
inputs:
issue_number:
required: true
type: number
engine: copilot
safe-outputs:
call-workflow:
workflows:
- spring-boot-bugfix
- frontend-dep-upgrade
- python-migration
max: 1
---
# Gateway
Analyse the issue and the target repository.
Determine which worker workflow to dispatch.
How the agent selects a worker (per-worker MCP tools)
This is how dispatch-workflow already works, and call-workflow would reuse the same pattern.
At compile time, the compiler reads each allowed worker's frontmatter, extracts its declared inputs, and generates a separate, named MCP tool per worker — with a schema that describes that worker's specific parameters. For example, given these two workers:
spring-boot-bugfix.md declares inputs environment (choice: dev/staging/production) and version (string).
frontend-dep-upgrade.md declares inputs package_manager (choice: npm/yarn/pnpm) and dry_run (boolean).
The compiler generates two MCP tools that the agent sees during its session:
spring_boot_bugfix(environment, version) — with enum constraints, descriptions, required flags
frontend_dep_upgrade(package_manager, dry_run) — with typed parameters
The agent doesn't see a generic "call some workflow" tool. It sees rich, named, typed tools — identical to how dispatch-workflow works today. The code that does this already exists:
generateDispatchWorkflowTool() in pkg/workflow/safe_outputs_dispatch.go reads each worker's inputs and generates the MCP tool definition
extractWorkflowDispatchInputs() in pkg/workflow/dispatch_workflow_validation.go extracts the input schema from each worker's frontmatter
- The runtime handler in
actions/setup/js/safe_outputs_tools_loader.cjs maps per-workflow tool calls to the generic handler
When the agent calls spring_boot_bugfix(environment: "staging", version: "1.2.3"), the handler sets output variables instead of making an API call:
call_workflow_name = "spring-boot-bugfix"
call_workflow_payload = '{"environment":"staging","version":"1.2.3"}'
The conditional uses: job for spring-boot-bugfix evaluates its if: condition, finds a match, and runs.
Input forwarding: the JSON envelope pattern
A key design decision is how to forward the agent's inputs to the selected worker. We considered two approaches:
| Approach |
How it works |
Problem |
| Per-worker typed inputs |
Compiler reads each worker's workflow_call.inputs, exposes each as a separate output from safe_outputs, generates a different with: block per conditional job |
The union of all inputs across all workers must be exposed as individual safe_outputs outputs. Hits GitHub's 50 total inputs+secrets+outputs limit per workflow_call. Compiler must generate a different with: block for every conditional job. |
| Single JSON payload |
All workers declare one input (payload: { type: string }). Agent output is serialised as JSON. All conditional jobs pass the same payload output. |
Workers must parse JSON internally. Less type-safe at the YAML level. But mirrors how dispatch-workflow already works — all workflow_dispatch input values are stringified at the API level. |
We recommend the JSON envelope pattern. It's simpler, doesn't hit platform limits, doesn't require the compiler to understand each worker's input schema for the with: block, and matches the existing dispatch-workflow architecture. The per-worker MCP tools still give the agent a rich, typed interface — the JSON envelope is just the plumbing underneath.
Ref pinning: not needed
Since the gateway and workers are in the same repo, the compiler generates relative paths:
uses: ./.github/workflows/worker-a.lock.yml
When a consumer calls the gateway via uses: org/platform/.github/workflows/gateway.lock.yml@main, GitHub resolves the gateway's internal uses: ./... references against the platform repo at the same ref. No explicit ref pinning or SHA resolution is needed.
What the compiler would generate
# Compiled gateway.lock.yml (simplified)
jobs:
activation:
# ... standard activation job ...
agent:
needs: [activation]
# ... standard agent job ...
# Agent calls the "spring_boot_bugfix" MCP tool
# Agent output: { "type": "call_workflow", "workflow_name": "spring-boot-bugfix",
# "inputs": {"environment":"staging","version":"1.2.3"} }
safe_outputs:
needs: [agent]
if: ((!cancelled()) && (needs.agent.result != 'skipped'))
runs-on: ubuntu-latest
outputs:
call_workflow_name: ${{ steps.process_safe_outputs.outputs.call_workflow_name }}
call_workflow_payload: ${{ steps.process_safe_outputs.outputs.call_workflow_payload }}
steps:
- name: Process Safe Outputs
id: process_safe_outputs
# Validates workflow_name against allowlist
# Serialises inputs as JSON payload string
# Conditional workflow_call jobs — one per allowed worker
call-spring-boot-bugfix:
needs: [safe_outputs]
if: needs.safe_outputs.outputs.call_workflow_name == 'spring-boot-bugfix'
uses: ./.github/workflows/spring-boot-bugfix.lock.yml
secrets: inherit
with:
payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
call-frontend-dep-upgrade:
needs: [safe_outputs]
if: needs.safe_outputs.outputs.call_workflow_name == 'frontend-dep-upgrade'
uses: ./.github/workflows/frontend-dep-upgrade.lock.yml
secrets: inherit
with:
payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
call-python-migration:
needs: [safe_outputs]
if: needs.safe_outputs.outputs.call_workflow_name == 'python-migration'
uses: ./.github/workflows/python-migration.lock.yml
secrets: inherit
with:
payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
conclusion:
needs: [activation, agent, safe_outputs,
call-spring-boot-bugfix, call-frontend-dep-upgrade, call-python-migration]
if: always()
# ... standard conclusion job, checks which worker ran ...
What this approach preserves
| Aspect |
Detail |
| Security model |
Identical to existing workflow_call. No new trust boundaries. No cross-repo API calls. |
| No additional tokens |
secrets: inherit flows GITHUB_TOKEN and all caller secrets through the chain. No PATs, no App tokens. |
| Billing |
Minutes billed to the original caller (consumer repo), following workflow_call billing rules. |
| Identity |
github.actor preserved through the workflow_call chain. |
| Allowlist enforcement |
Only workflows in the workflows list can be selected. Compile-time validated. |
| Agent experience |
Identical to dispatch-workflow — per-worker named MCP tools with typed parameters. |
Platform constraints this respects
| Constraint |
How we handle it |
uses: cannot contain expressions |
Static fan-out with if: conditionals — the only possible approach for workflow_call chaining. |
50 inputs+secrets+outputs limit per workflow_call |
JSON envelope pattern: one payload input, not per-field. |
4-level workflow_call nesting limit |
Consumer → Gateway → Worker = 2 levels. Leaves room for workers that themselves chain further. |
dispatch-workflow same-repo only |
We don't use dispatch-workflow at all. Pure workflow_call. |
Scaling considerations
| Scale |
Assessment |
| ≤10 workers |
No issues. The compiled YAML is manageable. |
| 10–30 workers |
The .lock.yml becomes large but functional. Skipped jobs in the Actions UI are noisy but don't consume resources. |
| 30+ workers |
Consider a tiered gateway pattern: the top-level gateway routes to category sub-gateways (each via call-workflow), and each sub-gateway routes to ~10 workers. This uses 3 levels of workflow_call nesting (consumer → gateway → sub-gateway → worker), within the platform's 4-level limit. |
Implementation Plan
1. Add CallWorkflowConfig struct and parser (pkg/workflow/call_workflow.go)
Create a new file following the existing dispatch_workflow.go pattern:
- Define
CallWorkflowConfig struct with BaseSafeOutputConfig inline, Workflows []string, and WorkflowFiles map[string]string (mirroring DispatchWorkflowConfig).
- Implement
parseCallWorkflowConfig() on the Compiler — parse both array format (list of workflow names) and map format (with workflows:, max:, etc.).
- Default
max to 1. Cap at a reasonable limit (e.g., 50, matching dispatch-workflow).
- Add
CallWorkflow *CallWorkflowConfig field to SafeOutputsConfig in pkg/workflow/safe_outputs_config.go.
2. Add schema validation (pkg/parser/schemas/frontmatter.json)
Add call-workflow to the frontmatter JSON schema alongside dispatch-workflow:
- Accept array format:
call-workflow: [workflow-a, workflow-b]
- Accept map format:
call-workflow: { workflows: [...], max: 1 }
- Validate
workflows is a non-empty array of strings.
After changing the schema, run make build — schemas are embedded via //go:embed.
3. Add compile-time validation (pkg/workflow/call_workflow_validation.go)
Create a validation file following the dispatch_workflow_validation.go pattern:
- Implement
validateCallWorkflow() on the Compiler.
- Reuse
findWorkflowFile() to locate each allowed worker's .lock.yml/.yml/.md.
- Validate each worker exists and declares
workflow_call in its on: section (not just workflow_dispatch).
- Validate self-reference prevention (gateway cannot call itself).
- Extract each worker's
workflow_call.inputs for MCP tool generation (create extractWorkflowCallInputs() mirroring extractWorkflowDispatchInputs()).
- Wire validation into
Compiler.validateWorkflow().
4. Generate per-worker MCP tools (pkg/workflow/safe_outputs_dispatch.go or new file)
Reuse the existing generateDispatchWorkflowTool() function or create a generateCallWorkflowTool() variant:
- Read each worker's
workflow_call.inputs schema.
- Generate a named MCP tool per worker with typed parameters (string, number, boolean, choice).
- Set
_workflow_name internal metadata on each tool for handler routing.
- Wire into
safe_outputs_tools_filtering.go alongside the existing dispatch_workflow tool generation block.
5. Implement the runtime handler (actions/setup/js/call_workflow.cjs)
Create a handler that is simpler than dispatch_workflow.cjs — it makes no API calls:
- Validate
workflow_name against the compile-time allowlist.
- Serialise
inputs as a JSON string.
- Set
core.setOutput("call_workflow_name", workflowName).
- Set
core.setOutput("call_workflow_payload", JSON.stringify(inputs)).
- Register in
safe_outputs_tools_loader.cjs alongside the existing dispatch handler (the _workflow_name routing pattern already exists).
6. Generate conditional uses: jobs in the compiler (pkg/workflow/compiler_safe_output_jobs.go)
This is the core change. In buildSafeOutputsJobs():
-
After building the consolidated safe_outputs job, if data.SafeOutputs.CallWorkflow is non-nil:
- For each workflow in
CallWorkflow.Workflows, generate a conditional job:
name: call-{workflow-name} (sanitised)
needs: [safe_outputs]
if: needs.safe_outputs.outputs.call_workflow_name == '{workflow-name}'
uses: ./.github/workflows/{workflow-name}.lock.yml
secrets: inherit
with: payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
- Add all
call-* job names to the conclusion job's needs list.
-
Ensure the safe_outputs job declares call_workflow_name and call_workflow_payload as outputs.
7. Populate workflow files at compile time (pkg/workflow/safe_outputs_dispatch.go or new)
Create populateCallWorkflowFiles() mirroring populateDispatchWorkflowFiles():
- For each workflow in the allowlist, resolve its
.lock.yml file path.
- Store in
CallWorkflow.WorkflowFiles for runtime use.
- Call from
generateMCPSetup() in pkg/workflow/mcp_setup_generator.go (same pattern as populateDispatchWorkflowFiles).
8. Add unit tests
Test files must have //go:build !integration as the first line, followed by an empty line.
pkg/workflow/call_workflow_test.go:
- Test
parseCallWorkflowConfig() with array and map formats.
- Test default max value and max cap.
pkg/workflow/call_workflow_validation_test.go:
- Test validation of existing workers with
workflow_call trigger.
- Test self-reference prevention.
- Test worker not found error.
- Test worker without
workflow_call trigger error.
pkg/workflow/safe_outputs_call_workflow_test.go:
- Test MCP tool generation from worker
workflow_call.inputs.
- Test compiled output contains conditional
uses: jobs with correct if: conditions.
- Test
secrets: inherit is present on all conditional jobs.
- Test
payload is wired correctly in with: blocks.
- Test conclusion job depends on all
call-* jobs.
actions/setup/js/call_workflow.test.cjs:
- Test handler validates workflow name against allowlist.
- Test handler serialises inputs as JSON.
- Test handler rejects unknown workflow names.
9. Update documentation
docs/src/content/docs/reference/safe-outputs.md: Add call-workflow section alongside dispatch-workflow.
docs/src/content/docs/reference/safe-outputs-specification.md: Add formal specification.
docs/src/content/docs/examples/multi-repo.md: Add example of gateway → worker pattern using call-workflow.
10. Follow guidelines
- Use console formatting from
pkg/console for CLI output.
- Follow error message style guide:
[what's wrong]. [what's expected]. [example].
- Run
make agent-finish before completing (build, test, recompile, fmt, lint).
- Run
./scripts/add-build-tags.sh to ensure all test files have correct build tags.
- Run
make lint to catch unused helpers, testifylint issues, and misspell errors.
Feature Request:
call-workflowsafe output forworkflow_callchainingSummary
Request for a new
call-workflowsafe output type that enables anengine: copilot(gh-aw) gateway workflow to chain to a worker workflow viaworkflow_call. This would enable the orchestrator/dispatcher pattern to work withinworkflow_callchains — without requiring additional tokens, without changing the security model, and without any of the billing/identity/secrets problems caused byrepository_dispatch.What we're building
An internal developer platform that provides standardised, opinionated coding agent workflows to application teams across the enterprise. The platform is hosted in a single central repository and serves multiple consumer teams.
The platform provides:
engine: copilot(gh-aw) workflow that receives triggers, runs preflight checks, and dispatches to the appropriate agentic workflow for the task.engine: copilot(gh-aw) workflows — one per application archetype and task combination (e.g., spring-boot/bug-fix, frontend/dep-upgrade). Each worker runs a coding agent session to execute the task, validates its own output viapost-steps:, and creates a pull request via thecreate-pull-requestsafe output.imports:at compile time.post-steps:, scope review).Consumer teams provide:
The platform must support thousands of potential users across hundreds of teams. Costs (premium requests, action minutes) should be attributed to the team member triggering the platform, not to the platform.
The orchestrator/dispatcher pattern
The platform uses an orchestrator/dispatcher pattern: the gateway determines which worker to run, then chains to the appropriate worker. This gateway → worker chaining is essential — it's how the platform routes different task types to specialised agentic workflows.
Today, the only chaining mechanism in gh-aw is
dispatch-workflow, which usesworkflow_dispatchand is same-repository only. This is the fundamental constraint that motivates this feature request.Why
workflow_callmatters for usWe want consumer teams to invoke the platform via cross-repo
workflow_call(a thin caller workflow in the consumer repo usinguses: org/platform/.github/workflows/gateway.lock.yml@mainwithsecrets: inherit). This gives us:github.actorreflects the developer who triggered the workflow.COPILOT_GITHUB_TOKENflows viasecrets: inherit.gh-aw recently added explicit support for cross-repo
workflow_callin PR #20301, confirming this direction is aligned with the platform's trajectory.The only missing piece is gateway → worker chaining within a
workflow_callcontext.Why
dispatch-workflowdoesn't work heredispatch-workflowuses the GitHub Actionsworkflow_dispatchREST API at runtime. gh-aw's documentation explicitly restricts this to same-repository:This restriction has genuine validity for runtime API dispatch:
create-issueoradd-comment(which already support cross-repo), dispatching a workflow causes code to run with whatever permissions the target workflow declares.actions: writeon the target repo.GITHUB_TOKENinworkflow_callcontext is scoped to the caller.workflow_dispatchminutes are billed to the repo where the workflow runs (the target), not the caller.github.actorin aworkflow_dispatchrun is the token owner that called the API, not the original developer.Even if we made
dispatch-workflowcross-repo, it would undo the billing, identity, and secret-flow benefits thatworkflow_callprovides.Proposed solution: compile-time
workflow_callfan-out with runtime selectionThe key insight
workflow_callis not an API call — it's a YAMLuses:declaration resolved by the GitHub Actions scheduler before any code runs. Crucially,uses:cannot contain expressions — it must be a literal string. This is a GitHub Actions platform constraint. An agent cannot dynamically triggerworkflow_callat runtime.But the gh-aw compiler can generate
uses:declarations at compile time, with runtime conditionals that let the gateway agent select which worker to activate. This is the only possible approach forworkflow_callchaining given the platform constraint.How the compiled workflow would look
The compiled
.lock.ymlfollows gh-aw's standard job pipeline:activation→agent→safe_outputs. Thecall-workflowconditional jobs come aftersafe_outputs, which is the job that processes the agent's output and sets thecall_workflow_nameoutput variable.All worker references are resolved and validated at compile time. The agent's only runtime decision is which pre-validated worker to activate. Only one conditional job runs per execution; the rest are skipped. GitHub Actions handles skipped jobs efficiently — they don't consume minutes or runner slots.
What the markdown workflow would look like
How the agent selects a worker (per-worker MCP tools)
This is how
dispatch-workflowalready works, andcall-workflowwould reuse the same pattern.At compile time, the compiler reads each allowed worker's frontmatter, extracts its declared inputs, and generates a separate, named MCP tool per worker — with a schema that describes that worker's specific parameters. For example, given these two workers:
spring-boot-bugfix.mddeclares inputsenvironment(choice: dev/staging/production) andversion(string).frontend-dep-upgrade.mddeclares inputspackage_manager(choice: npm/yarn/pnpm) anddry_run(boolean).The compiler generates two MCP tools that the agent sees during its session:
spring_boot_bugfix(environment, version)— with enum constraints, descriptions, required flagsfrontend_dep_upgrade(package_manager, dry_run)— with typed parametersThe agent doesn't see a generic "call some workflow" tool. It sees rich, named, typed tools — identical to how
dispatch-workflowworks today. The code that does this already exists:generateDispatchWorkflowTool()inpkg/workflow/safe_outputs_dispatch.goreads each worker's inputs and generates the MCP tool definitionextractWorkflowDispatchInputs()inpkg/workflow/dispatch_workflow_validation.goextracts the input schema from each worker's frontmatteractions/setup/js/safe_outputs_tools_loader.cjsmaps per-workflow tool calls to the generic handlerWhen the agent calls
spring_boot_bugfix(environment: "staging", version: "1.2.3"), the handler sets output variables instead of making an API call:The conditional
uses:job forspring-boot-bugfixevaluates itsif:condition, finds a match, and runs.Input forwarding: the JSON envelope pattern
A key design decision is how to forward the agent's inputs to the selected worker. We considered two approaches:
workflow_call.inputs, exposes each as a separate output fromsafe_outputs, generates a differentwith:block per conditional jobsafe_outputsoutputs. Hits GitHub's 50 total inputs+secrets+outputs limit perworkflow_call. Compiler must generate a differentwith:block for every conditional job.payload: { type: string }). Agent output is serialised as JSON. All conditional jobs pass the samepayloadoutput.dispatch-workflowalready works — allworkflow_dispatchinput values are stringified at the API level.We recommend the JSON envelope pattern. It's simpler, doesn't hit platform limits, doesn't require the compiler to understand each worker's input schema for the
with:block, and matches the existingdispatch-workflowarchitecture. The per-worker MCP tools still give the agent a rich, typed interface — the JSON envelope is just the plumbing underneath.Ref pinning: not needed
Since the gateway and workers are in the same repo, the compiler generates relative paths:
When a consumer calls the gateway via
uses: org/platform/.github/workflows/gateway.lock.yml@main, GitHub resolves the gateway's internaluses: ./...references against the platform repo at the same ref. No explicit ref pinning or SHA resolution is needed.What the compiler would generate
What this approach preserves
workflow_call. No new trust boundaries. No cross-repo API calls.secrets: inheritflowsGITHUB_TOKENand all caller secrets through the chain. No PATs, no App tokens.workflow_callbilling rules.github.actorpreserved through theworkflow_callchain.workflowslist can be selected. Compile-time validated.dispatch-workflow— per-worker named MCP tools with typed parameters.Platform constraints this respects
uses:cannot contain expressionsif:conditionals — the only possible approach forworkflow_callchaining.workflow_callpayloadinput, not per-field.workflow_callnesting limitdispatch-workflowsame-repo onlydispatch-workflowat all. Pureworkflow_call.Scaling considerations
.lock.ymlbecomes large but functional. Skipped jobs in the Actions UI are noisy but don't consume resources.call-workflow), and each sub-gateway routes to ~10 workers. This uses 3 levels ofworkflow_callnesting (consumer → gateway → sub-gateway → worker), within the platform's 4-level limit.Implementation Plan
1. Add
CallWorkflowConfigstruct and parser (pkg/workflow/call_workflow.go)Create a new file following the existing
dispatch_workflow.gopattern:CallWorkflowConfigstruct withBaseSafeOutputConfiginline,Workflows []string, andWorkflowFiles map[string]string(mirroringDispatchWorkflowConfig).parseCallWorkflowConfig()on theCompiler— parse both array format (list of workflow names) and map format (withworkflows:,max:, etc.).maxto 1. Cap at a reasonable limit (e.g., 50, matchingdispatch-workflow).CallWorkflow *CallWorkflowConfigfield toSafeOutputsConfiginpkg/workflow/safe_outputs_config.go.2. Add schema validation (
pkg/parser/schemas/frontmatter.json)Add
call-workflowto the frontmatter JSON schema alongsidedispatch-workflow:call-workflow: [workflow-a, workflow-b]call-workflow: { workflows: [...], max: 1 }workflowsis a non-empty array of strings.After changing the schema, run
make build— schemas are embedded via//go:embed.3. Add compile-time validation (
pkg/workflow/call_workflow_validation.go)Create a validation file following the
dispatch_workflow_validation.gopattern:validateCallWorkflow()on theCompiler.findWorkflowFile()to locate each allowed worker's.lock.yml/.yml/.md.workflow_callin itson:section (not justworkflow_dispatch).workflow_call.inputsfor MCP tool generation (createextractWorkflowCallInputs()mirroringextractWorkflowDispatchInputs()).Compiler.validateWorkflow().4. Generate per-worker MCP tools (
pkg/workflow/safe_outputs_dispatch.goor new file)Reuse the existing
generateDispatchWorkflowTool()function or create agenerateCallWorkflowTool()variant:workflow_call.inputsschema._workflow_nameinternal metadata on each tool for handler routing.safe_outputs_tools_filtering.goalongside the existingdispatch_workflowtool generation block.5. Implement the runtime handler (
actions/setup/js/call_workflow.cjs)Create a handler that is simpler than
dispatch_workflow.cjs— it makes no API calls:workflow_nameagainst the compile-time allowlist.inputsas a JSON string.core.setOutput("call_workflow_name", workflowName).core.setOutput("call_workflow_payload", JSON.stringify(inputs)).safe_outputs_tools_loader.cjsalongside the existing dispatch handler (the_workflow_namerouting pattern already exists).6. Generate conditional
uses:jobs in the compiler (pkg/workflow/compiler_safe_output_jobs.go)This is the core change. In
buildSafeOutputsJobs():After building the consolidated
safe_outputsjob, ifdata.SafeOutputs.CallWorkflowis non-nil:CallWorkflow.Workflows, generate a conditional job:name:call-{workflow-name}(sanitised)needs:[safe_outputs]if:needs.safe_outputs.outputs.call_workflow_name == '{workflow-name}'uses:./.github/workflows/{workflow-name}.lock.ymlsecrets:inheritwith:payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}call-*job names to the conclusion job'sneedslist.Ensure the
safe_outputsjob declarescall_workflow_nameandcall_workflow_payloadas outputs.7. Populate workflow files at compile time (
pkg/workflow/safe_outputs_dispatch.goor new)Create
populateCallWorkflowFiles()mirroringpopulateDispatchWorkflowFiles():.lock.ymlfile path.CallWorkflow.WorkflowFilesfor runtime use.generateMCPSetup()inpkg/workflow/mcp_setup_generator.go(same pattern aspopulateDispatchWorkflowFiles).8. Add unit tests
Test files must have
//go:build !integrationas the first line, followed by an empty line.pkg/workflow/call_workflow_test.go:parseCallWorkflowConfig()with array and map formats.pkg/workflow/call_workflow_validation_test.go:workflow_calltrigger.workflow_calltrigger error.pkg/workflow/safe_outputs_call_workflow_test.go:workflow_call.inputs.uses:jobs with correctif:conditions.secrets: inheritis present on all conditional jobs.payloadis wired correctly inwith:blocks.call-*jobs.actions/setup/js/call_workflow.test.cjs:9. Update documentation
docs/src/content/docs/reference/safe-outputs.md: Addcall-workflowsection alongsidedispatch-workflow.docs/src/content/docs/reference/safe-outputs-specification.md: Add formal specification.docs/src/content/docs/examples/multi-repo.md: Add example of gateway → worker pattern usingcall-workflow.10. Follow guidelines
pkg/consolefor CLI output.[what's wrong]. [what's expected]. [example].make agent-finishbefore completing (build, test, recompile, fmt, lint)../scripts/add-build-tags.shto ensure all test files have correct build tags.make lintto catch unused helpers, testifylint issues, and misspell errors.