Skip to content

fix: propagate worker job permissions to call-workflow caller jobs (#21061)#21080

Merged
pelikhan merged 6 commits intomainfrom
copilot/fix-call-workflow-permissions
Mar 15, 2026
Merged

fix: propagate worker job permissions to call-workflow caller jobs (#21061)#21080
pelikhan merged 6 commits intomainfrom
copilot/fix-call-workflow-permissions

Conversation

Copy link
Contributor

Copilot AI commented Mar 15, 2026

Summary

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

Problem

buildCallWorkflowJobs() generated call-* reusable-workflow caller jobs with no permissions: block. Because gh-aw writes permissions: {} at the top level, the caller job inherited none for all scopes, and GitHub rejected any nested worker job that declared permissions before execution started.

Representative failures:

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'.

Solution

At compile time, resolve the target worker file (.lock.yml > .yml > .md), union all job-level permissions blocks found in that file, and attach the result as a permissions: block on the generated call-* job.

Changes

New file: pkg/workflow/call_workflow_permissions.go

Three focused helpers:

Function Purpose
extractJobPermissionsFromParsedWorkflow Iterates a parsed map[string]any workflow, merges every job's permissions: into a single superset
extractCallWorkflowPermissions Resolves the worker file via existing findWorkflowFile(), dispatches to the right reader
extractPermissionsFromYAMLFile / extractPermissionsFromMDFile Read .lock.yml/.yml or .md frontmatter respectively

For same-batch .md-only workers (not yet compiled), the frontmatter-level permissions: is used as a proxy — the compiler will turn it into per-job permissions when the worker is eventually compiled.

Modified: compiler_safe_output_jobs.go

  • buildCallWorkflowJobs now accepts markdownPath string
  • After building each callJob, calls extractCallWorkflowPermissions; if a non-empty permission set is found, sets callJob.Permissions = perms.RenderToYAML()
  • Permission extraction errors are non-fatal (logged as warnings) so a missing or malformed worker file does not abort compilation

Modified: safe_outputs_call_workflow_test.go

Updated four existing call sites to pass "" as markdownPath (maintains original behaviour — no permission extraction attempted).

New tests: call_workflow_permissions_test.go

15 tests covering:

  • extractJobPermissionsFromParsedWorkflow: no jobs, single job, multiple jobs with write-wins-read merging, jobs with no permissions
  • extractCallWorkflowPermissions: from .lock.yml, from .yml, from .md frontmatter, file-not-found (returns nil), .lock.yml priority over .yml when both exist
  • buildCallWorkflowJobs: permissions set from .lock.yml, permissions set from .md, no permissions when worker has none
  • YAML output: permissions: block present and rendered before uses:
  • End-to-end CompileWorkflow with multiple workers and different permission scopes (.lock.yml workers)
  • End-to-end CompileWorkflow with plain .yml worker

Before / After

Before:

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

After:

call-worker-docs:
  needs: safe_outputs
  if: needs.safe_outputs.outputs.call_workflow_name == 'worker-docs'
  permissions:
    actions: read
    contents: write
    issues: write
    pull-requests: write
  uses: ./.github/workflows/worker-docs.lock.yml
  with:
    payload: ${{ needs.safe_outputs.outputs.call_workflow_payload }}
  secrets: inherit

Security Summary

No new security vulnerabilities introduced. The two #nosec G304 suppressions for os.ReadFile are safe because all file paths originate from findWorkflowFile(), which validates every path via isPathWithinDir() before returning them, preventing directory traversal.


📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.

Copilot AI and others added 3 commits March 15, 2026 13:29
When buildCallWorkflowJobs() generates conditional `uses:` fan-out jobs
for call-workflow safe outputs, it now computes and attaches a
permissions block that is the union of all nested job permissions in the
target worker workflow.

GitHub validates reusable workflow calls against the caller job's
declared permission envelope. Without a permissions block on the call-*
job, nested worker jobs that request any non-none permissions are
rejected before execution starts.

- Add call_workflow_permissions.go with three helpers:
  * extractJobPermissionsFromParsedWorkflow: unions all job-level
    permissions from a parsed workflow map
  * extractCallWorkflowPermissions: resolves the worker file
    (.lock.yml > .yml > .md) and extracts permissions
  * extractPermissionsFromYAMLFile / extractPermissionsFromMDFile:
    file-type-specific readers

- Modify buildCallWorkflowJobs to accept markdownPath and call
  extractCallWorkflowPermissions for each generated call-* job;
  sets callJob.Permissions when a non-empty permission set is found

- Add regression tests in call_workflow_permissions_test.go covering
  .lock.yml, .yml, .md, and missing-file cases, plus YAML output checks

Fixes #21061

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
- Clarify nosec comments in call_workflow_permissions.go to explicitly
  reference the isPathWithinDir() pre-validation performed by
  findWorkflowFile()
- Improve warning message in buildCallWorkflowJobs to explain impact
  and suggest remediation when permissions cannot be extracted
- Add require.NotEqual guards before index comparison in YAML ordering
  test to prevent false passes when strings are absent

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI requested a review from pelikhan March 15, 2026 13:54
@pelikhan pelikhan marked this pull request as ready for review March 15, 2026 13:55
Copilot AI review requested due to automatic review settings March 15, 2026 13:55
@pelikhan
Copy link
Contributor

@copilot add tests

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Propagates required job-level permissions into generated call-* reusable-workflow caller jobs so nested worker jobs aren’t rejected when the top-level workflow sets permissions: {}.

Changes:

  • Adds permission extraction utilities to union worker job permissions from .lock.yml / .yml (or .md frontmatter as a proxy).
  • Updates buildCallWorkflowJobs to compute and attach the permission superset to each generated call-* job.
  • Adds/updates unit tests to cover permission extraction and YAML rendering behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
pkg/workflow/call_workflow_permissions.go New helpers to resolve worker workflow files and extract/merge permissions.
pkg/workflow/compiler_safe_output_jobs.go Threads markdownPath into buildCallWorkflowJobs and sets callJob.Permissions based on extracted permissions.
pkg/workflow/safe_outputs_call_workflow_test.go Updates existing tests to match the new buildCallWorkflowJobs signature.
pkg/workflow/call_workflow_permissions_test.go New regression tests for permission extraction and call-job YAML output ordering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +59 to +60
// Returns nil when no workflow file is found or no permissions are declared.
// The caller should omit the permissions block on the call-* job in that case.
Comment on lines +118 to +119
if err != nil || result == nil {
callWorkflowPermissionsLog.Printf("Failed to extract frontmatter from %s: %v", mdPath, err)
rendered := perms.RenderToYAML()
if rendered != "" {
callJob.Permissions = rendered
compilerSafeOutputJobsLog.Printf("Set permissions on call-workflow job '%s': %s", jobName, rendered)
- TestExtractCallWorkflowPermissions_LockYMLPriorityOverYML: verifies
  .lock.yml takes priority over .yml when both exist for a worker
- TestCallWorkflowPermissions_EndToEnd: full CompileWorkflow run with
  two workers that have different job-level permissions; asserts each
  generated call-* job in the lock file contains the correct superset
- TestCallWorkflowPermissions_EndToEnd_YMLWorker: same end-to-end check
  for workers delivered as plain .yml files (no .lock.yml counterpart)

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 15, 2026

@copilot add tests

Added 3 more tests in 8587422:

  • TestExtractCallWorkflowPermissions_LockYMLPriorityOverYML — verifies .lock.yml takes priority over .yml when both exist
  • TestCallWorkflowPermissions_EndToEnd — full CompileWorkflow run with two workers carrying different job-level permissions; asserts each call-* job in the generated lock file has the correct permission superset (including write-wins-read merging across multiple worker jobs)
  • TestCallWorkflowPermissions_EndToEnd_YMLWorker — same end-to-end check for workers delivered as plain .yml files

@pelikhan
Copy link
Contributor

The integration test TestAddWorkflowWithDispatchWorkflowFromSharedImport
fetches smoke-copilot.md from main which uses close-older-key in both
create-issue and create-discussion safe outputs. The schema was missing
this property, causing schema validation to fail and the compiled
lock file to not be generated.

- Add close-older-key string property to create-issue schema
- Add close-older-key string property to create-discussion schema
- Sync smoke-copilot.md with main (add close-older-key fields)
- Rebuild binary and recompile smoke-copilot.lock.yml

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 15, 2026

@copilot Fix https://github.com/github/gh-aw/actions/runs/23111931822/job/67131961359#step:8:1

Fixed in ee26f48. The smoke-copilot.md on main uses close-older-key in both create-issue and create-discussion, but the JSON schema was missing that property, causing validation to fail and the lock file to not be generated. Added close-older-key as a string property to both schemas, synced smoke-copilot.md with main, and recompiled the lock file.

@pelikhan
Copy link
Contributor

@copilot merge main and recompile

- Resolve schema conflict: use main's close-older-key description with
  pattern constraint (\\S) for both create-issue and create-discussion
- Rebuild binary with updated embedded schema
- Recompile all 173 workflow files

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 15, 2026

@copilot merge main and recompile

Done in 73152ae. Merged main, resolved the close-older-key schema conflict (used main's description + pattern constraint), rebuilt the binary, and recompiled all 173 workflows.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/graphql
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw (http block)
    • Triggering command: /usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw -embedcfg /tmp/go-build966270669/b336/embedcfg -pack (http block)
  • https://api.github.com/repos/astral-sh/setup-uv/git/ref/tags/eac588ad8def6316056a12d4907a9d4d84ff7a3b
    • Triggering command: /usr/bin/gh gh api /repos/astral-sh/setup-uv/git/ref/tags/eac588ad8def6316056a12d4907a9d4d84ff7a3b --jq .object.sha (http block)
  • https://api.github.com/repos/github/gh-aw
    • Triggering command: /usr/bin/gh gh api /repos/github/gh-aw --jq .visibility (http block)
  • https://api.github.com/repos/githubnext/agentics/git/ref/tags/
    • Triggering command: /usr/bin/gh gh api /repos/githubnext/agentics/git/ref/tags/# --jq .object.sha (http block)

If you need me to access, download, or install something from one of these locations, you can either:

@pelikhan pelikhan merged commit c8ee6a4 into main Mar 15, 2026
53 checks passed
@pelikhan pelikhan deleted the copilot/fix-call-workflow-permissions branch March 15, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants