Skip to content

Commit d5b42bf

Browse files
authored
Fix cross-repo reusable workflow hash check: remove workflow_call event gate and correct workflow_ref assumptions (#24924)
1 parent a0e2a19 commit d5b42bf

3 files changed

Lines changed: 132 additions & 83 deletions

File tree

actions/setup/js/check_workflow_timestamp_api.cjs

Lines changed: 69 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@ async function main() {
4545

4646
// Determine workflow source repository from the workflow ref for cross-repo support.
4747
//
48-
// For cross-repo workflow_call invocations (reusable workflows called from another repo),
49-
// the GITHUB_WORKFLOW_REF env var always points to the TOP-LEVEL CALLER's workflow, not
50-
// the reusable workflow being executed. This causes the script to look for lock files in
51-
// the wrong repository.
48+
// For cross-repo reusable workflow invocations, both GITHUB_WORKFLOW_REF (env var) and
49+
// ${{ github.workflow_ref }} (injected as GH_AW_CONTEXT_WORKFLOW_REF) resolve to the
50+
// TOP-LEVEL CALLER's workflow, not the reusable workflow being executed. This causes the
51+
// script to look for lock files in the wrong repository when used alone.
5252
//
53-
// The GitHub Actions expression ${{ github.workflow_ref }} is injected as GH_AW_CONTEXT_WORKFLOW_REF
54-
// by the compiler and correctly identifies the CURRENT reusable workflow's ref even in
55-
// cross-repo workflow_call scenarios. We prefer it over GITHUB_WORKFLOW_REF when available.
53+
// The reliable fix is the referenced_workflows API lookup below, which identifies the
54+
// callee's repo/ref from the caller's run object. GH_AW_CONTEXT_WORKFLOW_REF is only
55+
// used as a fallback when the API lookup is unavailable or finds no matching entry.
5656
//
57-
// Ref: https://github.com/github/gh-aw/issues/23935
57+
// Refs: https://github.com/github/gh-aw/issues/23935
58+
// https://github.com/github/gh-aw/issues/24422
5859
const workflowEnvRef = process.env.GH_AW_CONTEXT_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF || "";
5960
const currentRepo = process.env.GITHUB_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;
6061

@@ -82,14 +83,18 @@ async function main() {
8283
ref = undefined;
8384
}
8485

85-
// For workflow_call events, use referenced_workflows from the GitHub API run object to
86-
// resolve the callee (reusable workflow) repo and ref.
86+
// Attempt referenced_workflows API lookup to detect cross-repo callee repo/ref.
87+
//
88+
// IMPORTANT: GITHUB_EVENT_NAME inside a reusable workflow reflects the ORIGINAL trigger
89+
// event (e.g., "push", "issues"), NOT "workflow_call". We therefore cannot rely on event
90+
// name to detect cross-repo scenarios.
91+
//
92+
// Similarly, GH_AW_CONTEXT_WORKFLOW_REF (${{ github.workflow_ref }}) resolves to the
93+
// CALLER's workflow ref, not the callee's. It is used as a fallback only when the API
94+
// lookup is unavailable or finds no matching entry.
8795
//
8896
// Resolution priority:
8997
// 1. referenced_workflows[].sha — immutable commit SHA from the callee repo (most precise).
90-
// GH_AW_CONTEXT_WORKFLOW_REF (${{ github.workflow_ref }}) correctly identifies the callee
91-
// in most cases, but referenced_workflows carries the pinned sha which won't drift if a
92-
// branch ref moves during a long-running job.
9398
// 2. referenced_workflows[].ref — branch/tag ref from the callee (fallback when sha absent).
9499
// 3. GH_AW_CONTEXT_WORKFLOW_REF — injected by the compiler; used when the API is unavailable
95100
// or when no matching entry is found in referenced_workflows.
@@ -98,60 +103,66 @@ async function main() {
98103
// are set to the caller's run ID and repo. The caller's run object includes a
99104
// referenced_workflows array listing the callee's exact path, sha, and ref.
100105
//
101-
// GITHUB_EVENT_NAME and GITHUB_RUN_ID are always set in GitHub Actions environments.
102-
// context.eventName / context.runId are fallbacks for environments where env vars are absent.
106+
// Short-circuit: if the env workflow ref already ends with the current workflow file,
107+
// the env vars already correctly identify the source (same-repo or non-reusable run).
108+
// Skip the API call to avoid unnecessary rate-limit usage and permission noise.
109+
//
110+
// GITHUB_RUN_ID is always set in GitHub Actions environments.
111+
// context.runId is a fallback for environments where env vars are absent.
103112
//
104-
// Ref: https://github.com/github/gh-aw/issues/24422
105-
const eventName = process.env.GITHUB_EVENT_NAME || context.eventName;
106-
if (eventName === "workflow_call") {
107-
const runId = parseInt(process.env.GITHUB_RUN_ID || String(context.runId), 10);
108-
if (Number.isFinite(runId)) {
109-
const [runOwner, runRepo] = currentRepo.split("/");
110-
try {
111-
core.info(`workflow_call event detected, resolving callee repo via referenced_workflows API (run ${runId})`);
112-
const runResponse = await github.rest.actions.getWorkflowRun({
113-
owner: runOwner,
114-
repo: runRepo,
115-
run_id: runId,
116-
});
117-
118-
const referencedWorkflows = runResponse.data.referenced_workflows || [];
119-
core.info(`Found ${referencedWorkflows.length} referenced workflow(s) in caller run`);
120-
121-
// Find the entry whose path matches the current workflow file.
122-
// Path format: "org/repo/.github/workflows/file.lock.yml@ref"
123-
// Using replace to robustly strip the optional @ref suffix before matching.
124-
const matchingEntry = referencedWorkflows.find(wf => {
125-
const pathWithoutRef = wf.path.replace(/@.*$/, "");
126-
return pathWithoutRef.endsWith(`/.github/workflows/${workflowFile}`);
127-
});
128-
129-
if (matchingEntry) {
130-
const pathMatch = matchingEntry.path.match(GITHUB_REPO_PATH_RE);
131-
if (pathMatch) {
132-
owner = pathMatch[1];
133-
repo = pathMatch[2];
134-
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
135-
ref = matchingEntry.sha || matchingEntry.ref || pathMatch[3];
136-
workflowRepo = `${owner}/${repo}`;
137-
core.info(`Resolved callee repo from referenced_workflows: ${owner}/${repo} @ ${ref || "(default branch)"}`);
138-
core.info(` Referenced workflow path: ${matchingEntry.path}`);
139-
}
140-
} else {
141-
core.info(`No matching entry in referenced_workflows for "${workflowFile}", falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
113+
// Refs: https://github.com/github/gh-aw/issues/24422
114+
const runId = parseInt(process.env.GITHUB_RUN_ID || String(context.runId), 10);
115+
const envRefWithoutAt = workflowEnvRef.replace(/@.*$/, "");
116+
const envRefMatchesWorkflow = envRefWithoutAt.endsWith(`/.github/workflows/${workflowFile}`);
117+
118+
if (envRefMatchesWorkflow) {
119+
core.info("Env workflow ref already identifies this workflow, skipping referenced_workflows API lookup");
120+
} else if (Number.isFinite(runId)) {
121+
const [runOwner, runRepo] = currentRepo.split("/");
122+
try {
123+
core.info(`Checking for cross-repo callee via referenced_workflows API (run ${runId})`);
124+
const runResponse = await github.rest.actions.getWorkflowRun({
125+
owner: runOwner,
126+
repo: runRepo,
127+
run_id: runId,
128+
});
129+
130+
const referencedWorkflows = runResponse.data.referenced_workflows || [];
131+
core.info(`Found ${referencedWorkflows.length} referenced workflow(s) in run`);
132+
133+
// Find the entry whose path matches the current workflow file.
134+
// Path format: "org/repo/.github/workflows/file.lock.yml@ref"
135+
// Using replace to robustly strip the optional @ref suffix before matching.
136+
const matchingEntry = referencedWorkflows.find(wf => {
137+
const pathWithoutRef = wf.path.replace(/@.*$/, "");
138+
return pathWithoutRef.endsWith(`/.github/workflows/${workflowFile}`);
139+
});
140+
141+
if (matchingEntry) {
142+
const pathMatch = matchingEntry.path.match(GITHUB_REPO_PATH_RE);
143+
if (pathMatch) {
144+
owner = pathMatch[1];
145+
repo = pathMatch[2];
146+
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
147+
ref = matchingEntry.sha || matchingEntry.ref || pathMatch[3];
148+
workflowRepo = `${owner}/${repo}`;
149+
core.info(`Resolved callee repo from referenced_workflows: ${owner}/${repo} @ ${ref || "(default branch)"}`);
150+
core.info(` Referenced workflow path: ${matchingEntry.path}`);
142151
}
143-
} catch (error) {
144-
core.info(`Could not fetch referenced_workflows from API: ${getErrorMessage(error)}, falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
152+
} else {
153+
core.info(`No matching entry in referenced_workflows for "${workflowFile}", falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
145154
}
146-
} else {
147-
core.info("workflow_call event detected but run ID is unavailable or invalid, falling back to GH_AW_CONTEXT_WORKFLOW_REF");
155+
} catch (error) {
156+
core.info(`Could not fetch referenced_workflows from API: ${getErrorMessage(error)}, falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
148157
}
158+
} else {
159+
core.info("Run ID is unavailable or invalid, falling back to GH_AW_CONTEXT_WORKFLOW_REF");
149160
}
150161

151162
const contextWorkflowRef = process.env.GH_AW_CONTEXT_WORKFLOW_REF;
152163
core.info(`GITHUB_WORKFLOW_REF: ${process.env.GITHUB_WORKFLOW_REF || "(not set)"}`);
153164
if (contextWorkflowRef) {
154-
core.info(`GH_AW_CONTEXT_WORKFLOW_REF: ${contextWorkflowRef} (used for source repo resolution)`);
165+
core.info(`GH_AW_CONTEXT_WORKFLOW_REF: ${contextWorkflowRef} (available as env fallback)`);
155166
}
156167
core.info(`GITHUB_REPOSITORY: ${currentRepo}`);
157168
core.info(`Resolved source repo: ${owner}/${repo} @ ${ref || "(default branch)"}`);

actions/setup/js/check_workflow_timestamp_api.test.cjs

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -794,25 +794,28 @@ engine: copilot
794794
});
795795
});
796796

797-
describe("cross-repo invocation via workflow_call (GH_AW_CONTEXT_WORKFLOW_REF fix)", () => {
797+
describe("manual GH_AW_CONTEXT_WORKFLOW_REF fallback override", () => {
798798
// Regression test for https://github.com/github/gh-aw/issues/23935
799-
// When a reusable workflow is invoked cross-repo via workflow_call:
800-
// - GITHUB_WORKFLOW_REF (env var) = top-level CALLER's workflow (e.g., repo-b/caller.yml@main)
801-
// - GH_AW_CONTEXT_WORKFLOW_REF (injected from ${{ github.workflow_ref }}) = the CALLEE's reusable workflow
802-
// Without this fix, the script would look for lock files in the caller's repo (404).
799+
// In reusable workflow contexts, both GITHUB_WORKFLOW_REF and
800+
// ${{ github.workflow_ref }} resolve to the caller's workflow.
801+
// The referenced_workflows API lookup is the primary fix for identifying the callee
802+
// workflow. These tests cover the fallback path used when that API lookup is bypassed
803+
// by the short-circuit (the env ref already ends with the current workflow file, meaning
804+
// GH_AW_CONTEXT_WORKFLOW_REF was manually set to the callee's ref as a targeted override).
803805

804806
beforeEach(() => {
805807
process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml";
806-
// Simulate workflow_call cross-repo: reusable workflow defined in platform-repo,
807-
// called from caller-repo. GITHUB_WORKFLOW_REF wrongly points to the caller's workflow.
808+
// Simulate a caller workflow context where GITHUB_WORKFLOW_REF points at the caller.
808809
process.env.GITHUB_WORKFLOW_REF = "caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main";
809810
process.env.GITHUB_REPOSITORY = "caller-owner/caller-repo";
810-
// GH_AW_CONTEXT_WORKFLOW_REF is injected by the compiler from ${{ github.workflow_ref }}
811-
// which correctly identifies the reusable workflow being executed.
811+
// Manually inject GH_AW_CONTEXT_WORKFLOW_REF to exercise the fallback/override path.
812+
// This value intentionally points to the callee repo (platform-repo) so the env ref
813+
// ends with "/.github/workflows/test.lock.yml", triggering the short-circuit and
814+
// bypassing the API lookup.
812815
process.env.GH_AW_CONTEXT_WORKFLOW_REF = "platform-owner/platform-repo/.github/workflows/test.lock.yml@refs/heads/main";
813816
});
814817

815-
it("should use GH_AW_CONTEXT_WORKFLOW_REF to identify source repo, not GITHUB_WORKFLOW_REF", async () => {
818+
it("should use GH_AW_CONTEXT_WORKFLOW_REF override to identify source repo when env ref matches workflow file", async () => {
816819
const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3";
817820
const lockFileContent = `# frontmatter-hash: ${validHash}\nname: Test\n`;
818821
const mdFileContent = "---\nengine: copilot\n---\n# Test";
@@ -827,7 +830,7 @@ engine: copilot
827830

828831
await main();
829832

830-
// Must use the platform repo (from GH_AW_CONTEXT_WORKFLOW_REF), not the caller repo
833+
// Must use the platform repo (from GH_AW_CONTEXT_WORKFLOW_REF override), not the caller repo
831834
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "platform-owner", repo: "platform-repo" }));
832835
expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ owner: "caller-owner", repo: "caller-repo" }));
833836
expect(mockCore.setFailed).not.toHaveBeenCalled();
@@ -840,7 +843,7 @@ engine: copilot
840843
await main();
841844

842845
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GH_AW_CONTEXT_WORKFLOW_REF: platform-owner/platform-repo/.github/workflows/test.lock.yml@refs/heads/main"));
843-
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKFLOW_REF: caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main"));
846+
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("available as env fallback"));
844847
});
845848

846849
it("should detect cross-repo invocation using GH_AW_CONTEXT_WORKFLOW_REF source vs GITHUB_REPOSITORY", async () => {
@@ -919,13 +922,30 @@ engine: copilot
919922
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ ref: "refs/heads/main" }));
920923
expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ ref: "abc123" }));
921924
});
925+
926+
it("should skip referenced_workflows API when env ref already matches the workflow file, even with a valid GITHUB_RUN_ID", async () => {
927+
// Short-circuit: if the env ref ends with the current workflowFile, the API call is
928+
// skipped to avoid unnecessary rate-limit usage in normal (non-reusable) runs.
929+
process.env.GITHUB_RUN_ID = "99999";
930+
mockGithub.rest.repos.getContent.mockResolvedValue({ data: null });
931+
932+
await main();
933+
934+
// API must NOT be called — env ref already identifies this workflow
935+
expect(mockGithub.rest.actions.getWorkflowRun).not.toHaveBeenCalled();
936+
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("skipping referenced_workflows API lookup"));
937+
});
922938
});
923939

