Skip to content

call-workflow generated caller jobs omit required permissions: for reusable workflows #21071

@johnwilliams-12

Description

@johnwilliams-12

Summary

As of gh aw v0.58.3 and current main, safe-outputs.call-workflow still generates conditional uses: jobs without a job-level permissions: block.

GitHub validates reusable workflow calls against the caller job's declared permission envelope. When the selected worker requests any non-none permissions in nested jobs, GitHub rejects the generated call-* job before execution starts.

This issue is specifically about the compiler-generated call-* jobs created by safe-outputs.call-workflow. It is not about outer handwritten relay workflows or general reusable-workflow permission rules.

Why this still looks relevant after the recent gh aw update

The changelog entries after v0.58.1 add several safe-outputs and workflow_call fixes, but they do not address permission propagation for generated call-* jobs:

  • v0.58.2 fixes safe-output manifest accounting (#20899)
  • v0.58.2 fixes idle HTTP server disconnects in the safe-outputs MCP server (#20901)
  • v0.58.3 fixes artifact-prefix downloads in workflow_call downstream jobs (#21011)

Current main still contains the same buildCallWorkflowJobs() shape that creates uses: jobs with needs, if, uses, secrets: inherit, and with.payload, but no permissions:.

Current behavior

pkg/workflow/compiler_safe_output_jobs.go still generates call-workflow fan-out jobs like this:

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 }}",
	},
}

That renders YAML like:

call-worker-docs:
  needs: [safe_outputs]
  if: needs.safe_outputs.outputs.call_workflow_name == 'worker-docs'
  uses: ./.github/workflows/worker-docs.lock.yml
  secrets: inherit
  with:
    payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}

There is still no permissions: block on the caller job.

Why GitHub rejects this

gh aw writes top-level permissions: {} and relies on job-level permissions for actual access.

For reusable workflows, GitHub compares the caller job's declared permissions with the permissions requested by nested jobs in the called workflow. If the caller job omits permissions:, the nested worker jobs are effectively constrained to none and validation fails before the workflow runs.

Representative failures look like:

The nested job 'activation' is requesting 'contents: read', but is only allowed 'contents: none'.

The nested job 'agent' is requesting 'actions: read, contents: read, issues: read, pull-requests: read',
but is only allowed 'actions: none, contents: none, issues: none, pull-requests: none'.

Reproduction

  1. Use gh aw v0.58.3.
  2. Create a gateway workflow that uses safe-outputs.call-workflow.
  3. Point it at a reusable worker whose nested jobs request permissions such as:
    • activation: contents: read
    • agent: actions: read, contents: read, issues: read, pull-requests: read
    • safe_outputs or conclusion: contents: write, issues: write, pull-requests: write
  4. Compile the gateway.
  5. Inspect the generated call-* job: it has no permissions: block.
  6. Run the workflow so GitHub validates the reusable workflow call.
  7. GitHub rejects the call before execution starts because the caller job does not grant the worker's required permissions.

Expected behavior

When gh aw generates a call-* reusable-workflow caller job, it also generates a job-level permissions: block that is a superset of the permissions required by the selected worker workflow.

Actual behavior

gh aw generates the call-* job without permissions:, so reusable workers that request permissions fail GitHub validation before they run.

Proposed fix

Teach buildCallWorkflowJobs() to compute and attach a permission superset for each generated caller job.

Suggested approach:

  1. Resolve the selected worker file using the existing workflow-file discovery logic.
  2. Prefer compiled .lock.yml / .yml when available.
  3. Union all nested job-level permissions from the resolved worker workflow.
  4. For same-batch .md workers, fall back to source-derived permissions so compilation still succeeds.
  5. Render the merged result into callJob.Permissions before adding the job.

Minimal acceptance criteria

  • Generated call-* jobs include permissions: when the target worker requires permissions.
  • The generated permission set is at least the union of the called workflow's nested job permissions.
  • Regression tests cover .lock.yml, .yml, and same-batch .md worker targets.

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