From 511189e15e58e3cad3b59e1182889261bf26a0d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:56:26 +0000 Subject: [PATCH 1/7] Plan call-workflow permission comment update Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-copilot-aoai-entra.lock.yml | 4 ++-- .github/workflows/smoke-copilot.lock.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/smoke-copilot-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-aoai-entra.lock.yml index 39b8d8a865a..06cce6097a1 100644 --- a/.github/workflows/smoke-copilot-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-entra.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aedaddef3f058e3390275c268927dc411726ff550ae0c1130dbfaf30cbba86cb","body_hash":"2889d48bbc10acf2afe7b2e91d801eca31de7eba05b2775c1e506adad86eb7e4","strict":true,"agent_id":"copilot","agent_model":"o4-mini-aw","engine_versions":{"copilot":"1.0.65"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aedaddef3f058e3390275c268927dc411726ff550ae0c1130dbfaf30cbba86cb","body_hash":"2889d48bbc10acf2afe7b2e91d801eca31de7eba05b2775c1e506adad86eb7e4","agent_id":"copilot","agent_model":"o4-mini-aw","engine_versions":{"copilot":"1.0.65"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","FOUNDRY_OPENAI_ENDPOINT","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"f9f3042f7e2789586610d6e8b85c8f03e5195baf","version":"v7.2.0"},{"repo":"docker/setup-buildx-action","sha":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5","version":"v4.1.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.10","digest":"sha256:e47878fa4953f5b4d38b4ec12c155aa12ab9befea299ea2d21a8b104de8bcbc8","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.10@sha256:e47878fa4953f5b4d38b4ec12c155aa12ab9befea299ea2d21a8b104de8bcbc8"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.10","digest":"sha256:4bd2598466928efbd360fd6575b68c6b420a7ec3b7c1be20844c560a0dd2878e","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.10@sha256:4bd2598466928efbd360fd6575b68c6b420a7ec3b7c1be20844c560a0dd2878e"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.10"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.10","digest":"sha256:4d7a79482c47f2390f9fa87663cd9cb728bfb2380d9a9610479fa234c906ea98","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.10@sha256:4d7a79482c47f2390f9fa87663cd9cb728bfb2380d9a9610479fa234c906ea98"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:4d0101d8740c99b755181d19dc0067ac7eb40433d1c354fd715358bee4a296c1","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:4d0101d8740c99b755181d19dc0067ac7eb40433d1c354fd715358bee4a296c1"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"},{"image":"ghcr.io/github/serena-mcp-server:latest","digest":"sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5","pinned_image":"ghcr.io/github/serena-mcp-server:latest@sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -173,7 +173,7 @@ jobs: GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "🧪" - GH_AW_COMPILED_STRICT: "true" + GH_AW_COMPILED_STRICT: "false" uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 7ce9d0e6094..c75c01038bf 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6669d3fc0bdfdd7a9e54e10d8c1e41c04dc17e1d162f2e639dabdb1255ee8a78","body_hash":"812899a64607d2d204003410dba1febf488c85700602e8702b89dd7db3609096","strict":true,"agent_id":"copilot","agent_model":"gpt-5.4","engine_versions":{"copilot":"1.0.65"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6669d3fc0bdfdd7a9e54e10d8c1e41c04dc17e1d162f2e639dabdb1255ee8a78","body_hash":"812899a64607d2d204003410dba1febf488c85700602e8702b89dd7db3609096","agent_id":"copilot","agent_model":"gpt-5.4","engine_versions":{"copilot":"1.0.65"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"f9f3042f7e2789586610d6e8b85c8f03e5195baf","version":"v7.2.0"},{"repo":"docker/setup-buildx-action","sha":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5","version":"v4.1.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.10","digest":"sha256:e47878fa4953f5b4d38b4ec12c155aa12ab9befea299ea2d21a8b104de8bcbc8","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.10@sha256:e47878fa4953f5b4d38b4ec12c155aa12ab9befea299ea2d21a8b104de8bcbc8"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.10","digest":"sha256:4bd2598466928efbd360fd6575b68c6b420a7ec3b7c1be20844c560a0dd2878e","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.10@sha256:4bd2598466928efbd360fd6575b68c6b420a7ec3b7c1be20844c560a0dd2878e"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.10"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.10","digest":"sha256:4d7a79482c47f2390f9fa87663cd9cb728bfb2380d9a9610479fa234c906ea98","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.10@sha256:4d7a79482c47f2390f9fa87663cd9cb728bfb2380d9a9610479fa234c906ea98"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:4d0101d8740c99b755181d19dc0067ac7eb40433d1c354fd715358bee4a296c1","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:4d0101d8740c99b755181d19dc0067ac7eb40433d1c354fd715358bee4a296c1"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"},{"image":"ghcr.io/github/serena-mcp-server:latest","digest":"sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5","pinned_image":"ghcr.io/github/serena-mcp-server:latest@sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -173,7 +173,7 @@ jobs: GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "🧪" - GH_AW_COMPILED_STRICT: "true" + GH_AW_COMPILED_STRICT: "false" GH_AW_INFO_MODEL_COSTS: '{"providers":{"anthropic":{"models":{"my-custom-claude":{"cost":{"cache_read":"3e-07","cache_write":"3.75e-06","input":"3e-06","output":"1.5e-05"}}}}}}' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: From 46070c79b3c6b85c609d75b7702f268d9ba5e3a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:57:25 +0000 Subject: [PATCH 2/7] Add workflow_call permission comment support Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/call_workflow_permissions.go | 70 ++++++++++++++++++- .../call_workflow_permissions_test.go | 44 ++++++++++++ pkg/workflow/compiler_safe_output_jobs.go | 16 +++-- pkg/workflow/jobs.go | 6 ++ 4 files changed, 126 insertions(+), 10 deletions(-) diff --git a/pkg/workflow/call_workflow_permissions.go b/pkg/workflow/call_workflow_permissions.go index 609581df7fd..ee6f0afed1b 100644 --- a/pkg/workflow/call_workflow_permissions.go +++ b/pkg/workflow/call_workflow_permissions.go @@ -3,14 +3,23 @@ package workflow import ( "fmt" "os" + "path/filepath" "sort" + "strings" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" ) var callWorkflowPermissionsLog = logger.New("workflow:call_workflow_permissions") +type callWorkflowPermissionImport struct { + permissions *Permissions + sourcePath string + sourceKind string +} + // permissionLevelRank maps a permission level to a comparable rank where a higher // number grants strictly more access (none < read < write). Used to determine // whether one permission set covers another. Unknown or empty levels rank as 0. @@ -121,6 +130,14 @@ func extractJobPermissionsFromParsedWorkflow(workflow map[string]any) *Permissio // extractJobPermissionsFromParsedWorkflow initialises a fresh Permissions map // regardless of whether any jobs declare a permissions block. func extractCallWorkflowPermissions(workflowName, markdownPath string) (*Permissions, error) { + imported, err := extractCallWorkflowPermissionImport(workflowName, markdownPath) + if err != nil || imported == nil { + return nil, err + } + return imported.permissions, nil +} + +func extractCallWorkflowPermissionImport(workflowName, markdownPath string) (*callWorkflowPermissionImport, error) { fileResult, err := findWorkflowFile(workflowName, markdownPath) if err != nil { return nil, fmt.Errorf("failed to find workflow file for '%s': %w", workflowName, err) @@ -128,15 +145,39 @@ func extractCallWorkflowPermissions(workflowName, markdownPath string) (*Permiss // Priority: .lock.yml > .yml > .md if fileResult.lockExists { - return extractPermissionsFromYAMLFile(fileResult.lockPath) + perms, err := extractPermissionsFromYAMLFile(fileResult.lockPath) + if err != nil { + return nil, err + } + return &callWorkflowPermissionImport{ + permissions: perms, + sourcePath: fileResult.lockPath, + sourceKind: "compiled", + }, nil } if fileResult.ymlExists { - return extractPermissionsFromYAMLFile(fileResult.ymlPath) + perms, err := extractPermissionsFromYAMLFile(fileResult.ymlPath) + if err != nil { + return nil, err + } + return &callWorkflowPermissionImport{ + permissions: perms, + sourcePath: fileResult.ymlPath, + sourceKind: "compiled", + }, nil } if fileResult.mdExists { - return extractPermissionsFromMDFile(fileResult.mdPath) + perms, err := extractPermissionsFromMDFile(fileResult.mdPath) + if err != nil { + return nil, err + } + return &callWorkflowPermissionImport{ + permissions: perms, + sourcePath: fileResult.mdPath, + sourceKind: "markdown", + }, nil } // No file found — return nil so the caller omits the permissions block. @@ -144,6 +185,29 @@ func extractCallWorkflowPermissions(workflowName, markdownPath string) (*Permiss return nil, nil } +func buildCallWorkflowPermissionsComment(workflowName string, imported *callWorkflowPermissionImport) string { + if imported == nil || imported.permissions == nil { + return "" + } + if imported.permissions.RenderToYAML() == "" { + return "" + } + + reviewWhat := "job-level permissions" + if imported.sourceKind == "markdown" { + reviewWhat = "frontmatter permissions" + } + + return strings.Join([]string{ + fmt.Sprintf("# Imported from called workflow %q because GitHub requires the caller job to grant permissions requested by reusable workflow jobs.", workflowName), + fmt.Sprintf("# Review the worker's %s in %s.", reviewWhat, renderWorkflowReviewPath(imported.sourcePath)), + }, "\n") +} + +func renderWorkflowReviewPath(sourcePath string) string { + return filepath.ToSlash(filepath.Join(".", constants.GetWorkflowDir(), filepath.Base(sourcePath))) +} + // extractPermissionsFromYAMLFile reads a .lock.yml or .yml workflow file, parses it, // and returns the merged permissions from all its jobs. func extractPermissionsFromYAMLFile(filePath string) (*Permissions, error) { diff --git a/pkg/workflow/call_workflow_permissions_test.go b/pkg/workflow/call_workflow_permissions_test.go index 37bf2c480be..e04e047494c 100644 --- a/pkg/workflow/call_workflow_permissions_test.go +++ b/pkg/workflow/call_workflow_permissions_test.go @@ -268,6 +268,35 @@ func TestExtractCallWorkflowPermissions_FileNotFound(t *testing.T) { assert.Nil(t, perms, "Should return nil when no file exists") } +func TestExtractCallWorkflowPermissionImport_TracksReviewSource(t *testing.T) { + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0755), "Failed to create workflows directory") + + lockContent := `name: Worker Lock +on: + workflow_call: {} +jobs: + work: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - run: echo "lock" +` + require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "worker-review.lock.yml"), []byte(lockContent), 0644), "Failed to write worker-review.lock.yml") + + markdownPath := filepath.Join(workflowsDir, "gateway.md") + + imported, err := extractCallWorkflowPermissionImport("worker-review", markdownPath) + require.NoError(t, err, "Should extract imported permissions without error") + require.NotNil(t, imported, "Should return import metadata") + require.NotNil(t, imported.permissions, "Should include permissions") + assert.Equal(t, "compiled", imported.sourceKind, "Should track compiled workflow source kind") + assert.Equal(t, "./.github/workflows/worker-review.lock.yml", renderWorkflowReviewPath(imported.sourcePath), + "Should render a repo-relative review path for help comments") +} + // TestBuildCallWorkflowJobs_SetsPermissionsFromLockYML tests that call-workflow jobs // carry the union of caller + worker permissions when a .lock.yml worker file is present. // When the caller already covers all of the worker's needs, the effective permissions @@ -326,6 +355,12 @@ jobs: job, exists := compiler.jobManager.GetJob("call-worker-docs") require.True(t, exists, "Job should exist in job manager") + assert.Contains(t, job.PermissionsComment, + `Imported from called workflow "worker-docs" because GitHub requires the caller job to grant permissions requested by reusable workflow jobs.`, + "Job should explain why worker permissions are imported") + assert.Contains(t, job.PermissionsComment, + "Review the worker's job-level permissions in ./.github/workflows/worker-docs.lock.yml.", + "Job should point reviewers to the compiled worker workflow") assert.NotEmpty(t, job.Permissions, "Job should have permissions set") assert.Contains(t, job.Permissions, "contents: write", "Permissions should include contents: write") assert.Contains(t, job.Permissions, "issues: write", "Permissions should include issues: write") @@ -379,6 +414,9 @@ permissions: job, exists := compiler.jobManager.GetJob("call-worker-e") require.True(t, exists, "Job should exist in job manager") + assert.Contains(t, job.PermissionsComment, + "Review the worker's frontmatter permissions in ./.github/workflows/worker-e.md.", + "Job should point reviewers to the markdown worker when no compiled file exists yet") assert.NotEmpty(t, job.Permissions, "Job should have permissions") assert.Contains(t, job.Permissions, "contents: read", "Permissions should include contents: read") assert.Contains(t, job.Permissions, "issues: write", "Permissions should include issues: write") @@ -547,6 +585,12 @@ jobs: assert.Contains(t, yamlOutput, "uses: ./.github/workflows/worker-a.lock.yml", "Should contain uses directive") assert.Contains(t, yamlOutput, "secrets: inherit", "Should inherit secrets") assert.Contains(t, yamlOutput, "permissions:", "Should include permissions block") + assert.Contains(t, yamlOutput, + `# Imported from called workflow "worker-a" because GitHub requires the caller job to grant permissions requested by reusable workflow jobs.`, + "Rendered YAML should explain imported workflow_call permissions") + assert.Contains(t, yamlOutput, + "# Review the worker's job-level permissions in ./.github/workflows/worker-a.lock.yml.", + "Rendered YAML should point to the worker workflow for review") // The call-* job gets the union of caller + worker permissions. Since the caller // already covers all of the worker's needs, the effective permissions equal the // caller's declared permissions. diff --git a/pkg/workflow/compiler_safe_output_jobs.go b/pkg/workflow/compiler_safe_output_jobs.go index 3698a04ee7e..b9e64ca0e26 100644 --- a/pkg/workflow/compiler_safe_output_jobs.go +++ b/pkg/workflow/compiler_safe_output_jobs.go @@ -169,10 +169,10 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName, markdownPat // - `payload` is forwarded as the raw transport only when the worker declares it // (GitHub Actions rejects undeclared inputs) // - inherits all caller secrets via `secrets: inherit` -// - includes a job-level `permissions:` block derived from the CALLER's own -// declared permissions (not the worker's). The caller controls its own -// permission surface; the compiler validates that the declared permissions -// cover what the worker requires and warns if they do not. +// - includes a job-level `permissions:` block equal to the union of the +// caller's declared permissions and the called worker's required permissions +// - adds a help comment explaining why imported worker permissions appear on +// the call job and where to review them in the worker workflow source // // Returns the names of all generated jobs so they can be added to the conclusion // job's `needs` list. @@ -305,14 +305,15 @@ func (c *Compiler) buildCallWorkflowJobs(data *WorkflowData, markdownPath string } effectivePerms := callerPerms + var importedPerms *callWorkflowPermissionImport if markdownPath != "" { - workerPerms, permErr := extractCallWorkflowPermissions(workflowName, markdownPath) + importedPerms, permErr := extractCallWorkflowPermissionImport(workflowName, markdownPath) if permErr != nil { // Non-fatal: log and continue. The worker file may not exist yet (it may be // compiled in the same batch), in which case we fall back to the caller's // own declared permissions. compilerSafeOutputJobsLog.Printf("Could not extract worker permissions for call-workflow job '%s' (falling back to caller-only permissions): %v", jobName, permErr) - } else if workerPerms != nil { + } else if importedPerms != nil && importedPerms.permissions != nil { // Compute the union by merging caller and worker permissions into a // fresh map-based Permissions. Starting from a blank slate (rather // than a clone of callerPerms) ensures shorthand values like @@ -322,7 +323,7 @@ func (c *Compiler) buildCallWorkflowJobs(data *WorkflowData, markdownPath string // expanding it, silently dropping the caller's baseline grant. merged := NewPermissions() merged.Merge(callerPerms) - merged.Merge(workerPerms) + merged.Merge(importedPerms.permissions) effectivePerms = merged compilerSafeOutputJobsLog.Printf("Merged caller and worker permissions for call-workflow job '%s'", jobName) } @@ -331,6 +332,7 @@ func (c *Compiler) buildCallWorkflowJobs(data *WorkflowData, markdownPath string if effectivePerms != nil { rendered := effectivePerms.RenderToYAML() if rendered != "" { + callJob.PermissionsComment = buildCallWorkflowPermissionsComment(workflowName, importedPerms) callJob.Permissions = rendered compilerSafeOutputJobsLog.Printf("Set permissions on call-workflow job '%s': %s", jobName, rendered) } diff --git a/pkg/workflow/jobs.go b/pkg/workflow/jobs.go index 06d73b5960d..742ebf61262 100644 --- a/pkg/workflow/jobs.go +++ b/pkg/workflow/jobs.go @@ -42,6 +42,7 @@ type Job struct { RunsOn string If string HasWorkflowRunSafetyChecks bool // If true, the job's if condition includes workflow_run safety checks + PermissionsComment string Permissions string TimeoutMinutes int TimeoutMinutesExpression string @@ -292,6 +293,11 @@ func (jm *JobManager) renderJobTo(b *strings.Builder, job *Job) { } // Add permissions section + if job.PermissionsComment != "" { + for _, line := range strings.Split(strings.TrimRight(job.PermissionsComment, "\n"), "\n") { + fmt.Fprintf(b, " %s\n", line) + } + } if job.Permissions != "" { fmt.Fprintf(b, " %s\n", job.Permissions) } From 94b35fa5c90bc2462124493bc276ff66560d0574 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:58:55 +0000 Subject: [PATCH 3/7] Verify workflow_call permission comment behavior Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/call_workflow_permissions.go | 2 +- pkg/workflow/compiler_safe_output_jobs.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/workflow/call_workflow_permissions.go b/pkg/workflow/call_workflow_permissions.go index ee6f0afed1b..0fe61fccd43 100644 --- a/pkg/workflow/call_workflow_permissions.go +++ b/pkg/workflow/call_workflow_permissions.go @@ -205,7 +205,7 @@ func buildCallWorkflowPermissionsComment(workflowName string, imported *callWork } func renderWorkflowReviewPath(sourcePath string) string { - return filepath.ToSlash(filepath.Join(".", constants.GetWorkflowDir(), filepath.Base(sourcePath))) + return "./" + filepath.ToSlash(filepath.Join(constants.GetWorkflowDir(), filepath.Base(sourcePath))) } // extractPermissionsFromYAMLFile reads a .lock.yml or .yml workflow file, parses it, diff --git a/pkg/workflow/compiler_safe_output_jobs.go b/pkg/workflow/compiler_safe_output_jobs.go index b9e64ca0e26..12c73afbebb 100644 --- a/pkg/workflow/compiler_safe_output_jobs.go +++ b/pkg/workflow/compiler_safe_output_jobs.go @@ -306,8 +306,9 @@ func (c *Compiler) buildCallWorkflowJobs(data *WorkflowData, markdownPath string effectivePerms := callerPerms var importedPerms *callWorkflowPermissionImport + var permErr error if markdownPath != "" { - importedPerms, permErr := extractCallWorkflowPermissionImport(workflowName, markdownPath) + importedPerms, permErr = extractCallWorkflowPermissionImport(workflowName, markdownPath) if permErr != nil { // Non-fatal: log and continue. The worker file may not exist yet (it may be // compiled in the same batch), in which case we fall back to the caller's From f44f0eca1d0fe0f929df00d65b9f094f7383ea01 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:03:23 +0000 Subject: [PATCH 4/7] Fix permissions comment lint issue Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/jobs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/workflow/jobs.go b/pkg/workflow/jobs.go index 742ebf61262..55976c0d9a4 100644 --- a/pkg/workflow/jobs.go +++ b/pkg/workflow/jobs.go @@ -294,7 +294,7 @@ func (jm *JobManager) renderJobTo(b *strings.Builder, job *Job) { // Add permissions section if job.PermissionsComment != "" { - for _, line := range strings.Split(strings.TrimRight(job.PermissionsComment, "\n"), "\n") { + for line := range strings.SplitSeq(strings.TrimRight(job.PermissionsComment, "\n"), "\n") { fmt.Fprintf(b, " %s\n", line) } } From 70595cfe74fcc4fbd3a101270a4b69aab4aa5d1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:36:12 +0000 Subject: [PATCH 5/7] Recompile smoke call workflow Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-call-workflow.lock.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/smoke-call-workflow.lock.yml b/.github/workflows/smoke-call-workflow.lock.yml index ee338952a0a..dc058374200 100644 --- a/.github/workflows/smoke-call-workflow.lock.yml +++ b/.github/workflows/smoke-call-workflow.lock.yml @@ -1131,6 +1131,8 @@ jobs: call-smoke-workflow-call: needs: safe_outputs if: needs.safe_outputs.outputs.call_workflow_name == 'smoke-workflow-call' + # Imported from called workflow "smoke-workflow-call" because GitHub requires the caller job to grant permissions requested by reusable workflow jobs. + # Review the worker's job-level permissions in ./.github/workflows/smoke-workflow-call.lock.yml. permissions: actions: read contents: read From 1bb3aec32d4f42b42cf0b1896a7287d474874d9d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:45:13 +0000 Subject: [PATCH 6/7] Clarify called workflow permissions note Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-call-workflow.lock.yml | 2 +- pkg/workflow/call_workflow_permissions.go | 2 +- pkg/workflow/call_workflow_permissions_test.go | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/smoke-call-workflow.lock.yml b/.github/workflows/smoke-call-workflow.lock.yml index dc058374200..00e961a3681 100644 --- a/.github/workflows/smoke-call-workflow.lock.yml +++ b/.github/workflows/smoke-call-workflow.lock.yml @@ -1132,7 +1132,7 @@ jobs: needs: safe_outputs if: needs.safe_outputs.outputs.call_workflow_name == 'smoke-workflow-call' # Imported from called workflow "smoke-workflow-call" because GitHub requires the caller job to grant permissions requested by reusable workflow jobs. - # Review the worker's job-level permissions in ./.github/workflows/smoke-workflow-call.lock.yml. + # Review the called workflow's job-level permissions in ./.github/workflows/smoke-workflow-call.lock.yml. permissions: actions: read contents: read diff --git a/pkg/workflow/call_workflow_permissions.go b/pkg/workflow/call_workflow_permissions.go index 0fe61fccd43..2618ed14418 100644 --- a/pkg/workflow/call_workflow_permissions.go +++ b/pkg/workflow/call_workflow_permissions.go @@ -200,7 +200,7 @@ func buildCallWorkflowPermissionsComment(workflowName string, imported *callWork return strings.Join([]string{ fmt.Sprintf("# Imported from called workflow %q because GitHub requires the caller job to grant permissions requested by reusable workflow jobs.", workflowName), - fmt.Sprintf("# Review the worker's %s in %s.", reviewWhat, renderWorkflowReviewPath(imported.sourcePath)), + fmt.Sprintf("# Review the called workflow's %s in %s.", reviewWhat, renderWorkflowReviewPath(imported.sourcePath)), }, "\n") } diff --git a/pkg/workflow/call_workflow_permissions_test.go b/pkg/workflow/call_workflow_permissions_test.go index e04e047494c..96b720841c1 100644 --- a/pkg/workflow/call_workflow_permissions_test.go +++ b/pkg/workflow/call_workflow_permissions_test.go @@ -359,7 +359,7 @@ jobs: `Imported from called workflow "worker-docs" because GitHub requires the caller job to grant permissions requested by reusable workflow jobs.`, "Job should explain why worker permissions are imported") assert.Contains(t, job.PermissionsComment, - "Review the worker's job-level permissions in ./.github/workflows/worker-docs.lock.yml.", + "Review the called workflow's job-level permissions in ./.github/workflows/worker-docs.lock.yml.", "Job should point reviewers to the compiled worker workflow") assert.NotEmpty(t, job.Permissions, "Job should have permissions set") assert.Contains(t, job.Permissions, "contents: write", "Permissions should include contents: write") @@ -415,7 +415,7 @@ permissions: job, exists := compiler.jobManager.GetJob("call-worker-e") require.True(t, exists, "Job should exist in job manager") assert.Contains(t, job.PermissionsComment, - "Review the worker's frontmatter permissions in ./.github/workflows/worker-e.md.", + "Review the called workflow's frontmatter permissions in ./.github/workflows/worker-e.md.", "Job should point reviewers to the markdown worker when no compiled file exists yet") assert.NotEmpty(t, job.Permissions, "Job should have permissions") assert.Contains(t, job.Permissions, "contents: read", "Permissions should include contents: read") @@ -589,7 +589,7 @@ jobs: `# Imported from called workflow "worker-a" because GitHub requires the caller job to grant permissions requested by reusable workflow jobs.`, "Rendered YAML should explain imported workflow_call permissions") assert.Contains(t, yamlOutput, - "# Review the worker's job-level permissions in ./.github/workflows/worker-a.lock.yml.", + "# Review the called workflow's job-level permissions in ./.github/workflows/worker-a.lock.yml.", "Rendered YAML should point to the worker workflow for review") // The call-* job gets the union of caller + worker permissions. Since the caller // already covers all of the worker's needs, the effective permissions equal the From b3f8d2196d7f67d97985eb14fe8ae79453fbb18f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:16:17 +0000 Subject: [PATCH 7/7] Tighten workflow permission import handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/call_workflow_permissions.go | 28 ++++++++++--- .../call_workflow_permissions_test.go | 39 ++++++++++++++++++- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/pkg/workflow/call_workflow_permissions.go b/pkg/workflow/call_workflow_permissions.go index 2618ed14418..1c2c90bd931 100644 --- a/pkg/workflow/call_workflow_permissions.go +++ b/pkg/workflow/call_workflow_permissions.go @@ -14,10 +14,18 @@ import ( var callWorkflowPermissionsLog = logger.New("workflow:call_workflow_permissions") +type workflowSourceKind string + +const ( + workflowSourceKindLock workflowSourceKind = "lock" + workflowSourceKindYAML workflowSourceKind = "yaml" + workflowSourceKindMarkdown workflowSourceKind = "markdown" +) + type callWorkflowPermissionImport struct { permissions *Permissions sourcePath string - sourceKind string + sourceKind workflowSourceKind } // permissionLevelRank maps a permission level to a comparable rank where a higher @@ -110,6 +118,10 @@ func extractJobPermissionsFromParsedWorkflow(workflow map[string]any) *Permissio return merged } +// extractCallWorkflowPermissions is a compatibility helper used by existing tests. +// New production code should prefer extractCallWorkflowPermissionImport when it +// needs both the permissions and their review source metadata. +// // extractCallWorkflowPermissions returns the permission superset required by the worker // workflow identified by workflowName. It resolves the file in priority order: // .lock.yml > .yml > .md (same-batch compilation target). @@ -152,7 +164,7 @@ func extractCallWorkflowPermissionImport(workflowName, markdownPath string) (*ca return &callWorkflowPermissionImport{ permissions: perms, sourcePath: fileResult.lockPath, - sourceKind: "compiled", + sourceKind: workflowSourceKindLock, }, nil } @@ -164,7 +176,7 @@ func extractCallWorkflowPermissionImport(workflowName, markdownPath string) (*ca return &callWorkflowPermissionImport{ permissions: perms, sourcePath: fileResult.ymlPath, - sourceKind: "compiled", + sourceKind: workflowSourceKindYAML, }, nil } @@ -173,10 +185,13 @@ func extractCallWorkflowPermissionImport(workflowName, markdownPath string) (*ca if err != nil { return nil, err } + if perms == nil { + return nil, nil + } return &callWorkflowPermissionImport{ permissions: perms, sourcePath: fileResult.mdPath, - sourceKind: "markdown", + sourceKind: workflowSourceKindMarkdown, }, nil } @@ -194,7 +209,7 @@ func buildCallWorkflowPermissionsComment(workflowName string, imported *callWork } reviewWhat := "job-level permissions" - if imported.sourceKind == "markdown" { + if imported.sourceKind == workflowSourceKindMarkdown { reviewWhat = "frontmatter permissions" } @@ -204,6 +219,9 @@ func buildCallWorkflowPermissionsComment(workflowName string, imported *callWork }, "\n") } +// renderWorkflowReviewPath converts an absolute workflow path to the canonical +// repo-relative display path used in generated review comments. This assumes +// workflow files live directly in constants.GetWorkflowDir(). func renderWorkflowReviewPath(sourcePath string) string { return "./" + filepath.ToSlash(filepath.Join(constants.GetWorkflowDir(), filepath.Base(sourcePath))) } diff --git a/pkg/workflow/call_workflow_permissions_test.go b/pkg/workflow/call_workflow_permissions_test.go index 96b720841c1..febf4c429e7 100644 --- a/pkg/workflow/call_workflow_permissions_test.go +++ b/pkg/workflow/call_workflow_permissions_test.go @@ -268,7 +268,31 @@ func TestExtractCallWorkflowPermissions_FileNotFound(t *testing.T) { assert.Nil(t, perms, "Should return nil when no file exists") } +func TestExtractCallWorkflowPermissionImport_MDWithoutPermissionsReturnsNil(t *testing.T) { + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0755), "Failed to create workflows directory") + + mdContent := `--- +on: + workflow_call: {} +engine: copilot +--- + +# Worker Without Permissions +` + require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "worker-no-perms.md"), []byte(mdContent), 0644), "Failed to write worker-no-perms.md") + + markdownPath := filepath.Join(workflowsDir, "gateway.md") + + imported, err := extractCallWorkflowPermissionImport("worker-no-perms", markdownPath) + require.NoError(t, err, "Should not error when markdown worker has no permissions") + assert.Nil(t, imported, "Should treat markdown workers with no permissions like other missing-import cases") +} + func TestExtractCallWorkflowPermissionImport_TracksReviewSource(t *testing.T) { + t.Setenv("GH_AW_WORKFLOWS_DIR", "") + tmpDir := t.TempDir() workflowsDir := filepath.Join(tmpDir, ".github", "workflows") require.NoError(t, os.MkdirAll(workflowsDir, 0755), "Failed to create workflows directory") @@ -292,16 +316,27 @@ jobs: require.NoError(t, err, "Should extract imported permissions without error") require.NotNil(t, imported, "Should return import metadata") require.NotNil(t, imported.permissions, "Should include permissions") - assert.Equal(t, "compiled", imported.sourceKind, "Should track compiled workflow source kind") + assert.Equal(t, workflowSourceKindLock, imported.sourceKind, "Should track lock workflow source kind") assert.Equal(t, "./.github/workflows/worker-review.lock.yml", renderWorkflowReviewPath(imported.sourcePath), "Should render a repo-relative review path for help comments") } +func TestBuildCallWorkflowPermissionsComment_NilInputs(t *testing.T) { + assert.Empty(t, buildCallWorkflowPermissionsComment("worker", nil), "Nil import should not emit a comment") + assert.Empty(t, buildCallWorkflowPermissionsComment("worker", &callWorkflowPermissionImport{}), "Nil permissions should not emit a comment") + assert.Empty(t, buildCallWorkflowPermissionsComment("worker", &callWorkflowPermissionImport{ + permissions: NewPermissions(), + sourceKind: workflowSourceKindLock, + }), "Empty permissions should not emit a comment") +} + // TestBuildCallWorkflowJobs_SetsPermissionsFromLockYML tests that call-workflow jobs // carry the union of caller + worker permissions when a .lock.yml worker file is present. // When the caller already covers all of the worker's needs, the effective permissions // equal the caller's declared permissions. func TestBuildCallWorkflowJobs_SetsPermissionsFromLockYML(t *testing.T) { + t.Setenv("GH_AW_WORKFLOWS_DIR", "") + compiler := NewCompiler(WithVersion("1.0.0")) tmpDir := t.TempDir() @@ -372,6 +407,8 @@ jobs: // target. When caller and worker declare the same permissions, the effective permissions // equal the caller's declared permissions. func TestBuildCallWorkflowJobs_SetsPermissionsFromMD(t *testing.T) { + t.Setenv("GH_AW_WORKFLOWS_DIR", "") + compiler := NewCompiler(WithVersion("1.0.0")) tmpDir := t.TempDir()