924940
describe("cross-repo reusable workflow via referenced_workflows API (issue #24422)", () => {
925-
// Fix for https://github.com/github/gh-aw/issues/24422
926-
// When a reusable workflow is triggered by workflow_call, github.workflow_ref
927-
// can still point to the caller's workflow. This fix uses referenced_workflows
928-
// from the GitHub Actions API run object to reliably identify the callee's repo.
941+
// Fix for https://github.com/github/gh-aw/issues/24422 and cross-repo bug
942+
// When a reusable workflow is triggered, GITHUB_EVENT_NAME reflects the ORIGINAL trigger
943+
// event (e.g., "push", "issues"), NOT "workflow_call". We therefore cannot rely on event
944+
// name to detect cross-repo scenarios.
945+
//
946+
// Additionally, github.workflow_ref (injected as GH_AW_CONTEXT_WORKFLOW_REF) resolves to
947+
// the CALLER's workflow ref, not the callee's. The referenced_workflows API lookup from
948+
// the caller's run object is the reliable way to identify the callee's repo and ref.
929949
//
930950
// In the workflow_call context, GITHUB_RUN_ID and GITHUB_REPOSITORY are set to
931951
// the caller's run and repo. The caller's run object includes referenced_workflows
@@ -937,7 +957,7 @@ engine: copilot
937957
process.env.GITHUB_RUN_ID = "12345";
938958
// GITHUB_REPOSITORY is the caller's repo in a workflow_call context
939959
process.env.GITHUB_REPOSITORY = "caller-owner/caller-repo";
940-
// GH_AW_CONTEXT_WORKFLOW_REF (from ${{ github.workflow_ref }}) may still point to caller
960+
// GH_AW_CONTEXT_WORKFLOW_REF (from ${{ github.workflow_ref }}) resolves to the caller
941961
process.env.GH_AW_CONTEXT_WORKFLOW_REF = "caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main";
942962
process.env.GITHUB_WORKFLOW_REF = "caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main";
943963
});
@@ -994,7 +1014,7 @@ engine: copilot
9941014

