From 0fce84e4096f2c721246d101e1b3c4fcc8b2e19a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:20:13 +0000 Subject: [PATCH 1/3] Initial plan From de638a5744df0e042203aeb98669586e4fc92d35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:27:13 +0000 Subject: [PATCH 2/3] chore: initial plan for GitHub App token fallback fix Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/mcp-inspector.lock.yml | 2 +- pkg/workflow/data/action_pins.json | 27 ++++++------------------ 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index b924918d3f..6aacc72bf4 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -390,7 +390,7 @@ jobs: with: python-version: '3.12' - name: Setup uv - uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 + uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh # Cache memory file share configuration from frontmatter processed below diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index a98a9f788a..e4dac97476 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -10,21 +10,11 @@ "version": "v4.1.0", "sha": "a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" }, - "actions/cache/restore@v4": { - "repo": "actions/cache/restore", - "version": "v4", - "sha": "0057852bfaa89a56745cba8c7296529d2fc39830" - }, "actions/cache/restore@v5.0.3": { "repo": "actions/cache/restore", "version": "v5.0.3", "sha": "cdf6c1fa76f9f475f3d7449005a359c84ca0f306" }, - "actions/cache/save@v4": { - "repo": "actions/cache/save", - "version": "v4", - "sha": "0057852bfaa89a56745cba8c7296529d2fc39830" - }, "actions/cache/save@v5.0.3": { "repo": "actions/cache/save", "version": "v5.0.3", @@ -40,10 +30,10 @@ "version": "v6.0.2", "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" }, - "actions/create-github-app-token@v3.0.0-beta.2": { + "actions/create-github-app-token@v3.0.0-beta.4": { "repo": "actions/create-github-app-token", - "version": "v3.0.0-beta.2", - "sha": "bf559f85448f9380bcfa2899dbdc01eb5b37be3a" + "version": "v3.0.0-beta.4", + "sha": "a7f885bf4560200d03183ed941cb6fb072e4b343" }, "actions/download-artifact@v8.0.1": { "repo": "actions/download-artifact", @@ -70,11 +60,6 @@ "version": "v5.2.0", "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" }, - "actions/setup-node@v4": { - "repo": "actions/setup-node", - "version": "v4", - "sha": "49933ea5288caeca8642d1e84afbd3f7d6820020" - }, "actions/setup-node@v6.3.0": { "repo": "actions/setup-node", "version": "v6.3.0", @@ -95,10 +80,10 @@ "version": "v0.23.1", "sha": "57aae528053a48a3f6235f2d9461b05fbcb7366d" }, - "astral-sh/setup-uv@v7.4.0": { + "astral-sh/setup-uv@v7.5.0": { "repo": "astral-sh/setup-uv", - "version": "v7.4.0", - "sha": "6ee6290f1cbc4156c0bdd66691b2c144ef8df19a" + "version": "v7.5.0", + "sha": "e06108dd0aef18192324c70427afc47652e63a82" }, "cli/gh-extension-precompile@v2.1.0": { "repo": "cli/gh-extension-precompile", From 8c5d40eb683258c3ab5ab58ee8897306a1fbc94b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:38:28 +0000 Subject: [PATCH 3/3] fix: GitHub App token fallback uses repo name instead of full slug in workflow_call relays When safe-outputs.github-app is configured without an explicit repositories list, the compiler was passing needs.activation.outputs.target_repo (an owner/repo slug) as the repositories value for actions/create-github-app-token. That action expects repo names only (without the owner prefix) when owner is also set, causing a 404 on the GitHub installation API. Fix: - Emit target_repo_name (repo name only) from resolve_host_repo.cjs - Expose target_repo_name as an activation job output for workflow_call triggers - Use target_repo_name instead of target_repo for the GitHub App token fallback in all three call sites: compiler_safe_outputs_job.go, safe_outputs_jobs.go, and notify_comment.go - Add tests covering all changed paths Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../smoke-workflow-call-with-inputs.lock.yml | 1 + .../workflows/smoke-workflow-call.lock.yml | 1 + actions/setup/js/resolve_host_repo.cjs | 6 ++ actions/setup/js/resolve_host_repo.test.cjs | 27 +++++++ pkg/workflow/compiler_activation_job.go | 1 + pkg/workflow/compiler_activation_job_test.go | 72 ++++++++++++++++++ pkg/workflow/compiler_safe_outputs_job.go | 7 +- .../compiler_safe_outputs_job_test.go | 75 +++++++++++++++++++ pkg/workflow/notify_comment.go | 6 +- pkg/workflow/safe_outputs_app_config.go | 9 ++- pkg/workflow/safe_outputs_jobs.go | 6 +- 11 files changed, 200 insertions(+), 11 deletions(-) diff --git a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml index 23e1e4e42d..64f6fcf015 100644 --- a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml +++ b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml @@ -71,6 +71,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} target_ref: ${{ steps.resolve-host-repo.outputs.target_ref }} target_repo: ${{ steps.resolve-host-repo.outputs.target_repo }} + target_repo_name: ${{ steps.resolve-host-repo.outputs.target_repo_name }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/smoke-workflow-call.lock.yml b/.github/workflows/smoke-workflow-call.lock.yml index d5d43e4a96..cc0a49d2aa 100644 --- a/.github/workflows/smoke-workflow-call.lock.yml +++ b/.github/workflows/smoke-workflow-call.lock.yml @@ -59,6 +59,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} target_ref: ${{ steps.resolve-host-repo.outputs.target_ref }} target_repo: ${{ steps.resolve-host-repo.outputs.target_repo }} + target_repo_name: ${{ steps.resolve-host-repo.outputs.target_repo_name }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/actions/setup/js/resolve_host_repo.cjs b/actions/setup/js/resolve_host_repo.cjs index e6c94a0cf0..e505fa345d 100644 --- a/actions/setup/js/resolve_host_repo.cjs +++ b/actions/setup/js/resolve_host_repo.cjs @@ -69,7 +69,13 @@ async function main() { core.info(`Same-repo invocation: checking out ${targetRepo} @ ${targetRef}`); } + // Compute the repository name (without owner prefix) for use cases that require + // only the repo name, such as actions/create-github-app-token which expects + // `repositories` to contain repo names only when `owner` is also provided. + const targetRepoName = targetRepo.includes("/") ? targetRepo.substring(targetRepo.indexOf("/") + 1) : targetRepo; + core.setOutput("target_repo", targetRepo); + core.setOutput("target_repo_name", targetRepoName); core.setOutput("target_ref", targetRef); } diff --git a/actions/setup/js/resolve_host_repo.test.cjs b/actions/setup/js/resolve_host_repo.test.cjs index 5b203caf05..a4a0afb1b9 100644 --- a/actions/setup/js/resolve_host_repo.test.cjs +++ b/actions/setup/js/resolve_host_repo.test.cjs @@ -199,6 +199,33 @@ describe("resolve_host_repo.cjs", () => { expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "abc123def456"); }); + it("should output target_repo_name when invoked cross-repo", async () => { + process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "my-org/app-repo"; + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo"); + }); + + it("should output target_repo_name when same-repo invocation", async () => { + process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "my-org/platform-repo"; + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo"); + }); + + it("should output target_repo_name without owner prefix when falling back to GITHUB_REPOSITORY", async () => { + process.env.GITHUB_WORKFLOW_REF = ""; + process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "fallback-repo"); + }); + it("should include target_ref in step summary for cross-repo invocations", async () => { process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/feature-branch"; process.env.GITHUB_REPOSITORY = "my-org/app-repo"; diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index a016e5bdf9..95df0b548c 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -75,6 +75,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // cross-repo workflow_call scenarios, especially when pinned to a non-default branch). if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { outputs["target_repo"] = "${{ steps.resolve-host-repo.outputs.target_repo }}" + outputs["target_repo_name"] = "${{ steps.resolve-host-repo.outputs.target_repo_name }}" outputs["target_ref"] = "${{ steps.resolve-host-repo.outputs.target_ref }}" } diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go index 96e21f35f2..3d7bef1cfb 100644 --- a/pkg/workflow/compiler_activation_job_test.go +++ b/pkg/workflow/compiler_activation_job_test.go @@ -415,6 +415,78 @@ func TestActivationJobTargetRefOutput(t *testing.T) { } } +// TestActivationJobTargetRepoNameOutput verifies that the activation job exposes target_repo_name +// as an output when a workflow_call trigger is present (without inlined imports). This repo-name-only +// output is required for actions/create-github-app-token which expects repo names without the +// owner prefix when `owner` is also set. +func TestActivationJobTargetRepoNameOutput(t *testing.T) { + tests := []struct { + name string + onSection string + inlinedImports bool + expectTargetRepoName bool + }{ + { + name: "workflow_call trigger - target_repo_name output added", + onSection: `"on": + workflow_call:`, + expectTargetRepoName: true, + }, + { + name: "mixed triggers with workflow_call - target_repo_name output added", + onSection: `"on": + issue_comment: + types: [created] + workflow_call:`, + expectTargetRepoName: true, + }, + { + name: "workflow_call with inlined-imports - no target_repo_name output", + onSection: `"on": + workflow_call:`, + inlinedImports: true, + expectTargetRepoName: false, + }, + { + name: "no workflow_call - no target_repo_name output", + onSection: `"on": + issues: + types: [opened]`, + expectTargetRepoName: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompilerWithVersion("dev") + compiler.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + Name: "test-workflow", + On: tt.onSection, + InlinedImports: tt.inlinedImports, + AI: "copilot", + } + + job, err := compiler.buildActivationJob(data, false, "", "test.lock.yml") + require.NoError(t, err, "buildActivationJob should succeed") + require.NotNil(t, job, "activation job should not be nil") + + if tt.expectTargetRepoName { + assert.Contains(t, job.Outputs, "target_repo_name", + "activation job should expose target_repo_name output for GitHub App token minting") + assert.Equal(t, + "${{ steps.resolve-host-repo.outputs.target_repo_name }}", + job.Outputs["target_repo_name"], + "target_repo_name output should reference resolve-host-repo step") + } else { + assert.NotContains(t, job.Outputs, "target_repo_name", + "activation job should not expose target_repo_name when workflow_call is absent or inlined-imports enabled") + } + }) + } +} + // TestCheckoutGitHubFolderIncludesRef verifies that the activation checkout emits a ref: field // when a workflow_call trigger is present. This ensures caller-hosted relays pinned to a // feature branch check out the correct platform branch during activation. diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 89ee911038..8c78d7ace9 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -246,11 +246,12 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa // Add GitHub App token minting step at the beginning if app is configured if data.SafeOutputs.GitHubApp != nil { - // For workflow_call relay workflows, scope the token to the platform repo so that - // API calls targeting the host repo (e.g. dispatch_workflow) are authorized. + // For workflow_call relay workflows, scope the token to the platform repo name only + // (not the full slug) because actions/create-github-app-token expects repo names + // without the owner prefix when `owner` is also set. var appTokenFallbackRepo string if hasWorkflowCallTrigger(data.On) { - appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}" + appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}" } appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo) // Calculate insertion index: after setup action (if present) and artifact downloads, but before checkout and safe output steps diff --git a/pkg/workflow/compiler_safe_outputs_job_test.go b/pkg/workflow/compiler_safe_outputs_job_test.go index 15304e133a..4e7739003d 100644 --- a/pkg/workflow/compiler_safe_outputs_job_test.go +++ b/pkg/workflow/compiler_safe_outputs_job_test.go @@ -662,3 +662,78 @@ func TestGitHubAppWithPushToPRBranch(t *testing.T) { // Verify step ID is set correctly assert.Contains(t, stepsContent, "id: safe-outputs-app-token") } + +// TestJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback is a regression test verifying that +// a safe-output job compiled for a workflow_call trigger uses +// needs.activation.outputs.target_repo_name (repo name only, no owner prefix) as the repositories +// fallback for the GitHub App token mint step, instead of the full target_repo slug. +// This prevents actions/create-github-app-token from receiving an invalid owner/repo slug +// in the repositories field when owner is also set. +func TestJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + + workflowData := &WorkflowData{ + Name: "Test Workflow", + On: `"on": + workflow_call:`, + SafeOutputs: &SafeOutputsConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + CreateIssues: &CreateIssuesConfig{ + TitlePrefix: "[Test] ", + }, + }, + } + + job, _, err := compiler.buildConsolidatedSafeOutputsJob(workflowData, string(constants.AgentJobName), "test.md") + + require.NoError(t, err, "Should successfully build job") + require.NotNil(t, job, "Job should not be nil") + + stepsContent := strings.Join(job.Steps, "") + + // Must use the repo-name-only output, NOT the full slug + assert.Contains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo_name }}", + "GitHub App token step must use target_repo_name (repo name only) for workflow_call workflows") + assert.NotContains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo }}", + "GitHub App token step must not use target_repo (full slug) for workflow_call workflows") +} + +// TestConclusionJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback is a regression test +// verifying that the conclusion job compiled for a workflow_call trigger uses +// needs.activation.outputs.target_repo_name (repo name only) as the repositories fallback +// for the GitHub App token mint step. +func TestConclusionJobWithGitHubAppWorkflowCallUsesTargetRepoNameFallback(t *testing.T) { + compiler := NewCompiler() + compiler.jobManager = NewJobManager() + compiler.SetActionMode(ActionModeDev) + + workflowData := &WorkflowData{ + Name: "Test Workflow", + On: `"on": + workflow_call:`, + SafeOutputs: &SafeOutputsConfig{ + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + AddComments: &AddCommentsConfig{}, + }, + } + + job, err := compiler.buildConclusionJob(workflowData, string(constants.AgentJobName), nil) + + require.NoError(t, err, "Should successfully build conclusion job") + require.NotNil(t, job, "Conclusion job should not be nil") + + stepsContent := strings.Join(job.Steps, "") + + // Must use the repo-name-only output, NOT the full slug + assert.Contains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo_name }}", + "Conclusion job GitHub App token step must use target_repo_name (repo name only) for workflow_call workflows") + assert.NotContains(t, stepsContent, "repositories: ${{ needs.activation.outputs.target_repo }}", + "Conclusion job GitHub App token step must not use target_repo (full slug) for workflow_call workflows") +} diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index 2c50999d1a..e850f56acd 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -50,10 +50,12 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa if data.SafeOutputs.GitHubApp != nil { // Compute permissions based on configured safe outputs (principle of least privilege) permissions := ComputePermissionsForSafeOutputs(data.SafeOutputs) - // For workflow_call relay workflows, scope the token to the platform repo. + // For workflow_call relay workflows, scope the token to the platform repo name only + // (not the full slug) because actions/create-github-app-token expects repo names + // without the owner prefix when `owner` is also set. var appTokenFallbackRepo string if hasWorkflowCallTrigger(data.On) { - appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}" + appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}" } steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo)...) } diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index efbb5293a0..828b32d1ff 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -120,8 +120,9 @@ func (c *Compiler) mergeAppFromIncludedConfigs(topSafeOutputs *SafeOutputsConfig // buildGitHubAppTokenMintStep generates the step to mint a GitHub App installation access token // Permissions are automatically computed from the safe output job requirements. // fallbackRepoExpr overrides the default ${{ github.event.repository.name }} fallback when -// no explicit repositories are configured (e.g. pass needs.activation.outputs.target_repo for -// workflow_call relay workflows so the token is scoped to the platform repo, not the caller's). +// no explicit repositories are configured (e.g. pass needs.activation.outputs.target_repo_name for +// workflow_call relay workflows so the token is scoped to the platform repo's NAME, not the full +// owner/repo slug — actions/create-github-app-token expects repo names only when owner is also set). func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string) []string { safeOutputsAppLog.Printf("Building GitHub App token mint step: owner=%s, repos=%d", app.Owner, len(app.Repositories)) var steps []string @@ -161,8 +162,8 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions } } else { // No explicit repositories: use fallback expression, or default to the triggering repo's name. - // For workflow_call relay scenarios the caller passes needs.activation.outputs.target_repo so - // the token is scoped to the platform (host) repo rather than the caller repo. + // For workflow_call relay scenarios the caller passes needs.activation.outputs.target_repo_name so + // the token is scoped to the platform (host) repo name rather than the full owner/repo slug. repoExpr := fallbackRepoExpr if repoExpr == "" { repoExpr = "${{ github.event.repository.name }}" diff --git a/pkg/workflow/safe_outputs_jobs.go b/pkg/workflow/safe_outputs_jobs.go index e1c8948ee3..ed69cd596e 100644 --- a/pkg/workflow/safe_outputs_jobs.go +++ b/pkg/workflow/safe_outputs_jobs.go @@ -61,10 +61,12 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo // Add GitHub App token minting step if app is configured if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil { safeOutputsJobsLog.Print("Adding GitHub App token minting step with auto-computed permissions") - // For workflow_call relay workflows, scope the token to the platform repo. + // For workflow_call relay workflows, scope the token to the platform repo name only + // (not the full slug) because actions/create-github-app-token expects repo names + // without the owner prefix when `owner` is also set. var appTokenFallbackRepo string if hasWorkflowCallTrigger(data.On) { - appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}" + appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo_name }}" } steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, config.Permissions, appTokenFallbackRepo)...) }