diff --git a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml index 317f5b06539..762f3c20d0c 100644 --- a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml +++ b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml @@ -112,6 +112,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} setup-trace-id: ${{ steps.setup.outputs.trace-id }} stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + target_checkout_ref: ${{ steps.resolve-host-repo.outputs.target_checkout_ref }} 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 }} @@ -197,7 +198,7 @@ jobs: with: persist-credentials: false repository: ${{ steps.resolve-host-repo.outputs.target_repo }} - ref: ${{ steps.resolve-host-repo.outputs.target_ref }} + ref: ${{ steps.resolve-host-repo.outputs.target_checkout_ref }} sparse-checkout: | .github .agents diff --git a/.github/workflows/smoke-workflow-call.lock.yml b/.github/workflows/smoke-workflow-call.lock.yml index 327d51dcda3..d74434e17f9 100644 --- a/.github/workflows/smoke-workflow-call.lock.yml +++ b/.github/workflows/smoke-workflow-call.lock.yml @@ -131,6 +131,7 @@ jobs: secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} setup-trace-id: ${{ steps.setup.outputs.trace-id }} stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + target_checkout_ref: ${{ steps.resolve-host-repo.outputs.target_checkout_ref }} 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 }} @@ -218,7 +219,7 @@ jobs: with: persist-credentials: false repository: ${{ steps.resolve-host-repo.outputs.target_repo }} - ref: ${{ steps.resolve-host-repo.outputs.target_ref }} + ref: ${{ steps.resolve-host-repo.outputs.target_checkout_ref }} sparse-checkout: | .github .agents diff --git a/actions/setup/js/resolve_host_repo.cjs b/actions/setup/js/resolve_host_repo.cjs index d1c13651a34..95ec11a5b85 100644 --- a/actions/setup/js/resolve_host_repo.cjs +++ b/actions/setup/js/resolve_host_repo.cjs @@ -2,46 +2,94 @@ /// /** - * Resolves the target repository and ref for the activation job checkout. + * Resolves the target repository and refs for the activation job. * * Uses the job.workflow_* context fields to determine the platform (host) - * repository and pin the checkout to the exact executing commit SHA. + * repository and produce two distinct ref outputs for different consumers: + * + * - target_checkout_ref: the immutable commit SHA from job.workflow_sha, used + * by actions/checkout to pin the activation checkout to the exact executing + * revision rather than a moving branch/tag ref. + * + * - target_ref: the branch or tag ref parsed from job.workflow_ref (the + * substring after "@", e.g. "refs/heads/main" from + * "owner/repo/.github/workflows/file.yml@refs/heads/main"), used by + * dispatch_workflow.cjs as the `ref` argument to createWorkflowDispatch. + * The GitHub workflow dispatch API only accepts branch/tag refs; passing a + * commit SHA causes "No ref found for: " errors. * * These fields are passed via environment variables (JOB_WORKFLOW_REPOSITORY, - * JOB_WORKFLOW_SHA, etc.) to avoid shell injection — the ${{ }} expressions - * are evaluated in the env: block, not interpolated into script source. + * JOB_WORKFLOW_SHA, JOB_WORKFLOW_REF, etc.) to avoid shell injection — the + * ${{ }} expressions are evaluated in the env: block, not interpolated into + * script source. * * job.workflow_repository provides the owner/repo of the currently executing * workflow file, correctly identifying the platform repo in all relay patterns: * cross-repo workflow_call, event-driven relays (on: issue_comment, on: push), * and cross-org scenarios. * - * job.workflow_sha provides the immutable commit SHA of the workflow being - * executed, ensuring the activation checkout pins to the exact revision rather - * than a moving branch/tag ref. - * * @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runner context via env vars only */ +/** + * Parses the dispatch-compatible branch/tag ref from job.workflow_ref. + * job.workflow_ref has the form "owner/repo/.github/workflows/file.yml@refs/heads/main". + * Returns the substring after the last "@", or an empty string if missing, malformed, + * or if the extracted value looks like a commit SHA (40 lowercase hex characters — + * these are not accepted by the workflow dispatch API). + * + * Uses lastIndexOf to handle the unlikely case of an "@" in the workflow path. + * + * @param {string} workflowRef + * @returns {string} + */ +function parseDispatchRef(workflowRef) { + if (!workflowRef) { + return ""; + } + const atIndex = workflowRef.lastIndexOf("@"); + if (atIndex === -1) { + return ""; + } + const ref = workflowRef.slice(atIndex + 1); + // Reject SHA-like values (40 lowercase hex chars). workflow_call can reference a + // workflow by commit SHA, but createWorkflowDispatch does not accept SHAs as refs. + if (/^[0-9a-f]{40}$/.test(ref)) { + return ""; + } + return ref; +} + /** * @returns {Promise} */ async function main() { const targetRepo = process.env.JOB_WORKFLOW_REPOSITORY || ""; - const targetRef = process.env.JOB_WORKFLOW_SHA || ""; + const targetCheckoutRef = process.env.JOB_WORKFLOW_SHA || ""; + const workflowRef = process.env.JOB_WORKFLOW_REF || ""; + const targetDispatchRef = parseDispatchRef(workflowRef); const targetRepoName = targetRepo.split("/").pop() || ""; const currentRepo = process.env.GITHUB_REPOSITORY || ""; core.info("Resolving host repo via job.workflow_* context"); core.info(`job.workflow_repository = ${targetRepo}`); - core.info(`job.workflow_sha = ${targetRef}`); - core.info(`job.workflow_ref = ${process.env.JOB_WORKFLOW_REF || ""}`); + core.info(`job.workflow_sha = ${targetCheckoutRef}`); + core.info(`job.workflow_ref = ${workflowRef}`); core.info(`job.workflow_file_path = ${process.env.JOB_WORKFLOW_FILE_PATH || ""}`); core.info(`github.repository = ${currentRepo}`); core.info(""); - core.info(`Resolved target_repo = ${targetRepo}`); - core.info(`Resolved target_repo_name = ${targetRepoName}`); - core.info(`Resolved target_ref = ${targetRef}`); + core.info(`Resolved target_repo = ${targetRepo}`); + core.info(`Resolved target_repo_name = ${targetRepoName}`); + core.info(`Resolved target_checkout_ref = ${targetCheckoutRef}`); + core.info(`Resolved target_ref = ${targetDispatchRef}`); + + if (!targetDispatchRef) { + core.warning( + `Could not parse a branch/tag ref from JOB_WORKFLOW_REF="${workflowRef}". ` + + "dispatch_workflow safe outputs may fail if they rely on target_ref. " + + "Falling back to empty string — do not use target_checkout_ref (SHA) as the dispatch ref." + ); + } if (targetRepo && targetRepo !== currentRepo) { core.info(`Cross-repo invocation detected: platform repo "${targetRepo}" differs from caller "${currentRepo}"`); @@ -51,7 +99,11 @@ async function main() { core.setOutput("target_repo", targetRepo); core.setOutput("target_repo_name", targetRepoName); - core.setOutput("target_ref", targetRef); + // target_checkout_ref: immutable SHA used by actions/checkout for exact-revision pinning. + core.setOutput("target_checkout_ref", targetCheckoutRef); + // target_ref: dispatch-compatible branch/tag ref used by dispatch_workflow.cjs. + // The GitHub workflow dispatch API requires a branch or tag, not a commit SHA. + core.setOutput("target_ref", targetDispatchRef); } -module.exports = { main }; +module.exports = { main, parseDispatchRef }; 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 00000000000..ec0007096d0 --- /dev/null +++ b/actions/setup/js/resolve_host_repo.test.cjs @@ -0,0 +1,183 @@ +// @ts-check +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { main, parseDispatchRef } from "./resolve_host_repo.cjs"; + +describe("parseDispatchRef", () => { + it("parses refs/heads/main from a standard workflow_ref", () => { + expect(parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/heads/main")).toBe("refs/heads/main"); + }); + + it("parses refs/tags/v1.2.3 from a tag-triggered workflow_ref", () => { + expect(parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/tags/v1.2.3")).toBe("refs/tags/v1.2.3"); + }); + + it("parses refs/heads/feature/my-branch from a nested branch name", () => { + expect(parseDispatchRef("org/repo/.github/workflows/ci.yml@refs/heads/feature/my-branch")).toBe("refs/heads/feature/my-branch"); + }); + + it("returns empty string when JOB_WORKFLOW_REF is empty", () => { + expect(parseDispatchRef("")).toBe(""); + }); + + it("returns empty string when JOB_WORKFLOW_REF has no '@' separator", () => { + expect(parseDispatchRef("owner/repo/.github/workflows/file.yml")).toBe(""); + }); + + it("returns empty string when JOB_WORKFLOW_REF ends with a 40-hex commit SHA", () => { + // workflow_call can reference a workflow by commit SHA, but createWorkflowDispatch + // rejects SHAs — parseDispatchRef must return "" to prevent dispatch failures. + const sha = "abc123def456abc123def456abc123def456abc1"; + expect(parseDispatchRef(`owner/repo/.github/workflows/file.yml@${sha}`)).toBe(""); + }); + + it("uses lastIndexOf so an '@' in the workflow path does not mis-parse the ref", () => { + // Pathological but valid: if the path segment contained '@', lastIndexOf ensures + // we capture the ref portion after the final '@'. + expect(parseDispatchRef("owner/repo@org/.github/workflows/file.yml@refs/heads/main")).toBe("refs/heads/main"); + }); + + it("does not return a SHA-like value for a tag ref", () => { + const result = parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/tags/v2.0.0"); + expect(result).toBe("refs/tags/v2.0.0"); + // Must not look like a SHA (40 hex chars) + expect(result).not.toMatch(/^[0-9a-f]{40}$/); + }); +}); + +describe("resolve_host_repo main", () => { + let outputs; + let warnings; + let infos; + let originalEnv; + + beforeEach(() => { + outputs = {}; + warnings = []; + infos = []; + + global.core = { + info: vi.fn(msg => infos.push(msg)), + warning: vi.fn(msg => warnings.push(msg)), + error: vi.fn(), + setOutput: vi.fn((key, value) => { + outputs[key] = value; + }), + }; + + originalEnv = { + JOB_WORKFLOW_REPOSITORY: process.env.JOB_WORKFLOW_REPOSITORY, + JOB_WORKFLOW_SHA: process.env.JOB_WORKFLOW_SHA, + JOB_WORKFLOW_REF: process.env.JOB_WORKFLOW_REF, + JOB_WORKFLOW_FILE_PATH: process.env.JOB_WORKFLOW_FILE_PATH, + GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY, + }; + }); + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("emits target_checkout_ref as the commit SHA from JOB_WORKFLOW_SHA", async () => { + process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo"; + process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1"; + process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/orchestrator.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "owner/platform-repo"; + + await main(); + + expect(outputs["target_checkout_ref"]).toBe("abc123def456abc123def456abc123def456abc1"); + }); + + it("emits target_ref as the dispatch-compatible branch ref from JOB_WORKFLOW_REF", async () => { + process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo"; + process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1"; + process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/orchestrator.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "owner/platform-repo"; + + await main(); + + expect(outputs["target_ref"]).toBe("refs/heads/main"); + }); + + it("emits a tag as target_ref when workflow_ref contains a tag", async () => { + process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo"; + process.env.JOB_WORKFLOW_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/release.yml@refs/tags/v1.2.3"; + process.env.GITHUB_REPOSITORY = "owner/platform-repo"; + + await main(); + + expect(outputs["target_ref"]).toBe("refs/tags/v1.2.3"); + }); + + it("target_ref is never a SHA when JOB_WORKFLOW_REF is provided", async () => { + const sha = "abc123def456abc123def456abc123def456abc1"; + process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo"; + process.env.JOB_WORKFLOW_SHA = sha; + process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/file.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "owner/platform-repo"; + + await main(); + + expect(outputs["target_ref"]).not.toBe(sha); + expect(outputs["target_ref"]).toBe("refs/heads/main"); + }); + + it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF is missing", async () => { + process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo"; + process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1"; + delete process.env.JOB_WORKFLOW_REF; + process.env.GITHUB_REPOSITORY = "owner/platform-repo"; + + await main(); + + expect(outputs["target_ref"]).toBe(""); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]).toContain("Could not parse"); + }); + + it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF has no '@' separator", async () => { + process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo"; + process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1"; + process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/file.yml"; + process.env.GITHUB_REPOSITORY = "owner/platform-repo"; + + await main(); + + expect(outputs["target_ref"]).toBe(""); + expect(warnings.length).toBeGreaterThan(0); + }); + + it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF ends with a commit SHA", async () => { + // workflow_call can pin to a SHA; createWorkflowDispatch cannot accept a SHA as ref. + const sha = "abc123def456abc123def456abc123def456abc1"; + process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo"; + process.env.JOB_WORKFLOW_SHA = sha; + process.env.JOB_WORKFLOW_REF = `owner/platform-repo/.github/workflows/file.yml@${sha}`; + process.env.GITHUB_REPOSITORY = "owner/platform-repo"; + + await main(); + + expect(outputs["target_ref"]).toBe(""); + expect(warnings.length).toBeGreaterThan(0); + expect(outputs["target_checkout_ref"]).toBe(sha); + }); + + it("emits target_repo and target_repo_name correctly", async () => { + process.env.JOB_WORKFLOW_REPOSITORY = "my-org/my-platform"; + process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1"; + process.env.JOB_WORKFLOW_REF = "my-org/my-platform/.github/workflows/file.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "my-org/my-caller"; + + await main(); + + expect(outputs["target_repo"]).toBe("my-org/my-platform"); + expect(outputs["target_repo_name"]).toBe("my-platform"); + }); +}); diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index 5548f516869..c0ac0afa645 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -149,8 +149,10 @@ type CheckoutManager struct { // performing .github/.agents sparse checkout steps for cross-repo workflow_call // invocations pinned to a non-default branch. // - // In the activation job this is set to "${{ steps.resolve-host-repo.outputs.target_ref }}". - // In the agent and safe_outputs jobs it is set to "${{ needs.activation.outputs.target_ref }}". + // Currently only set in the activation job to + // "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}" (the immutable SHA). + // Downstream jobs (agent, safe_outputs) do not currently set this field — they rely + // on the default-branch checkout behaviour. // An empty string means the checkout uses the repository's default branch. crossRepoTargetRef string } @@ -190,8 +192,8 @@ func (cm *CheckoutManager) GetCrossRepoTargetRepo() string { // .github/.agents sparse checkout steps. Call this when the workflow has a workflow_call // trigger and the checkout should target a specific branch rather than the default branch. // -// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_ref }}". -// In downstream jobs (agent, safe_outputs) pass "${{ needs.activation.outputs.target_ref }}". +// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}" +// (the immutable SHA for exact-revision pinning). func (cm *CheckoutManager) SetCrossRepoTargetRef(ref string) { checkoutManagerLog.Printf("Setting cross-repo target ref: %q", ref) cm.crossRepoTargetRef = ref diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 04e5140cde4..d039d376a95 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -341,8 +341,12 @@ func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *Workflow // correctly identifying the platform repo in all relay patterns (cross-repo workflow_call, // event-driven relays like on: issue_comment, on: push, and cross-org scenarios). // -// job.workflow_sha provides the immutable commit SHA of the workflow being executed, ensuring -// the activation checkout pins to the exact revision rather than a moving branch/tag ref. +// The step emits two distinct ref outputs: +// - target_checkout_ref: the immutable commit SHA from job.workflow_sha, used by +// actions/checkout to pin the activation checkout to the exact executing revision. +// - target_ref: the branch/tag ref parsed from job.workflow_ref (e.g. refs/heads/main), +// used by dispatch_workflow safe outputs as the dispatch ref. The GitHub workflow +// dispatch API only accepts branch/tag refs, not commit SHAs. func (c *Compiler) generateResolveHostRepoStep(data *WorkflowData) string { var step strings.Builder step.WriteString(" - name: Resolve host repo for activation checkout\n") @@ -383,9 +387,10 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) // The agent job uses only the user-specified permissions (no automatic contents:read augmentation). // For workflow_call triggers, checkout the callee (platform) repository using the target_repo - // and target_ref outputs from the resolve-host-repo step. That step uses job.workflow_repository - // and job.workflow_sha to identify the platform repo and pin to the exact commit, - // correctly handling all relay patterns including cross-repo and cross-org scenarios. + // and target_checkout_ref outputs from the resolve-host-repo step. That step uses + // job.workflow_repository and job.workflow_sha to identify the platform repo and pin to the + // exact commit, correctly handling all relay patterns including cross-repo and cross-org scenarios. + // (target_checkout_ref carries the SHA; target_ref carries the dispatch-compatible branch/tag ref.) // // 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. @@ -416,7 +421,7 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) 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 }}") - cm.SetCrossRepoTargetRef("${{ steps.resolve-host-repo.outputs.target_ref }}") + cm.SetCrossRepoTargetRef("${{ steps.resolve-host-repo.outputs.target_checkout_ref }}") checkoutSteps := cm.GenerateGitHubFolderCheckoutStep( cm.GetCrossRepoTargetRepo(), cm.GetCrossRepoTargetRef(), diff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go index 0e7c637d500..6a53cc092a5 100644 --- a/pkg/workflow/compiler_activation_job_builder.go +++ b/pkg/workflow/compiler_activation_job_builder.go @@ -112,7 +112,13 @@ func (c *Compiler) newActivationJobBuildContext( if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { ctx.outputs["target_repo"] = "${{ steps.resolve-host-repo.outputs.target_repo }}" ctx.outputs["target_repo_name"] = "${{ steps.resolve-host-repo.outputs.target_repo_name }}" + // target_ref: dispatch-compatible branch/tag ref (e.g. refs/heads/main) parsed from + // job.workflow_ref. Used by dispatch_workflow safe outputs as the `ref` argument to + // createWorkflowDispatch. The GitHub workflow dispatch API does not accept commit SHAs. ctx.outputs["target_ref"] = "${{ steps.resolve-host-repo.outputs.target_ref }}" + // target_checkout_ref: immutable commit SHA from job.workflow_sha. Used by actions/checkout + // in the activation job to pin to the exact executing revision. + ctx.outputs["target_checkout_ref"] = "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}" } return ctx, nil diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go index df6ba38dd9a..4d122d3ea02 100644 --- a/pkg/workflow/compiler_activation_job_test.go +++ b/pkg/workflow/compiler_activation_job_test.go @@ -19,8 +19,8 @@ const workflowCallRepo = "${{ steps.resolve-host-repo.outputs.target_repo }}" // workflowCallRef is the expression injected into the ref: field of the activation-job // checkout step when a workflow_call trigger is detected without inlined imports. -// Uses job.workflow_sha for immutable pinning to the exact executing revision. -const workflowCallRef = "${{ steps.resolve-host-repo.outputs.target_ref }}" +// Uses target_checkout_ref (job.workflow_sha) for immutable pinning to the exact executing revision. +const workflowCallRef = "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}" // sameRepoCondition is the if: condition injected into the .github checkout step when // no custom activation token is configured. It restricts the checkout to same-repo @@ -505,6 +505,118 @@ func TestActivationJobTargetRefOutput(t *testing.T) { } } +// TestActivationJobTargetCheckoutRefOutput verifies that the activation job exposes +// target_checkout_ref (the immutable commit SHA) as an output when a workflow_call trigger +// is present (without inlined imports). This output is used by the activation checkout step +// for exact-revision pinning, distinct from target_ref which carries the dispatch-compatible +// branch/tag ref. +func TestActivationJobTargetCheckoutRefOutput(t *testing.T) { + tests := []struct { + name string + onSection string + inlinedImports bool + expectTargetCheckoutRef bool + }{ + { + name: "workflow_call trigger - target_checkout_ref output added", + onSection: `"on": + workflow_call:`, + expectTargetCheckoutRef: true, + }, + { + name: "mixed triggers with workflow_call - target_checkout_ref output added", + onSection: `"on": + issue_comment: + types: [created] + workflow_call:`, + expectTargetCheckoutRef: true, + }, + { + name: "workflow_call with inlined-imports - no target_checkout_ref output", + onSection: `"on": + workflow_call:`, + inlinedImports: true, + expectTargetCheckoutRef: false, + }, + { + name: "no workflow_call - no target_checkout_ref output", + onSection: `"on": + issues: + types: [opened]`, + expectTargetCheckoutRef: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompiler(WithVersion("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.expectTargetCheckoutRef { + assert.Contains(t, job.Outputs, "target_checkout_ref", + "activation job should expose target_checkout_ref output for checkout pinning") + assert.Equal(t, + "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}", + job.Outputs["target_checkout_ref"], + "target_checkout_ref output should reference resolve-host-repo step") + } else { + assert.NotContains(t, job.Outputs, "target_checkout_ref", + "activation job should not expose target_checkout_ref when workflow_call is absent or inlined-imports enabled") + } + }) + } +} + +// TestActivationJobTargetRefIsDispatchCompatible verifies that target_ref points to the +// dispatch-compatible step output (not target_checkout_ref/SHA), and that the activation +// checkout uses target_checkout_ref for exact-revision pinning. +func TestActivationJobTargetRefIsDispatchCompatible(t *testing.T) { + compiler := NewCompiler(WithVersion("dev")) + compiler.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + Name: "test-workflow", + On: `"on": + workflow_call:`, + 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") + + // target_ref should point to the dispatch-compatible step output + assert.Equal(t, + "${{ steps.resolve-host-repo.outputs.target_ref }}", + job.Outputs["target_ref"], + "target_ref should be the dispatch-compatible branch/tag ref, not the SHA") + + // target_checkout_ref should point to the SHA output + assert.Equal(t, + "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}", + job.Outputs["target_checkout_ref"], + "target_checkout_ref should be the immutable SHA for checkout pinning") + + // The checkout step itself must use target_checkout_ref (SHA), not target_ref + checkoutSteps := compiler.generateCheckoutGitHubFolderForActivation(data) + combined := strings.Join(checkoutSteps, "") + assert.Contains(t, combined, "ref: ${{ steps.resolve-host-repo.outputs.target_checkout_ref }}", + "activation checkout must use target_checkout_ref (SHA) for exact-revision pinning") + assert.NotContains(t, combined, "ref: ${{ steps.resolve-host-repo.outputs.target_ref }}", + "activation checkout must NOT use target_ref (branch/tag) to avoid ambiguity with dispatch ref") +} + // 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 diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index 707eada50ff..453c96f03c5 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -2889,3 +2889,43 @@ func TestPRPolicyFieldsExpressionsPassThrough(t *testing.T) { }) } } + +// TestDispatchWorkflowRelayInjectsDispatchCompatibleRef verifies that when a workflow_call +// trigger is present and dispatch_workflow safe-outputs are configured, the compiler injects +// needs.activation.outputs.target_ref (the dispatch-compatible branch/tag ref) — not +// needs.activation.outputs.target_checkout_ref (the SHA) — as the target-ref for dispatch. +// Sending a SHA to createWorkflowDispatch causes "No ref found for: " errors. +func TestDispatchWorkflowRelayInjectsDispatchCompatibleRef(t *testing.T) { + compiler := NewCompiler(WithVersion("dev")) + compiler.SetActionMode(ActionModeDev) + + safeOutputs := &SafeOutputsConfig{ + DispatchWorkflow: &DispatchWorkflowConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}, + Workflows: []string{"repo-worker"}, + }, + } + + data := &WorkflowData{ + Name: "test-relay", + On: `"on": + workflow_call: + workflow_dispatch:`, + SafeOutputs: safeOutputs, + AI: "copilot", + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, data) + require.NotEmpty(t, steps, "should produce at least one step env var") + + stepsContent := strings.Join(steps, "\n") + + // target_ref (dispatch-compatible branch/tag) must be injected + assert.Contains(t, stepsContent, "needs.activation.outputs.target_ref", + "dispatch target-ref must use needs.activation.outputs.target_ref (branch/tag ref)") + + // target_checkout_ref (SHA) must NOT be used as the dispatch ref + assert.NotContains(t, stepsContent, "target_checkout_ref", + "dispatch target-ref must NOT use target_checkout_ref (commit SHA)") +}