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 9c2e29938c..13e36cef30 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)...) }