Skip to content

call-workflow fan-out jobs do not forward declared workflow_call.inputs beyond payload #21062

@johnwilliams-12

Description

@johnwilliams-12

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:

  1. pkg/workflow/safe_outputs_tools_filtering.go and pkg/workflow/safe_outputs_call_workflow.go correctly read the worker's declared workflow_call.inputs and generate a typed MCP tool from them.
  2. actions/setup/js/call_workflow.cjs serializes the selected tool arguments into a single JSON string and writes only call_workflow_payload plus call_workflow_name.
  3. pkg/workflow/compiler_safe_output_jobs.go hardcodes the generated fan-out job's with: 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.gobuildCallWorkflowJobs() builds the generated uses: jobs and currently forwards only payload.
  • pkg/workflow/call_workflow_validation.goextractWorkflowCallInputs() / extractWorkflowCallInputsFromParsed() already know how to read the worker's declared workflow_call.inputs.
  • pkg/workflow/safe_outputs_tools_filtering.gogenerateFilteredToolsJSON() already extracts those inputs to build the dynamic MCP tool schema.
  • actions/setup/js/call_workflow.cjs — the runtime handler serializes the selected arguments into call_workflow_payload.
  • docs/src/content/docs/reference/safe-outputs.md — the docs currently state that worker inputs are forwarded as a single JSON-encoded payload string.

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, so payload-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:

  • payload remains the single source of truth emitted by call_workflow.cjs.
  • The compiler derives typed worker inputs from the worker's declared workflow_call.inputs schema.
  • The generated fan-out job forwards both:
    • payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
    • one with: entry per declared worker input (except payload) using fromJSON(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

  1. Update fan-out generation in pkg/workflow/compiler_safe_output_jobs.go:

    • Change buildCallWorkflowJobs(data *WorkflowData) to buildCallWorkflowJobs(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.go to load declared workflow_call.inputs.
    • Build the With map dynamically:
      • always include payload
      • for each declared worker input except payload, add fromJSON(needs.safe_outputs.outputs.call_workflow_payload).<inputName>.
  2. Update the caller in pkg/workflow/compiler_safe_output_jobs.go:

    • Pass markdownPath into buildCallWorkflowJobs(...) from buildSafeOutputsJobs(...).
  3. 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, and payload generates a With map containing:
      • payload
      • environment: ${{ fromJSON(needs.safe_outputs.outputs.call_workflow_payload).environment }}
      • version: ${{ fromJSON(needs.safe_outputs.outputs.call_workflow_payload).version }}
    • Assert that payload is not duplicated or overwritten.
  4. 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.inputs containing payload plus 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 from fromJSON(...).
  5. Update docs in docs/src/content/docs/reference/safe-outputs.md:

    • Revise the #### Worker Inputs section.
    • Revise the #### Compiled Output example to show both payload and derived typed inputs.
    • Clarify that typed worker inputs are derived from the canonical payload, not an independent second transport.
  6. Run standard validation:

    • make test
    • make recompile
    • make agent-finish

Reproduction

  1. Use gh aw v0.58.3.
  2. 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
  1. In that worker, use the declared inputs in normal reusable-workflow fields, for example:
checkout:
  repository: ${{ inputs.source_repo }}
  1. Create a gateway with:
safe-outputs:
  call-workflow:
    workflows: [worker-a]
  1. Run gh aw compile gateway --strict and inspect the generated call-worker-a job.
  2. Observe that the compiled YAML forwards only:
with:
  payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
  1. The declared worker inputs are exposed to the agent but are not forwarded to the called workflow.
  2. 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.

Metadata

Metadata

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions