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)")
+}