Skip to content

Commit db92a84

Browse files
authored
fix: dispatch-workflow fails with "No ref found" when target-ref is a commit SHA (#30426)
1 parent f4c5ee6 commit db92a84

9 files changed

Lines changed: 432 additions & 30 deletions

.github/workflows/smoke-workflow-call-with-inputs.lock.yml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-workflow-call.lock.yml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/resolve_host_repo.cjs

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,94 @@
22
/// <reference types="@actions/github-script" />
33

44
/**
5-
* Resolves the target repository and ref for the activation job checkout.
5+
* Resolves the target repository and refs for the activation job.
66
*
77
* Uses the job.workflow_* context fields to determine the platform (host)
8-
* repository and pin the checkout to the exact executing commit SHA.
8+
* repository and produce two distinct ref outputs for different consumers:
9+
*
10+
* - target_checkout_ref: the immutable commit SHA from job.workflow_sha, used
11+
* by actions/checkout to pin the activation checkout to the exact executing
12+
* revision rather than a moving branch/tag ref.
13+
*
14+
* - target_ref: the branch or tag ref parsed from job.workflow_ref (the
15+
* substring after "@", e.g. "refs/heads/main" from
16+
* "owner/repo/.github/workflows/file.yml@refs/heads/main"), used by
17+
* dispatch_workflow.cjs as the `ref` argument to createWorkflowDispatch.
18+
* The GitHub workflow dispatch API only accepts branch/tag refs; passing a
19+
* commit SHA causes "No ref found for: <sha>" errors.
920
*
1021
* These fields are passed via environment variables (JOB_WORKFLOW_REPOSITORY,
11-
* JOB_WORKFLOW_SHA, etc.) to avoid shell injection — the ${{ }} expressions
12-
* are evaluated in the env: block, not interpolated into script source.
22+
* JOB_WORKFLOW_SHA, JOB_WORKFLOW_REF, etc.) to avoid shell injection — the
23+
* ${{ }} expressions are evaluated in the env: block, not interpolated into
24+
* script source.
1325
*
1426
* job.workflow_repository provides the owner/repo of the currently executing
1527
* workflow file, correctly identifying the platform repo in all relay patterns:
1628
* cross-repo workflow_call, event-driven relays (on: issue_comment, on: push),
1729
* and cross-org scenarios.
1830
*
19-
* job.workflow_sha provides the immutable commit SHA of the workflow being
20-
* executed, ensuring the activation checkout pins to the exact revision rather
21-
* than a moving branch/tag ref.
22-
*
2331
* @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runner context via env vars only
2432
*/
2533

34+
/**
35+
* Parses the dispatch-compatible branch/tag ref from job.workflow_ref.
36+
* job.workflow_ref has the form "owner/repo/.github/workflows/file.yml@refs/heads/main".
37+
* Returns the substring after the last "@", or an empty string if missing, malformed,
38+
* or if the extracted value looks like a commit SHA (40 lowercase hex characters —
39+
* these are not accepted by the workflow dispatch API).
40+
*
41+
* Uses lastIndexOf to handle the unlikely case of an "@" in the workflow path.
42+
*
43+
* @param {string} workflowRef
44+
* @returns {string}
45+
*/
46+
function parseDispatchRef(workflowRef) {
47+
if (!workflowRef) {
48+
return "";
49+
}
50+
const atIndex = workflowRef.lastIndexOf("@");
51+
if (atIndex === -1) {
52+
return "";
53+
}
54+
const ref = workflowRef.slice(atIndex + 1);
55+
// Reject SHA-like values (40 lowercase hex chars). workflow_call can reference a
56+
// workflow by commit SHA, but createWorkflowDispatch does not accept SHAs as refs.
57+
if (/^[0-9a-f]{40}$/.test(ref)) {
58+
return "";
59+
}
60+
return ref;
61+
}
62+
2663
/**
2764
* @returns {Promise<void>}
2865
*/
2966
async function main() {
3067
const targetRepo = process.env.JOB_WORKFLOW_REPOSITORY || "";
31-
const targetRef = process.env.JOB_WORKFLOW_SHA || "";
68+
const targetCheckoutRef = process.env.JOB_WORKFLOW_SHA || "";
69+
const workflowRef = process.env.JOB_WORKFLOW_REF || "";
70+
const targetDispatchRef = parseDispatchRef(workflowRef);
3271
const targetRepoName = targetRepo.split("/").pop() || "";
3372
const currentRepo = process.env.GITHUB_REPOSITORY || "";
3473

3574
core.info("Resolving host repo via job.workflow_* context");
3675
core.info(`job.workflow_repository = ${targetRepo}`);
37-
core.info(`job.workflow_sha = ${targetRef}`);
38-
core.info(`job.workflow_ref = ${process.env.JOB_WORKFLOW_REF || ""}`);
76+
core.info(`job.workflow_sha = ${targetCheckoutRef}`);
77+
core.info(`job.workflow_ref = ${workflowRef}`);
3978
core.info(`job.workflow_file_path = ${process.env.JOB_WORKFLOW_FILE_PATH || ""}`);
4079
core.info(`github.repository = ${currentRepo}`);
4180
core.info("");
42-
core.info(`Resolved target_repo = ${targetRepo}`);
43-
core.info(`Resolved target_repo_name = ${targetRepoName}`);
44-
core.info(`Resolved target_ref = ${targetRef}`);
81+
core.info(`Resolved target_repo = ${targetRepo}`);
82+
core.info(`Resolved target_repo_name = ${targetRepoName}`);
83+
core.info(`Resolved target_checkout_ref = ${targetCheckoutRef}`);
84+
core.info(`Resolved target_ref = ${targetDispatchRef}`);
85+
86+
if (!targetDispatchRef) {
87+
core.warning(
88+
`Could not parse a branch/tag ref from JOB_WORKFLOW_REF="${workflowRef}". ` +
89+
"dispatch_workflow safe outputs may fail if they rely on target_ref. " +
90+
"Falling back to empty string — do not use target_checkout_ref (SHA) as the dispatch ref."
91+
);
92+
}
4593

4694
if (targetRepo && targetRepo !== currentRepo) {
4795
core.info(`Cross-repo invocation detected: platform repo "${targetRepo}" differs from caller "${currentRepo}"`);
@@ -51,7 +99,11 @@ async function main() {
5199

52100
core.setOutput("target_repo", targetRepo);
53101
core.setOutput("target_repo_name", targetRepoName);
54-
core.setOutput("target_ref", targetRef);
102+
// target_checkout_ref: immutable SHA used by actions/checkout for exact-revision pinning.
103+
core.setOutput("target_checkout_ref", targetCheckoutRef);
104+
// target_ref: dispatch-compatible branch/tag ref used by dispatch_workflow.cjs.
105+
// The GitHub workflow dispatch API requires a branch or tag, not a commit SHA.
106+
core.setOutput("target_ref", targetDispatchRef);
55107
}
56108

57-
module.exports = { main };
109+
module.exports = { main, parseDispatchRef };
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// @ts-check
2+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
3+
import { main, parseDispatchRef } from "./resolve_host_repo.cjs";
4+
5+
describe("parseDispatchRef", () => {
6+
it("parses refs/heads/main from a standard workflow_ref", () => {
7+
expect(parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/heads/main")).toBe("refs/heads/main");
8+
});
9+
10+
it("parses refs/tags/v1.2.3 from a tag-triggered workflow_ref", () => {
11+
expect(parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/tags/v1.2.3")).toBe("refs/tags/v1.2.3");
12+
});
13+
14+
it("parses refs/heads/feature/my-branch from a nested branch name", () => {
15+
expect(parseDispatchRef("org/repo/.github/workflows/ci.yml@refs/heads/feature/my-branch")).toBe("refs/heads/feature/my-branch");
16+
});
17+
18+
it("returns empty string when JOB_WORKFLOW_REF is empty", () => {
19+
expect(parseDispatchRef("")).toBe("");
20+
});
21+
22+
it("returns empty string when JOB_WORKFLOW_REF has no '@' separator", () => {
23+
expect(parseDispatchRef("owner/repo/.github/workflows/file.yml")).toBe("");
24+
});
25+
26+
it("returns empty string when JOB_WORKFLOW_REF ends with a 40-hex commit SHA", () => {
27+
// workflow_call can reference a workflow by commit SHA, but createWorkflowDispatch
28+
// rejects SHAs — parseDispatchRef must return "" to prevent dispatch failures.
29+
const sha = "abc123def456abc123def456abc123def456abc1";
30+
expect(parseDispatchRef(`owner/repo/.github/workflows/file.yml@${sha}`)).toBe("");
31+
});
32+
33+
it("uses lastIndexOf so an '@' in the workflow path does not mis-parse the ref", () => {
34+
// Pathological but valid: if the path segment contained '@', lastIndexOf ensures
35+
// we capture the ref portion after the final '@'.
36+
expect(parseDispatchRef("owner/repo@org/.github/workflows/file.yml@refs/heads/main")).toBe("refs/heads/main");
37+
});
38+
39+
it("does not return a SHA-like value for a tag ref", () => {
40+
const result = parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/tags/v2.0.0");
41+
expect(result).toBe("refs/tags/v2.0.0");
42+
// Must not look like a SHA (40 hex chars)
43+
expect(result).not.toMatch(/^[0-9a-f]{40}$/);
44+
});
45+
});
46+
47+
describe("resolve_host_repo main", () => {
48+
let outputs;
49+
let warnings;
50+
let infos;
51+
let originalEnv;
52+
53+
beforeEach(() => {
54+
outputs = {};
55+
warnings = [];
56+
infos = [];
57+
58+
global.core = {
59+
info: vi.fn(msg => infos.push(msg)),
60+
warning: vi.fn(msg => warnings.push(msg)),
61+
error: vi.fn(),
62+
setOutput: vi.fn((key, value) => {
63+
outputs[key] = value;
64+
}),
65+
};
66+
67+
originalEnv = {
68+
JOB_WORKFLOW_REPOSITORY: process.env.JOB_WORKFLOW_REPOSITORY,
69+
JOB_WORKFLOW_SHA: process.env.JOB_WORKFLOW_SHA,
70+
JOB_WORKFLOW_REF: process.env.JOB_WORKFLOW_REF,
71+
JOB_WORKFLOW_FILE_PATH: process.env.JOB_WORKFLOW_FILE_PATH,
72+
GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY,
73+
};
74+
});
75+
76+
afterEach(() => {
77+
for (const [key, value] of Object.entries(originalEnv)) {
78+
if (value === undefined) {
79+
delete process.env[key];
80+
} else {
81+
process.env[key] = value;
82+
}
83+
}
84+
});
85+
86+
it("emits target_checkout_ref as the commit SHA from JOB_WORKFLOW_SHA", async () => {
87+
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
88+
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
89+
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/orchestrator.yml@refs/heads/main";
90+
process.env.GITHUB_REPOSITORY = "owner/platform-repo";
91+
92+
await main();
93+
94+
expect(outputs["target_checkout_ref"]).toBe("abc123def456abc123def456abc123def456abc1");
95+
});
96+
97+
it("emits target_ref as the dispatch-compatible branch ref from JOB_WORKFLOW_REF", async () => {
98+
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
99+
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
100+
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/orchestrator.yml@refs/heads/main";
101+
process.env.GITHUB_REPOSITORY = "owner/platform-repo";
102+
103+
await main();
104+
105+
expect(outputs["target_ref"]).toBe("refs/heads/main");
106+
});
107+
108+
it("emits a tag as target_ref when workflow_ref contains a tag", async () => {
109+
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
110+
process.env.JOB_WORKFLOW_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
111+
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/release.yml@refs/tags/v1.2.3";
112+
process.env.GITHUB_REPOSITORY = "owner/platform-repo";
113+
114+
await main();
115+
116+
expect(outputs["target_ref"]).toBe("refs/tags/v1.2.3");
117+
});
118+
119+
it("target_ref is never a SHA when JOB_WORKFLOW_REF is provided", async () => {
120+
const sha = "abc123def456abc123def456abc123def456abc1";
121+
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
122+
process.env.JOB_WORKFLOW_SHA = sha;
123+
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/file.yml@refs/heads/main";
124+
process.env.GITHUB_REPOSITORY = "owner/platform-repo";
125+
126+
await main();
127+
128+
expect(outputs["target_ref"]).not.toBe(sha);
129+
expect(outputs["target_ref"]).toBe("refs/heads/main");
130+
});
131+
132+
it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF is missing", async () => {
133+
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
134+
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
135+
delete process.env.JOB_WORKFLOW_REF;
136+
process.env.GITHUB_REPOSITORY = "owner/platform-repo";
137+
138+
await main();
139+
140+
expect(outputs["target_ref"]).toBe("");
141+
expect(warnings.length).toBeGreaterThan(0);
142+
expect(warnings[0]).toContain("Could not parse");
143+
});
144+
145+
it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF has no '@' separator", async () => {
146+
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
147+
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
148+
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/file.yml";
149+
process.env.GITHUB_REPOSITORY = "owner/platform-repo";
150+
151+
await main();
152+
153+
expect(outputs["target_ref"]).toBe("");
154+
expect(warnings.length).toBeGreaterThan(0);
155+
});
156+
157+
it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF ends with a commit SHA", async () => {
158+
// workflow_call can pin to a SHA; createWorkflowDispatch cannot accept a SHA as ref.
159+
const sha = "abc123def456abc123def456abc123def456abc1";
160+
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
161+
process.env.JOB_WORKFLOW_SHA = sha;
162+
process.env.JOB_WORKFLOW_REF = `owner/platform-repo/.github/workflows/file.yml@${sha}`;
163+
process.env.GITHUB_REPOSITORY = "owner/platform-repo";
164+
165+
await main();
166+
167+
expect(outputs["target_ref"]).toBe("");
168+
expect(warnings.length).toBeGreaterThan(0);
169+
expect(outputs["target_checkout_ref"]).toBe(sha);
170+
});
171+
172+
it("emits target_repo and target_repo_name correctly", async () => {
173+
process.env.JOB_WORKFLOW_REPOSITORY = "my-org/my-platform";
174+
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
175+
process.env.JOB_WORKFLOW_REF = "my-org/my-platform/.github/workflows/file.yml@refs/heads/main";
176+
process.env.GITHUB_REPOSITORY = "my-org/my-caller";
177+
178+
await main();
179+
180+
expect(outputs["target_repo"]).toBe("my-org/my-platform");
181+
expect(outputs["target_repo_name"]).toBe("my-platform");
182+
});
183+
});

