-
Notifications
You must be signed in to change notification settings - Fork 281
Description
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 viapost-steps:, and creates a pull request via thecreate-pull-requestsafe 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.actorreflects the developer who triggered the workflow. - Secret flow: Consumer's
COPILOT_GITHUB_TOKENflows viasecrets: 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 flagsfrontend_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()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 frontmatter- The runtime handler in
actions/setup/js/safe_outputs_tools_loader.cjsmaps 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.ymlWhen 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
CallWorkflowConfigstruct withBaseSafeOutputConfiginline,Workflows []string, andWorkflowFiles map[string]string(mirroringDispatchWorkflowConfig). - Implement
parseCallWorkflowConfig()on theCompiler— parse both array format (list of workflow names) and map format (withworkflows:,max:, etc.). - Default
maxto 1. Cap at a reasonable limit (e.g., 50, matchingdispatch-workflow). - Add
CallWorkflow *CallWorkflowConfigfield toSafeOutputsConfiginpkg/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
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.go pattern:
- Implement
validateCallWorkflow()on theCompiler. - Reuse
findWorkflowFile()to locate each allowed worker's.lock.yml/.yml/.md. - Validate each worker exists and declares
workflow_callin itson:section (not justworkflow_dispatch). - Validate self-reference prevention (gateway cannot call itself).
- Extract each worker's
workflow_call.inputsfor MCP tool generation (createextractWorkflowCallInputs()mirroringextractWorkflowDispatchInputs()). - 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.inputsschema. - Generate a named MCP tool per worker with typed parameters (string, number, boolean, choice).
- Set
_workflow_nameinternal metadata on each tool for handler routing. - Wire into
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:
- Validate
workflow_nameagainst the compile-time allowlist. - Serialise
inputsas 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.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:- 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.ymlsecrets:inheritwith:payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
- Add all
call-*job names to the conclusion job'sneedslist.
- For each workflow in
-
Ensure the
safe_outputsjob declarescall_workflow_nameandcall_workflow_payloadas 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.ymlfile path. - Store in
CallWorkflow.WorkflowFilesfor runtime use. - Call from
generateMCPSetup()inpkg/workflow/mcp_setup_generator.go(same pattern aspopulateDispatchWorkflowFiles).
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.
- Test
pkg/workflow/call_workflow_validation_test.go:- Test validation of existing workers with
workflow_calltrigger. - Test self-reference prevention.
- Test worker not found error.
- Test worker without
workflow_calltrigger error.
- Test validation of existing workers with
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 correctif:conditions. - Test
secrets: inheritis present on all conditional jobs. - Test
payloadis wired correctly inwith:blocks. - Test conclusion job depends on all
call-*jobs.
- Test MCP tool generation from worker
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: 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
- Use console formatting from
pkg/consolefor CLI output. - Follow error message style guide:
[what's wrong]. [what's expected]. [example]. - Run
make agent-finishbefore completing (build, test, recompile, fmt, lint). - Run
./scripts/add-build-tags.shto ensure all test files have correct build tags. - Run
make lintto catch unused helpers, testifylint issues, and misspell errors.