-
Notifications
You must be signed in to change notification settings - Fork 301
Description
Summary
In the latest checked gh aw release (v0.58.3 on 2026-03-15), safe-outputs.call-workflow generates a typed MCP tool from the worker's declared workflow_call.inputs, but the compiled fan-out job forwards only a single payload input to the reusable workflow.
This creates an architectural mismatch: worker authors are encouraged to declare typed workflow_call.inputs, but those inputs are not actually populated in the generated uses: job. In real workflows this forces one of two bad outcomes: either add post-compile patches to inject explicit with: keys derived from call_workflow_payload, or rewrite worker frontmatter to parse fromJSON(inputs.payload) in places like checkout, token scoping, and safe-output targets—an approach that can fail strict expression validation in some frontmatter contexts.
Root Cause
The current implementation in v0.58.3 and upstream main has three connected pieces:
pkg/workflow/safe_outputs_tools_filtering.goandpkg/workflow/safe_outputs_call_workflow.gocorrectly read the worker's declaredworkflow_call.inputsand generate a typed MCP tool from them.actions/setup/js/call_workflow.cjsserializes the selected tool arguments into a single JSON string and writes onlycall_workflow_payloadpluscall_workflow_name.pkg/workflow/compiler_safe_output_jobs.gohardcodes the generated fan-out job'swith:block to only:
With: map[string]any{
"payload": "${{ needs.safe_outputs.outputs.call_workflow_payload }}",
},That means the compiler exposes typed worker inputs to the agent, but then drops them when generating the reusable-workflow call.
Relevant current implementation points:
pkg/workflow/compiler_safe_output_jobs.go—buildCallWorkflowJobs()builds the generateduses:jobs and currently forwards onlypayload.pkg/workflow/call_workflow_validation.go—extractWorkflowCallInputs()/extractWorkflowCallInputsFromParsed()already know how to read the worker's declaredworkflow_call.inputs.pkg/workflow/safe_outputs_tools_filtering.go—generateFilteredToolsJSON()already extracts those inputs to build the dynamic MCP tool schema.actions/setup/js/call_workflow.cjs— the runtime handler serializes the selected arguments intocall_workflow_payload.docs/src/content/docs/reference/safe-outputs.md— the docs currently state that worker inputs are forwarded as a single JSON-encodedpayloadstring.
The core bug is therefore not in tool generation or runtime serialization; it is in the compiler's final fan-out step, which does not project the declared worker inputs back out of the canonical payload into the generated with: block.
Affected Code
Current generated fan-out job shape (pkg/workflow/compiler_safe_output_jobs.go):
callJob := &Job{
Name: jobName,
Needs: []string{"safe_outputs"},
If: fmt.Sprintf("needs.safe_outputs.outputs.call_workflow_name == '%s'", workflowName),
Uses: workflowPath,
SecretsInherit: true,
With: map[string]any{
"payload": "${{ needs.safe_outputs.outputs.call_workflow_payload }}",
},
}Current docs promise typed worker inputs for agent selection, but compiled output still only forwards payload:
The compiler also reads typed inputs declared on the worker and exposes them as parameters on the generated MCP tool, so the agent can provide structured values.Current compiled output example in the docs:
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 }}What is wrong:
- The agent can submit structured arguments for declared worker inputs.
- The called workflow does not receive those declared inputs unless it manually parses
inputs.payload. - Many reusable-workflow fields are much easier and more idiomatic with normal
inputs.*references. - In strict mode, using
fromJSON(inputs.payload)in frontmatter expressions can fail validation, sopayload-only is not always a viable authoring model.
Proposed Fix
Keep payload as the canonical transport, but make the compiler also auto-forward matching declared worker inputs into the generated with: block.
Architecturally:
payloadremains the single source of truth emitted bycall_workflow.cjs.- The compiler derives typed worker inputs from the worker's declared
workflow_call.inputsschema. - The generated fan-out job forwards both:
payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}- one
with:entry per declared worker input (exceptpayload) usingfromJSON(needs.safe_outputs.outputs.call_workflow_payload).<inputName>.
Paste-ready replacement sketch for buildCallWorkflowJobs() in pkg/workflow/compiler_safe_output_jobs.go:
func (c *Compiler) buildCallWorkflowJobs(data *WorkflowData, markdownPath string) ([]string, error) {
if data.SafeOutputs == nil || data.SafeOutputs.CallWorkflow == nil {
return nil, nil
}
config := data.SafeOutputs.CallWorkflow
if len(config.Workflows) == 0 {
return nil, nil
}
compilerSafeOutputJobsLog.Printf("Building %d call-workflow fan-out jobs", len(config.Workflows))
var jobNames []string
for _, workflowName := range config.Workflows {
sanitizedName := sanitizeJobName(workflowName)
jobName := "call-" + sanitizedName
workflowPath, ok := config.WorkflowFiles[workflowName]
if !ok || workflowPath == "" {
workflowPath = fmt.Sprintf("./.github/workflows/%s.lock.yml", workflowName)
}
resolvedFile, err := findWorkflowFile(workflowName, markdownPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve call-workflow target %q: %w", workflowName, err)
}
var workflowInputs map[string]any
switch resolvedFile.Extension {
case ".md":
workflowInputs, err = extractMDWorkflowCallInputs(resolvedFile.AbsolutePath)
default:
workflowInputs, err = extractWorkflowCallInputs(resolvedFile.AbsolutePath)
}
if err != nil {
return nil, fmt.Errorf("failed to extract workflow_call inputs for %q: %w", workflowName, err)
}
with := map[string]any{
"payload": "${{ needs.safe_outputs.outputs.call_workflow_payload }}",
}
for inputName := range workflowInputs {
if inputName == "payload" {
continue
}
with[inputName] = fmt.Sprintf("${{ fromJSON(needs.safe_outputs.outputs.call_workflow_payload).%s }}", inputName)
}
callJob := &Job{
Name: jobName,
Needs: []string{"safe_outputs"},
If: fmt.Sprintf("needs.safe_outputs.outputs.call_workflow_name == '%s'", workflowName),
Uses: workflowPath,
SecretsInherit: true,
With: with,
}
if err := c.jobManager.AddJob(callJob); err != nil {
return nil, fmt.Errorf("failed to add call-workflow job '%s': %w", jobName, err)
}
jobNames = append(jobNames, jobName)
compilerSafeOutputJobsLog.Printf("Added call-workflow job: %s (uses: %s)", jobName, workflowPath)
}
return jobNames, nil
}Companion change: update the buildCallWorkflowJobs() call site in pkg/workflow/compiler_safe_output_jobs.go to pass markdownPath.
Documentation update: revise docs/src/content/docs/reference/safe-outputs.md so payload is documented as the canonical transport and declared worker inputs are documented as compiler-derived forwarded inputs in generated fan-out jobs.
Implementation Plan
-
Update fan-out generation in
pkg/workflow/compiler_safe_output_jobs.go:- Change
buildCallWorkflowJobs(data *WorkflowData)tobuildCallWorkflowJobs(data *WorkflowData, markdownPath string). - Reuse existing worker-resolution logic (
findWorkflowFile) to locate the selected worker source/lock file. - Reuse existing input-extraction helpers from
pkg/workflow/call_workflow_validation.goto load declaredworkflow_call.inputs. - Build the
Withmap dynamically:- always include
payload - for each declared worker input except
payload, addfromJSON(needs.safe_outputs.outputs.call_workflow_payload).<inputName>.
- always include
- Change
-
Update the caller in
pkg/workflow/compiler_safe_output_jobs.go:- Pass
markdownPathintobuildCallWorkflowJobs(...)frombuildSafeOutputsJobs(...).
- Pass
-
Add focused unit tests in
pkg/workflow/safe_outputs_call_workflow_test.go:- Add a test such as
TestBuildCallWorkflowJobs_ForwardsDeclaredInputsFromPayload. - Assert that a worker declaring inputs like
environment,version, andpayloadgenerates aWithmap containing:payloadenvironment: ${{ fromJSON(needs.safe_outputs.outputs.call_workflow_payload).environment }}version: ${{ fromJSON(needs.safe_outputs.outputs.call_workflow_payload).version }}
- Assert that
payloadis not duplicated or overwritten.
- Add a test such as
-
Add end-to-end compile coverage in
pkg/workflow/call_workflow_compilation_test.go:- Add a test such as
TestCallWorkflowCompile_ForwardsTypedInputsAlongsidePayload. - Create a worker with
workflow_call.inputscontainingpayloadplus at least one typed field. - Compile a gateway and assert the generated YAML contains:
payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}- an additional forwarded
with:key derived fromfromJSON(...).
- Add a test such as
-
Update docs in
docs/src/content/docs/reference/safe-outputs.md:- Revise the
#### Worker Inputssection. - Revise the
#### Compiled Outputexample to show bothpayloadand derived typed inputs. - Clarify that typed worker inputs are derived from the canonical payload, not an independent second transport.
- Revise the
-
Run standard validation:
make testmake recompilemake agent-finish
Reproduction
- Use
gh awv0.58.3. - Create a worker workflow that declares reusable-workflow inputs like this:
on:
workflow_call:
inputs:
payload:
type: string
required: false
source_repo:
type: string
required: false
issue_number:
type: string
required: false- In that worker, use the declared inputs in normal reusable-workflow fields, for example:
checkout:
repository: ${{ inputs.source_repo }}- Create a gateway with:
safe-outputs:
call-workflow:
workflows: [worker-a]- Run
gh aw compile gateway --strictand inspect the generatedcall-worker-ajob. - Observe that the compiled YAML forwards only:
with:
payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}- The declared worker inputs are exposed to the agent but are not forwarded to the called workflow.
- If you try to avoid that by rewriting worker frontmatter to parse
fromJSON(inputs.payload)directly, strict compilation can fail with an error like:
Validation failed for field 'expressions'
Value: 2 unauthorized expressions found
Reason: expressions are not in the allowed list:
- fromJSON(inputs.payload).issue_number
- fromJSON(inputs.payload).source_repo
Failing step name from the consumer reproduction:
gh aw compile <worker> --strict
Failing run:
Private consumer reproduction run available; raw URL omitted because the repository is private.