pkg/workflow/checkout_manager.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,10 @@ type CheckoutManager struct {
149149
// performing .github/.agents sparse checkout steps for cross-repo workflow_call
150150
// invocations pinned to a non-default branch.
151151
//
152-
// In the activation job this is set to "${{ steps.resolve-host-repo.outputs.target_ref }}".
153-
// In the agent and safe_outputs jobs it is set to "${{ needs.activation.outputs.target_ref }}".
152+
// Currently only set in the activation job to
153+
// "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}" (the immutable SHA).
154+
// Downstream jobs (agent, safe_outputs) do not currently set this field — they rely
155+
// on the default-branch checkout behaviour.
154156
// An empty string means the checkout uses the repository's default branch.
155157
crossRepoTargetRef string
156158
}
@@ -190,8 +192,8 @@ func (cm *CheckoutManager) GetCrossRepoTargetRepo() string {
190192
// .github/.agents sparse checkout steps. Call this when the workflow has a workflow_call
191193
// trigger and the checkout should target a specific branch rather than the default branch.
192194
//
193-
// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_ref }}".
194-
// In downstream jobs (agent, safe_outputs) pass "${{ needs.activation.outputs.target_ref }}".
195+
// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}"
196+
// (the immutable SHA for exact-revision pinning).
195197
func (cm *CheckoutManager) SetCrossRepoTargetRef(ref string) {
196198
checkoutManagerLog.Printf("Setting cross-repo target ref: %q", ref)
197199
cm.crossRepoTargetRef = ref

0 commit comments

Comments
 (0)