diff --git a/.github/workflows/smoke-workflow-call.lock.yml b/.github/workflows/smoke-workflow-call.lock.yml index 07050efaea..1b82300939 100644 --- a/.github/workflows/smoke-workflow-call.lock.yml +++ b/.github/workflows/smoke-workflow-call.lock.yml @@ -56,6 +56,7 @@ jobs: comment_repo: "" model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + target_repo: ${{ steps.resolve-host-repo.outputs.target_repo }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -67,6 +68,15 @@ jobs: uses: ./actions/setup with: destination: /opt/gh-aw/actions + - name: Resolve host repo for activation checkout + id: resolve-host-repo + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/resolve_host_repo.cjs'); + await main(); - name: Generate agentic run info id: generate_aw_info env: @@ -85,6 +95,7 @@ jobs: GH_AW_INFO_AWMG_VERSION: "" GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_TARGET_REPO: ${{ steps.resolve-host-repo.outputs.target_repo }} uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: script: | @@ -96,7 +107,7 @@ jobs: env: COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - name: Cross-repo setup guidance - if: failure() && github.event_name == 'workflow_call' + if: failure() && steps.resolve-host-repo.outputs.target_repo != github.repository run: | echo "::error::COPILOT_GITHUB_TOKEN must be configured in the CALLER repository's secrets." echo "::error::For cross-repo workflow_call, secrets must be set in the repository that triggers the workflow." @@ -105,7 +116,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - repository: ${{ github.event_name == 'workflow_call' && github.action_repository || github.repository }} + repository: ${{ steps.resolve-host-repo.outputs.target_repo }} sparse-checkout: | .github .agents @@ -1105,7 +1116,9 @@ jobs: await main(); safe_outputs: - needs: agent + needs: + - activation + - agent if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') runs-on: ubuntu-slim permissions: diff --git a/actions/setup/js/generate_aw_info.cjs b/actions/setup/js/generate_aw_info.cjs index e561c92a39..b10d2e5aab 100644 --- a/actions/setup/js/generate_aw_info.cjs +++ b/actions/setup/js/generate_aw_info.cjs @@ -64,6 +64,7 @@ async function main(core, ctx) { sha: ctx.sha, actor: ctx.actor, event_name: ctx.eventName, + target_repo: process.env.GH_AW_INFO_TARGET_REPO || "", staged: process.env.GH_AW_INFO_STAGED === "true", allowed_domains: allowedDomains, firewall_enabled: process.env.GH_AW_INFO_FIREWALL_ENABLED === "true", diff --git a/actions/setup/js/resolve_host_repo.cjs b/actions/setup/js/resolve_host_repo.cjs new file mode 100644 index 0000000000..3840be84f1 --- /dev/null +++ b/actions/setup/js/resolve_host_repo.cjs @@ -0,0 +1,51 @@ +// @ts-check +/// + +/** + * Resolves the target repository for the activation job checkout. + * + * Uses GITHUB_WORKFLOW_REF to determine the platform (host) repository regardless + * of the triggering event. This fixes cross-repo activation for event-driven relays + * (e.g. on: issue_comment, on: push) where github.event_name is NOT 'workflow_call', + * so the expression introduced in #20301 incorrectly fell back to github.repository + * (the caller's repo) instead of the platform repo. + * + * GITHUB_WORKFLOW_REF always reflects the currently executing workflow file, not the + * triggering event. Its format is: + * owner/repo/.github/workflows/file.yml@refs/heads/main + * + * When the platform workflow runs cross-repo (called via uses:), GITHUB_WORKFLOW_REF + * starts with the platform repo slug, while GITHUB_REPOSITORY is the caller repo. + * Comparing the two lets us detect cross-repo invocations without relying on event_name. + */ + +/** + * @returns {Promise} + */ +async function main() { + const workflowRef = process.env.GITHUB_WORKFLOW_REF || ""; + const currentRepo = process.env.GITHUB_REPOSITORY || ""; + + // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref + // The regex captures everything before the third slash segment (i.e., the owner/repo prefix). + const match = workflowRef.match(/^([^/]+\/[^/]+)\//); + const workflowRepo = match ? match[1] : ""; + + // Fall back to currentRepo when GITHUB_WORKFLOW_REF cannot be parsed + const targetRepo = workflowRepo || currentRepo; + + core.info(`GITHUB_WORKFLOW_REF: ${workflowRef}`); + core.info(`GITHUB_REPOSITORY: ${currentRepo}`); + core.info(`Resolved host repo for activation checkout: ${targetRepo}`); + + if (targetRepo !== currentRepo && targetRepo !== "") { + core.info(`Cross-repo invocation detected: platform repo is "${targetRepo}", caller is "${currentRepo}"`); + await core.summary.addRaw(`**Activation Checkout**: Checking out platform repo \`${targetRepo}\` (caller: \`${currentRepo}\`)`).write(); + } else { + core.info(`Same-repo invocation: checking out ${targetRepo}`); + } + + core.setOutput("target_repo", targetRepo); +} + +module.exports = { main }; diff --git a/actions/setup/js/resolve_host_repo.test.cjs b/actions/setup/js/resolve_host_repo.test.cjs new file mode 100644 index 0000000000..a2b7d5cdb0 --- /dev/null +++ b/actions/setup/js/resolve_host_repo.test.cjs @@ -0,0 +1,128 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +// Mock the global objects that GitHub Actions provides +const mockCore = { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + setFailed: vi.fn(), + setOutput: vi.fn(), + summary: { + addRaw: vi.fn().mockReturnThis(), + write: vi.fn().mockResolvedValue(undefined), + }, +}; + +// Set up global mocks before importing the module +global.core = mockCore; + +describe("resolve_host_repo.cjs", () => { + let main; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCore.summary.addRaw.mockReturnThis(); + mockCore.summary.write.mockResolvedValue(undefined); + + const module = await import("./resolve_host_repo.cjs"); + main = module.main; + }); + + afterEach(() => { + delete process.env.GITHUB_WORKFLOW_REF; + delete process.env.GITHUB_REPOSITORY; + }); + + it("should output the platform repo 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", "my-org/platform-repo"); + }); + + it("should log a cross-repo detection message and write step summary", 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.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected")); + expect(mockCore.summary.addRaw).toHaveBeenCalled(); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("should output the current repo 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", "my-org/platform-repo"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Same-repo invocation")); + }); + + it("should not write step summary for same-repo invocations", 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.summary.write).not.toHaveBeenCalled(); + }); + + it("should fall back to GITHUB_REPOSITORY when GITHUB_WORKFLOW_REF is empty", async () => { + process.env.GITHUB_WORKFLOW_REF = ""; + process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/fallback-repo"); + }); + + it("should fall back to GITHUB_REPOSITORY when GITHUB_WORKFLOW_REF has unexpected format", async () => { + process.env.GITHUB_WORKFLOW_REF = "not-a-valid-ref"; + process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/fallback-repo"); + }); + + it("should handle event-driven relay (issue_comment) that calls a cross-repo workflow", async () => { + // This is the exact scenario from the bug report: + // An issue_comment event in app-repo triggers a relay that calls the platform workflow. + // GITHUB_WORKFLOW_REF reflects the platform workflow, GITHUB_REPOSITORY is the caller. + process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/my-workflow.lock.yml@main"; + process.env.GITHUB_REPOSITORY = "my-org/app-repo"; + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected")); + }); + + it("should fall back to empty string when GITHUB_REPOSITORY is also undefined", async () => { + process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; + delete process.env.GITHUB_REPOSITORY; + + await main(); + + // workflowRepo parsed from GITHUB_WORKFLOW_REF is "my-org/platform-repo" + // currentRepo is "" since env var is deleted + // targetRepo = workflowRepo || currentRepo = "my-org/platform-repo" + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); + }); + + it("should log GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY", 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.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKFLOW_REF:")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_REPOSITORY:")); + }); +}); diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index b1db1d65aa..04890002d1 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -131,6 +131,13 @@ type CheckoutManager struct { ordered []*resolvedCheckout // index maps checkoutKey to the position in ordered index map[checkoutKey]int + // crossRepoTargetRepo holds the platform (host) repository to use when performing + // .github/.agents sparse checkout steps for cross-repo workflow_call invocations. + // + // In the activation job this is set to "${{ steps.resolve-host-repo.outputs.target_repo }}". + // In the agent and safe_outputs jobs it is set to "${{ needs.activation.outputs.target_repo }}". + // An empty string means the checkout targets the current repository (github.repository). + crossRepoTargetRepo string } // NewCheckoutManager creates a new CheckoutManager pre-loaded with user-supplied @@ -146,6 +153,24 @@ func NewCheckoutManager(userCheckouts []*CheckoutConfig) *CheckoutManager { return cm } +// SetCrossRepoTargetRepo stores the platform (host) repository expression used for +// .github/.agents sparse checkout steps. Call this when the workflow has a workflow_call +// trigger and the checkout should target the platform repo rather than github.repository. +// +// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_repo }}". +// In downstream jobs (agent, safe_outputs) pass "${{ needs.activation.outputs.target_repo }}". +func (cm *CheckoutManager) SetCrossRepoTargetRepo(repo string) { + checkoutManagerLog.Printf("Setting cross-repo target: %q", repo) + cm.crossRepoTargetRepo = repo +} + +// GetCrossRepoTargetRepo returns the platform repo expression previously set by +// SetCrossRepoTargetRepo, or an empty string if no cross-repo target was set +// (same-repo invocation or inlined imports). +func (cm *CheckoutManager) GetCrossRepoTargetRepo() string { + return cm.crossRepoTargetRepo +} + // add processes a single CheckoutConfig and either creates a new entry or merges // it into an existing entry with the same key. func (cm *CheckoutManager) add(cfg *CheckoutConfig) { @@ -249,7 +274,9 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permission continue } checkoutManagerLog.Printf("Generating app token minting step for checkout index=%d repo=%q", i, entry.key.repository) - appSteps := c.buildGitHubAppTokenMintStep(entry.githubApp, permissions) + // Pass empty fallback so the app token defaults to github.event.repository.name. + // Checkout-specific cross-repo scoping is handled via the explicit repository field. + appSteps := c.buildGitHubAppTokenMintStep(entry.githubApp, permissions, "") stepID := fmt.Sprintf("checkout-app-token-%d", i) for _, step := range appSteps { modified := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: "+stepID) diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index 888300edde..7963550d98 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -910,3 +910,34 @@ func TestAdditionalCheckoutWithAppAuth(t *testing.T) { assert.Contains(t, combined, "other/repo", "should reference the additional repo") }) } + +// TestCrossRepoTargetRepo verifies the SetCrossRepoTargetRepo/GetCrossRepoTargetRepo lifecycle. +func TestCrossRepoTargetRepo(t *testing.T) { + t.Run("default is empty string (same-repo)", func(t *testing.T) { + cm := NewCheckoutManager(nil) + assert.Empty(t, cm.GetCrossRepoTargetRepo(), "new checkout manager should have no cross-repo target") + }) + + t.Run("activation job expression is stored and retrievable", func(t *testing.T) { + cm := NewCheckoutManager(nil) + cm.SetCrossRepoTargetRepo("${{ steps.resolve-host-repo.outputs.target_repo }}") + assert.Equal(t, "${{ steps.resolve-host-repo.outputs.target_repo }}", cm.GetCrossRepoTargetRepo()) + }) + + t.Run("downstream job expression (needs.activation.outputs) is stored and retrievable", func(t *testing.T) { + cm := NewCheckoutManager(nil) + cm.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}") + assert.Equal(t, "${{ needs.activation.outputs.target_repo }}", cm.GetCrossRepoTargetRepo()) + }) + + t.Run("GenerateGitHubFolderCheckoutStep uses stored value", func(t *testing.T) { + cm := NewCheckoutManager(nil) + cm.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}") + + lines := cm.GenerateGitHubFolderCheckoutStep(cm.GetCrossRepoTargetRepo(), GetActionPin) + combined := strings.Join(lines, "") + + assert.Contains(t, combined, "repository: ${{ needs.activation.outputs.target_repo }}", + "checkout step should use the cross-repo target") + }) +} diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index a25d009c1c..fb46daadeb 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -34,6 +34,18 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // Activation job doesn't need project support (no safe outputs processed here) steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...) + // When a workflow_call trigger is present, resolve the platform (host) repository before + // generating aw_info so that target_repo can be included in aw_info.json and used by + // the checkout step. This is necessary for event-driven relays (e.g. on: issue_comment) + // where github.event_name is not 'workflow_call', making the previous expression + // (github.event_name == 'workflow_call' && github.action_repository || github.repository) + // unreliable. GITHUB_WORKFLOW_REF always reflects the executing workflow's repo regardless + // of how it was triggered. + if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { + compilerActivationJobLog.Print("Adding resolve-host-repo step for workflow_call trigger") + steps = append(steps, c.generateResolveHostRepoStep()) + } + // Generate agentic run info immediately after setup so aw_info.json is ready as early as possible. // This ensures it is available for prompt generation and can be uploaded together with prompt.txt. engine, err := c.getAgenticEngine(data.AI) @@ -47,6 +59,13 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate // Expose the model output from the activation job so downstream jobs can reference it outputs["model"] = "${{ steps.generate_aw_info.outputs.model }}" + // Expose the resolved platform (host) repository so agent and safe_outputs jobs can use + // needs.activation.outputs.target_repo for any checkout that must target the platform repo + // rather than github.repository (the caller's repo in cross-repo workflow_call scenarios). + if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { + outputs["target_repo"] = "${{ steps.resolve-host-repo.outputs.target_repo }}" + } + // Add secret validation step before context variable validation. // This validates that the required engine secrets are available before any other checks. secretValidationStep := engine.GetSecretValidationStep(data) @@ -61,12 +80,15 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate } // Add cross-repo setup guidance when workflow_call is a trigger. - // This step only runs when secret validation fails in a workflow_call context, + // This step only runs when secret validation fails in a cross-repo context, // providing actionable guidance to the caller team about configuring secrets. + // Use steps.resolve-host-repo.outputs.target_repo != github.repository instead of + // github.event_name == 'workflow_call': the latter never fires for event-driven relays + // (issue_comment/push → workflow_call) where the event_name is the originating event. if hasWorkflowCallTrigger(data.On) { compilerActivationJobLog.Print("Adding cross-repo setup guidance step for workflow_call trigger") steps = append(steps, " - name: Cross-repo setup guidance\n") - steps = append(steps, " if: failure() && github.event_name == 'workflow_call'\n") + steps = append(steps, " if: failure() && steps.resolve-host-repo.outputs.target_repo != github.repository\n") steps = append(steps, " run: |\n") steps = append(steps, " echo \"::error::COPILOT_GITHUB_TOKEN must be configured in the CALLER repository's secrets.\"\n") steps = append(steps, " echo \"::error::For cross-repo workflow_call, secrets must be set in the repository that triggers the workflow.\"\n") @@ -434,6 +456,33 @@ func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *Workflow compilerActivationJobLog.Print("Prompt generation steps added to activation job") } +// generateResolveHostRepoStep generates a step that resolves the platform (host) repository +// for the activation job checkout by inspecting GITHUB_WORKFLOW_REF at runtime. +// +// This step replaces the previous compile-time expression +// +// github.event_name == 'workflow_call' && github.action_repository || github.repository +// +// which only worked when the outermost trigger was workflow_call. For event-driven relays +// (e.g. on: issue_comment, on: push) the event_name is the native event, so the old +// expression always fell back to github.repository (the caller's repo), causing the +// activation job to check out the wrong repository. +// +// GITHUB_WORKFLOW_REF always contains the path of the currently executing workflow file +// (owner/repo/.github/workflows/file.yml@ref), regardless of the triggering event. +// Comparing its owner/repo prefix with GITHUB_REPOSITORY reliably detects cross-repo +// invocations for all relay patterns. +func (c *Compiler) generateResolveHostRepoStep() string { + var step strings.Builder + step.WriteString(" - name: Resolve host repo for activation checkout\n") + step.WriteString(" id: resolve-host-repo\n") + step.WriteString(fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + step.WriteString(" with:\n") + step.WriteString(" script: |\n") + step.WriteString(generateGitHubScriptWithRequire("resolve_host_repo.cjs")) + return step.String() +} + // generateCheckoutGitHubFolderForActivation generates the checkout step for .github and .agents folders // specifically for the activation job. Unlike generateCheckoutGitHubFolder, this method doesn't skip // the checkout when the agent job will have a full repository checkout, because the activation job @@ -457,18 +506,19 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) // but the activation job will always have it for GitHub API access and runtime imports. // The agent job uses only the user-specified permissions (no automatic contents:read augmentation). - // For workflow_call triggers, checkout the callee repository using a conditional expression. - // github.action_repository points to the callee (platform) repo during workflow_call; - // for other event types the explicit event_name check short-circuits to falsy and we - // fall back to github.repository. This supports mixed triggers (e.g., workflow_call + workflow_dispatch). + // For workflow_call triggers, checkout the callee (platform) repository using the target_repo + // output from the resolve-host-repo step. That step parses GITHUB_WORKFLOW_REF at runtime to + // determine the platform repo, correctly handling event-driven relays where event_name is not + // 'workflow_call' (e.g. on: issue_comment, on: push). // // Skip when inlined-imports is enabled: content is embedded at compile time and no // runtime-import macros are used, so the callee's .md files are not needed at runtime. cm := NewCheckoutManager(nil) if data != nil && hasWorkflowCallTrigger(data.On) && !data.InlinedImports { compilerActivationJobLog.Print("Adding cross-repo-aware .github checkout for workflow_call trigger") + cm.SetCrossRepoTargetRepo("${{ steps.resolve-host-repo.outputs.target_repo }}") return cm.GenerateGitHubFolderCheckoutStep( - "${{ github.event_name == 'workflow_call' && github.action_repository || github.repository }}", + cm.GetCrossRepoTargetRepo(), GetActionPin, ) } diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go index e027259961..4da5a73799 100644 --- a/pkg/workflow/compiler_activation_job_test.go +++ b/pkg/workflow/compiler_activation_job_test.go @@ -12,7 +12,10 @@ import ( // workflowCallRepo is the expression injected into the repository: field of the // activation-job checkout step when a workflow_call trigger is detected. -const workflowCallRepo = "${{ github.event_name == 'workflow_call' && github.action_repository || github.repository }}" +// The resolve-host-repo step (which runs before checkout) parses GITHUB_WORKFLOW_REF +// at runtime to determine the platform repo, correctly handling both pure workflow_call +// relays and event-driven relays (e.g. on: issue_comment) where event_name != 'workflow_call'. +const workflowCallRepo = "${{ steps.resolve-host-repo.outputs.target_repo }}" func TestGenerateCheckoutGitHubFolderForActivation_WorkflowCall(t *testing.T) { tests := []struct { @@ -165,7 +168,7 @@ func TestGenerateGitHubFolderCheckoutStep(t *testing.T) { wantRepoValue: "org/platform-repo", }, { - name: "GitHub Actions expression for cross-repo", + name: "step output expression for cross-repo", repository: workflowCallRepo, wantRepository: true, wantRepoValue: workflowCallRepo, @@ -200,3 +203,124 @@ func TestGenerateGitHubFolderCheckoutStep(t *testing.T) { }) } } + +// TestGenerateResolveHostRepoStep verifies that the resolve-host-repo step is correctly +// generated and does not contain the broken event_name-based expression. +func TestGenerateResolveHostRepoStep(t *testing.T) { + c := NewCompilerWithVersion("dev") + c.SetActionMode(ActionModeDev) + + result := c.generateResolveHostRepoStep() + + assert.Contains(t, result, "resolve-host-repo", + "step should have the correct id") + assert.Contains(t, result, "Resolve host repo for activation checkout", + "step should have the correct name") + assert.Contains(t, result, "actions/github-script", + "step should use actions/github-script") + assert.Contains(t, result, "resolve_host_repo.cjs", + "step should require resolve_host_repo.cjs") + + // Verify the broken event_name expression is NOT present + assert.NotContains(t, result, "github.event_name == 'workflow_call'", + "step must not use the broken event_name-based expression") + assert.NotContains(t, result, "github.action_repository", + "step must not use github.action_repository (unreliable for event-driven relays)") +} + +// TestCheckoutDoesNotUseEventNameExpression verifies that the checkout step for +// workflow_call triggers uses the resolve-host-repo step output instead of the +// broken event_name == 'workflow_call' expression. +func TestCheckoutDoesNotUseEventNameExpression(t *testing.T) { + c := NewCompilerWithVersion("dev") + c.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + On: `"on": + workflow_call:`, + } + + result := c.generateCheckoutGitHubFolderForActivation(data) + combined := strings.Join(result, "") + + // Must use the step output, not the broken expression + assert.Contains(t, combined, "steps.resolve-host-repo.outputs.target_repo", + "checkout must reference the resolve-host-repo step output") + + // Must NOT use the old broken event_name expression + assert.NotContains(t, combined, "github.event_name == 'workflow_call'", + "checkout must not use the broken event_name-based expression") + assert.NotContains(t, combined, "github.action_repository", + "checkout must not use github.action_repository") +} + +// TestActivationJobTargetRepoOutput verifies that the activation job exposes target_repo as an +// output when a workflow_call trigger is present (without inlined imports), so that agent and +// safe_outputs jobs can reference needs.activation.outputs.target_repo. +func TestActivationJobTargetRepoOutput(t *testing.T) { + tests := []struct { + name string + onSection string + inlinedImports bool + expectTargetRepo bool + }{ + { + name: "workflow_call trigger - target_repo output added", + onSection: `"on": + workflow_call:`, + expectTargetRepo: true, + }, + { + name: "mixed triggers with workflow_call - target_repo output added", + onSection: `"on": + issue_comment: + types: [created] + workflow_call:`, + expectTargetRepo: true, + }, + { + name: "workflow_call with inlined-imports - no target_repo output", + onSection: `"on": + workflow_call:`, + inlinedImports: true, + expectTargetRepo: false, + }, + { + name: "no workflow_call - no target_repo output", + onSection: `"on": + issues: + types: [opened]`, + expectTargetRepo: 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.expectTargetRepo { + assert.Contains(t, job.Outputs, "target_repo", + "activation job should expose target_repo output for downstream jobs") + assert.Equal(t, + "${{ steps.resolve-host-repo.outputs.target_repo }}", + job.Outputs["target_repo"], + "target_repo output should reference resolve-host-repo step") + } else { + assert.NotContains(t, job.Outputs, "target_repo", + "activation job should not expose target_repo when workflow_call is absent or inlined-imports enabled") + } + }) + } +} diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index c601e7e966..647f6d0a9f 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -567,7 +567,8 @@ var handlerRegistry = map[string]handlerBuilder{ c := cfg.DispatchWorkflow builder := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). - AddStringSlice("workflows", c.Workflows) + AddStringSlice("workflows", c.Workflows). + AddIfNotEmpty("target-repo", c.TargetRepoSlug) // Add workflow_files map if it has entries if len(c.WorkflowFiles) > 0 { @@ -728,9 +729,17 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow fullManifestFiles := getAllManifestFiles(extraManifestFiles...) fullPathPrefixes := getProtectedPathPrefixes(extraPathPrefixes...) + // For workflow_call relay workflows, inject the resolved platform repo into the + // dispatch_workflow handler config so dispatch targets the host repo, not the caller's. + safeOutputs := data.SafeOutputs + if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil && safeOutputs.DispatchWorkflow.TargetRepoSlug == "" { + safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}") + compilerSafeOutputsConfigLog.Print("Injecting target_repo into dispatch_workflow config for workflow_call relay") + } + // Build configuration for each handler using the registry for handlerName, builder := range handlerRegistry { - handlerConfig := builder(data.SafeOutputs) + handlerConfig := builder(safeOutputs) // Include handler if: // 1. It returns a non-nil config (explicitly enabled, even if empty) // 2. For auto-enabled handlers, include even with empty config @@ -762,6 +771,17 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow } } +// safeOutputsWithDispatchTargetRepo returns a shallow copy of cfg with the dispatch_workflow +// TargetRepoSlug overridden to targetRepo. Only DispatchWorkflow is deep-copied; all other +// pointer fields remain shared. This avoids mutating the original config. +func safeOutputsWithDispatchTargetRepo(cfg *SafeOutputsConfig, targetRepo string) *SafeOutputsConfig { + dispatchCopy := *cfg.DispatchWorkflow + dispatchCopy.TargetRepoSlug = targetRepo + configCopy := *cfg + configCopy.DispatchWorkflow = &dispatchCopy + return &configCopy +} + // getEngineAgentFileInfo returns the engine-specific manifest filenames and path prefixes // by type-asserting the active engine to AgentFileProvider. Returns empty slices when // the engine is not set or does not implement the interface. diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 4c870c1328..ba818a72a6 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -244,7 +244,13 @@ 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 { - appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions) + // 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. + var appTokenFallbackRepo string + if hasWorkflowCallTrigger(data.On) { + appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}" + } + 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 insertIndex := 0 @@ -314,8 +320,11 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa // Build dependencies — detection is now inline in the agent job, no separate dependency needed needs := []string{mainJobName} - // Add activation job dependency for jobs that need it (create_pull_request, push_to_pull_request_branch, lock-for-agent) - if usesPatchesAndCheckouts(data.SafeOutputs) || data.LockForAgent { + // Add activation job dependency when: + // - create_pull_request or push_to_pull_request_branch (need the activation artifact) + // - lock-for-agent (need the activation lock) + // - workflow_call trigger (need needs.activation.outputs.target_repo for cross-repo token/dispatch) + if usesPatchesAndCheckouts(data.SafeOutputs) || data.LockForAgent || hasWorkflowCallTrigger(data.On) { needs = append(needs, string(constants.ActivationJobName)) } // Add unlock job dependency if lock-for-agent is enabled diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index 86acf0b5ef..7a583c3f34 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -646,6 +646,11 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat // validateLockdownRequirements uses this to enforce strict: true for public repositories. // Use effectiveStrictMode to infer strictness from the source (frontmatter), not just the CLI flag. fmt.Fprintf(yaml, " GH_AW_COMPILED_STRICT: \"%t\"\n", c.effectiveStrictMode(data.RawFrontmatter)) + // When a workflow_call trigger is present, pass the target_repo resolved by the + // resolve-host-repo step so it can be stored in aw_info.json for observability. + if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { + yaml.WriteString(" GH_AW_INFO_TARGET_REPO: ${{ steps.resolve-host-repo.outputs.target_repo }}\n") + } // Include lockdown validation env vars when lockdown is explicitly enabled. // validateLockdownRequirements is called from generate_aw_info.cjs and uses these vars. githubTool, hasGitHub := data.Tools["github"] diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 13876fbde9..e01fd73548 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -20,6 +20,14 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Build a CheckoutManager with any user-configured checkouts checkoutMgr := NewCheckoutManager(data.CheckoutConfigs) + // Propagate the platform (host) repo resolved by the activation job so that + // checkout steps in this job and in safe_outputs can use the correct repository + // for .github/.agents sparse checkouts when called cross-repo. + // The activation job exposes this as needs.activation.outputs.target_repo. + if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { + checkoutMgr.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}") + } + // Generate GitHub App token minting steps for checkouts with app auth // These must be emitted BEFORE the checkout steps that reference them if checkoutMgr.HasAppAuth() { diff --git a/pkg/workflow/dispatch_workflow.go b/pkg/workflow/dispatch_workflow.go index c5d7114b21..84aa36512a 100644 --- a/pkg/workflow/dispatch_workflow.go +++ b/pkg/workflow/dispatch_workflow.go @@ -11,6 +11,7 @@ type DispatchWorkflowConfig struct { BaseSafeOutputConfig `yaml:",inline"` Workflows []string `yaml:"workflows,omitempty"` // List of workflow names (without .md extension) to allow dispatching WorkflowFiles map[string]string `yaml:"workflow_files,omitempty"` // Map of workflow name to file extension (.lock.yml or .yml) - populated at compile time + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository for cross-repo dispatch (owner/repo or GitHub Actions expression) } // parseDispatchWorkflowConfig handles dispatch-workflow configuration diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 8f839f2453..06d05b996c 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -434,7 +434,7 @@ func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, d } // Generate the token minting step using the existing helper from safe_outputs_app.go - steps := c.buildGitHubAppTokenMintStep(app, permissions) + steps := c.buildGitHubAppTokenMintStep(app, permissions, "") // Modify the step ID to differentiate from safe-outputs app token // Replace "safe-outputs-app-token" with "github-mcp-app-token" diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go index eca0f41962..baad8503de 100644 --- a/pkg/workflow/notify_comment.go +++ b/pkg/workflow/notify_comment.go @@ -50,7 +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) - steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions)...) + // For workflow_call relay workflows, scope the token to the platform repo. + var appTokenFallbackRepo string + if hasWorkflowCallTrigger(data.On) { + appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}" + } + steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo)...) } // Add artifact download steps once (shared by noop and conclusion steps) diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index c3c18d1aea..38f489c69d 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -717,8 +717,11 @@ 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 -func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions) []string { +// 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). +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 @@ -741,7 +744,7 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions // - If repositories is a single value, use inline format // - If repositories has multiple values, use block scalar format (newline-separated) // to ensure clarity and proper parsing by actions/create-github-app-token - // - If repositories is empty/not specified, default to current repository + // - If repositories is empty/not specified, default to fallbackRepoExpr or the current repository if len(app.Repositories) == 1 && app.Repositories[0] == "*" { // Org-wide access: omit repositories field entirely safeOutputsAppLog.Print("Using org-wide GitHub App token (repositories: *)") @@ -756,9 +759,14 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions steps = append(steps, fmt.Sprintf(" %s\n", repo)) } } else { - // Extract repo name from github.repository (which is "owner/repo") - // Using GitHub Actions expression: split(github.repository, '/')[1] - steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") + // 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. + repoExpr := fallbackRepoExpr + if repoExpr == "" { + repoExpr = "${{ github.event.repository.name }}" + } + steps = append(steps, fmt.Sprintf(" repositories: %s\n", repoExpr)) } // Always add github-api-url from environment variable diff --git a/pkg/workflow/safe_outputs_jobs.go b/pkg/workflow/safe_outputs_jobs.go index 3bca5e4f3b..0a5dd648c7 100644 --- a/pkg/workflow/safe_outputs_jobs.go +++ b/pkg/workflow/safe_outputs_jobs.go @@ -61,7 +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") - steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, config.Permissions)...) + // For workflow_call relay workflows, scope the token to the platform repo. + var appTokenFallbackRepo string + if hasWorkflowCallTrigger(data.On) { + appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}" + } + steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, config.Permissions, appTokenFallbackRepo)...) } // Add pre-steps if provided (e.g., checkout, git config for create-pull-request)