9951015
await main();
9961016

997-
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("workflow_call event detected"));
1017+
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Checking for cross-repo callee via referenced_workflows API"));
9981018
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved callee repo from referenced_workflows: callee-owner/callee-repo"));
9991019
});
10001020

@@ -1044,13 +1064,29 @@ engine: copilot
10441064
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "caller-owner", repo: "caller-repo" }));
10451065
});
10461066

1047-
it("should not call referenced_workflows API for non-workflow_call events", async () => {
1067+
it("should call referenced_workflows API even for non-workflow_call events", async () => {
1068+
// In reusable workflows, GITHUB_EVENT_NAME reflects the original trigger event (e.g.,
1069+
// "push"), not "workflow_call". We must try referenced_workflows regardless of event name.
10481070
process.env.GITHUB_EVENT_NAME = "push";
1071+
mockGithub.rest.actions.getWorkflowRun.mockResolvedValueOnce({
1072+
data: {
1073+
referenced_workflows: [
1074+
{
1075+
path: "callee-owner/callee-repo/.github/workflows/callee-workflow.lock.yml@refs/heads/main",
1076+
sha: "deadbeef",
1077+
ref: "refs/heads/main",
1078+
},
1079+
],
1080+
},
1081+
});
10491082
mockGithub.rest.repos.getContent.mockResolvedValue({ data: null });
10501083

10511084
await main();
10521085

1053-
expect(mockGithub.rest.actions.getWorkflowRun).not.toHaveBeenCalled();
1086+
// API must be called even for "push" events
1087+
expect(mockGithub.rest.actions.getWorkflowRun).toHaveBeenCalled();
1088+
// Resolves to callee repo even though GITHUB_EVENT_NAME is "push"
1089+
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "callee-owner", repo: "callee-repo" }));
10541090
});
10551091

10561092
it("should prefer sha over ref from referenced_workflows entry", async () => {
@@ -1112,7 +1148,7 @@ engine: copilot
11121148

11131149
// API must not be called with a NaN run_id
11141150
expect(mockGithub.rest.actions.getWorkflowRun).not.toHaveBeenCalled();
1115-
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("run ID is unavailable or invalid"));
1151+
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Run ID is unavailable or invalid"));
11161152
// Falls back to caller repo from GH_AW_CONTEXT_WORKFLOW_REF
11171153
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "caller-owner", repo: "caller-repo" }));
11181154
});

0 commit comments

Comments
 (0)