Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 61 additions & 57 deletions actions/setup/js/check_workflow_timestamp_api.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,17 @@ async function main() {

// Determine workflow source repository from the workflow ref for cross-repo support.
//
// For cross-repo workflow_call invocations (reusable workflows called from another repo),
// the GITHUB_WORKFLOW_REF env var always points to the TOP-LEVEL CALLER's workflow, not
// the reusable workflow being executed. This causes the script to look for lock files in
// the wrong repository.
// For cross-repo reusable workflow invocations, both GITHUB_WORKFLOW_REF (env var) and
// ${{ github.workflow_ref }} (injected as GH_AW_CONTEXT_WORKFLOW_REF) resolve to the
// TOP-LEVEL CALLER's workflow, not the reusable workflow being executed. This causes the
// script to look for lock files in the wrong repository when used alone.
//
// The GitHub Actions expression ${{ github.workflow_ref }} is injected as GH_AW_CONTEXT_WORKFLOW_REF
// by the compiler and correctly identifies the CURRENT reusable workflow's ref even in
// cross-repo workflow_call scenarios. We prefer it over GITHUB_WORKFLOW_REF when available.
// The reliable fix is the referenced_workflows API lookup below, which identifies the
// callee's repo/ref from the caller's run object. GH_AW_CONTEXT_WORKFLOW_REF is only
// used as a fallback when the API lookup is unavailable or finds no matching entry.
Comment on lines +54 to +55
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The script comments say GH_AW_CONTEXT_WORKFLOW_REF is only a fallback when the referenced_workflows API lookup misses/unavailable, but later logging still states GH_AW_CONTEXT_WORKFLOW_REF ... (used for source repo resolution) even when the API successfully resolves the callee. Update the log wording (or gate it on whether fallback was actually used) to avoid misleading diagnostics.

Suggested change
// callee's repo/ref from the caller's run object. GH_AW_CONTEXT_WORKFLOW_REF is only
// used as a fallback when the API lookup is unavailable or finds no matching entry.
// callee's repo/ref from the caller's run object. GH_AW_CONTEXT_WORKFLOW_REF remains an
// available parsed input here, but the API result is authoritative; the env ref serves as
// the fallback when the API lookup is unavailable or finds no matching entry.

Copilot uses AI. Check for mistakes.
//
// Ref: https://github.com/github/gh-aw/issues/23935
// Refs: https://github.com/github/gh-aw/issues/23935
// https://github.com/github/gh-aw/issues/24422
const workflowEnvRef = process.env.GH_AW_CONTEXT_WORKFLOW_REF || process.env.GITHUB_WORKFLOW_REF || "";
const currentRepo = process.env.GITHUB_REPOSITORY || `${context.repo.owner}/${context.repo.repo}`;

Expand Down Expand Up @@ -82,14 +83,20 @@ async function main() {
ref = undefined;
}

// For workflow_call events, use referenced_workflows from the GitHub API run object to
// resolve the callee (reusable workflow) repo and ref.
// Always attempt referenced_workflows API lookup to resolve the callee repo/ref.
// This handles cross-repo reusable workflow scenarios reliably.
//
// IMPORTANT: GITHUB_EVENT_NAME inside a reusable workflow reflects the ORIGINAL trigger
// event (e.g., "push", "issues"), NOT "workflow_call". We therefore cannot rely on event
// name to detect cross-repo scenarios and must always attempt the referenced_workflows
// API lookup.
//
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The referenced_workflows lookup now runs for every finite GITHUB_RUN_ID, which adds an extra Actions API call even when GITHUB_WORKFLOW_REF/GH_AW_CONTEXT_WORKFLOW_REF already point at the current lock workflow (common same-repo / non-reusable runs). Consider short-circuiting the getWorkflowRun call unless the env workflow ref does NOT reference GH_AW_WORKFLOW_FILE (or unless repo/ref parsing indicates a workflow_call context), to reduce API usage and avoid rate-limit/permission noise in normal runs.

Copilot uses AI. Check for mistakes.
// Similarly, GH_AW_CONTEXT_WORKFLOW_REF (${{ github.workflow_ref }}) resolves to the
// CALLER's workflow ref, not the callee's. It is used as a fallback only when the API
// lookup does not find a matching entry.
//
// Resolution priority:
// 1. referenced_workflows[].sha — immutable commit SHA from the callee repo (most precise).
// GH_AW_CONTEXT_WORKFLOW_REF (${{ github.workflow_ref }}) correctly identifies the callee
// in most cases, but referenced_workflows carries the pinned sha which won't drift if a
// branch ref moves during a long-running job.
// 2. referenced_workflows[].ref — branch/tag ref from the callee (fallback when sha absent).
// 3. GH_AW_CONTEXT_WORKFLOW_REF — injected by the compiler; used when the API is unavailable
// or when no matching entry is found in referenced_workflows.
Expand All @@ -98,54 +105,51 @@ async function main() {
// are set to the caller's run ID and repo. The caller's run object includes a
// referenced_workflows array listing the callee's exact path, sha, and ref.
//
// GITHUB_EVENT_NAME and GITHUB_RUN_ID are always set in GitHub Actions environments.
// context.eventName / context.runId are fallbacks for environments where env vars are absent.
// GITHUB_RUN_ID is always set in GitHub Actions environments.
// context.runId is a fallback for environments where env vars are absent.
//
// Ref: https://github.com/github/gh-aw/issues/24422
const eventName = process.env.GITHUB_EVENT_NAME || context.eventName;
if (eventName === "workflow_call") {
const runId = parseInt(process.env.GITHUB_RUN_ID || String(context.runId), 10);
if (Number.isFinite(runId)) {
const [runOwner, runRepo] = currentRepo.split("/");
try {
core.info(`workflow_call event detected, resolving callee repo via referenced_workflows API (run ${runId})`);
const runResponse = await github.rest.actions.getWorkflowRun({
owner: runOwner,
repo: runRepo,
run_id: runId,
});

const referencedWorkflows = runResponse.data.referenced_workflows || [];
core.info(`Found ${referencedWorkflows.length} referenced workflow(s) in caller run`);

// Find the entry whose path matches the current workflow file.
// Path format: "org/repo/.github/workflows/file.lock.yml@ref"
// Using replace to robustly strip the optional @ref suffix before matching.
const matchingEntry = referencedWorkflows.find(wf => {
const pathWithoutRef = wf.path.replace(/@.*$/, "");
return pathWithoutRef.endsWith(`/.github/workflows/${workflowFile}`);
});

if (matchingEntry) {
const pathMatch = matchingEntry.path.match(GITHUB_REPO_PATH_RE);
if (pathMatch) {
owner = pathMatch[1];
repo = pathMatch[2];
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
ref = matchingEntry.sha || matchingEntry.ref || pathMatch[3];
workflowRepo = `${owner}/${repo}`;
core.info(`Resolved callee repo from referenced_workflows: ${owner}/${repo} @ ${ref || "(default branch)"}`);
core.info(` Referenced workflow path: ${matchingEntry.path}`);
}
} else {
core.info(`No matching entry in referenced_workflows for "${workflowFile}", falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
// Refs: https://github.com/github/gh-aw/issues/24422
const runId = parseInt(process.env.GITHUB_RUN_ID || String(context.runId), 10);
if (Number.isFinite(runId)) {
const [runOwner, runRepo] = currentRepo.split("/");
try {
core.info(`Checking for cross-repo callee via referenced_workflows API (run ${runId})`);
const runResponse = await github.rest.actions.getWorkflowRun({
owner: runOwner,
repo: runRepo,
run_id: runId,
});

const referencedWorkflows = runResponse.data.referenced_workflows || [];
core.info(`Found ${referencedWorkflows.length} referenced workflow(s) in run`);

// Find the entry whose path matches the current workflow file.
// Path format: "org/repo/.github/workflows/file.lock.yml@ref"
// Using replace to robustly strip the optional @ref suffix before matching.
const matchingEntry = referencedWorkflows.find(wf => {
const pathWithoutRef = wf.path.replace(/@.*$/, "");
return pathWithoutRef.endsWith(`/.github/workflows/${workflowFile}`);
});

if (matchingEntry) {
const pathMatch = matchingEntry.path.match(GITHUB_REPO_PATH_RE);
if (pathMatch) {
owner = pathMatch[1];
repo = pathMatch[2];
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
ref = matchingEntry.sha || matchingEntry.ref || pathMatch[3];
workflowRepo = `${owner}/${repo}`;
core.info(`Resolved callee repo from referenced_workflows: ${owner}/${repo} @ ${ref || "(default branch)"}`);
core.info(` Referenced workflow path: ${matchingEntry.path}`);
}
} catch (error) {
core.info(`Could not fetch referenced_workflows from API: ${getErrorMessage(error)}, falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
} else {
core.info(`No matching entry in referenced_workflows for "${workflowFile}", falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
}
} else {
core.info("workflow_call event detected but run ID is unavailable or invalid, falling back to GH_AW_CONTEXT_WORKFLOW_REF");
} catch (error) {
core.info(`Could not fetch referenced_workflows from API: ${getErrorMessage(error)}, falling back to GH_AW_CONTEXT_WORKFLOW_REF`);
}
} else {
core.info("Run ID is unavailable or invalid, falling back to GH_AW_CONTEXT_WORKFLOW_REF");
}

const contextWorkflowRef = process.env.GH_AW_CONTEXT_WORKFLOW_REF;
Expand Down
51 changes: 38 additions & 13 deletions actions/setup/js/check_workflow_timestamp_api.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -798,17 +798,22 @@ engine: copilot
// Regression test for https://github.com/github/gh-aw/issues/23935
// When a reusable workflow is invoked cross-repo via workflow_call:
// - GITHUB_WORKFLOW_REF (env var) = top-level CALLER's workflow (e.g., repo-b/caller.yml@main)
// - GH_AW_CONTEXT_WORKFLOW_REF (injected from ${{ github.workflow_ref }}) = the CALLEE's reusable workflow
// Without this fix, the script would look for lock files in the caller's repo (404).
// - GH_AW_CONTEXT_WORKFLOW_REF (injected from ${{ github.workflow_ref }}) = CALLER's workflow too
// (github.workflow_ref resolves to the caller in reusable workflow contexts)
// The referenced_workflows API lookup is the primary fix; GH_AW_CONTEXT_WORKFLOW_REF is
// used as a fallback. These tests cover the fallback path (no GITHUB_RUN_ID set) where
// GH_AW_CONTEXT_WORKFLOW_REF happens to correctly identify the callee (e.g., same-repo case).

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This describe block documents that github.workflow_ref resolves to the caller in reusable workflow contexts, but the tests still set GH_AW_CONTEXT_WORKFLOW_REF to a different (callee) repo and treat it as a cross-repo fix. That scenario can’t occur from ${{ github.workflow_ref }} in cross-repo reusable workflows, so the test name/comments are misleading. Suggest re-framing these as “manual override/fallback env var” tests, or adjusting the setup to a realistic same-repo case where caller==callee repo (so fallback actually works).

See below for a potential fix:

  describe("manual GH_AW_CONTEXT_WORKFLOW_REF fallback override", () => {
    // Regression test for https://github.com/github/gh-aw/issues/23935
    // In reusable workflow contexts, both GITHUB_WORKFLOW_REF and
    // ${{ github.workflow_ref }} resolve to the caller's workflow.
    // The referenced_workflows API lookup is the primary fix for identifying the callee
    // workflow. These tests cover the fallback path used when that API lookup is unavailable
    // (for example, no GITHUB_RUN_ID is set) and GH_AW_CONTEXT_WORKFLOW_REF is manually
    // provided with the intended source workflow ref.

    beforeEach(() => {
      process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml";
      // Simulate a caller workflow context where GITHUB_WORKFLOW_REF points at the caller.
      process.env.GITHUB_WORKFLOW_REF = "caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main";
      process.env.GITHUB_REPOSITORY = "caller-owner/caller-repo";
      // Manually inject GH_AW_CONTEXT_WORKFLOW_REF to exercise the fallback/override path.
      // This value is intentionally the callee repo and is not meant to model the literal
      // value of ${{ github.workflow_ref }} in a cross-repo reusable workflow.
      process.env.GH_AW_CONTEXT_WORKFLOW_REF = "platform-owner/platform-repo/.github/workflows/test.lock.yml@refs/heads/main";
    });

    it("should prefer a manually provided GH_AW_CONTEXT_WORKFLOW_REF over GITHUB_WORKFLOW_REF for source repo resolution", async () => {

Copilot uses AI. Check for mistakes.
beforeEach(() => {
process.env.GH_AW_WORKFLOW_FILE = "test.lock.yml";
// Simulate workflow_call cross-repo: reusable workflow defined in platform-repo,
// called from caller-repo. GITHUB_WORKFLOW_REF wrongly points to the caller's workflow.
process.env.GITHUB_WORKFLOW_REF = "caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "caller-owner/caller-repo";
// GH_AW_CONTEXT_WORKFLOW_REF is injected by the compiler from ${{ github.workflow_ref }}
// which correctly identifies the reusable workflow being executed.
// GH_AW_CONTEXT_WORKFLOW_REF is used as a fallback for repo resolution when the
// referenced_workflows API lookup is unavailable (no GITHUB_RUN_ID in these tests).
// Note: in practice, ${{ github.workflow_ref }} resolves to the caller's workflow,
// but when set correctly it still serves as a reliable fallback.
process.env.GH_AW_CONTEXT_WORKFLOW_REF = "platform-owner/platform-repo/.github/workflows/test.lock.yml@refs/heads/main";
});

Expand Down Expand Up @@ -922,10 +927,14 @@ engine: copilot
});

describe("cross-repo reusable workflow via referenced_workflows API (issue #24422)", () => {
// Fix for https://github.com/github/gh-aw/issues/24422
// When a reusable workflow is triggered by workflow_call, github.workflow_ref
// can still point to the caller's workflow. This fix uses referenced_workflows
// from the GitHub Actions API run object to reliably identify the callee's repo.
// Fix for https://github.com/github/gh-aw/issues/24422 and cross-repo bug
// When a reusable workflow is triggered, GITHUB_EVENT_NAME reflects the ORIGINAL trigger
// event (e.g., "push", "issues"), NOT "workflow_call". We therefore cannot rely on event
// name to detect cross-repo scenarios.
//
// Additionally, github.workflow_ref (injected as GH_AW_CONTEXT_WORKFLOW_REF) resolves to
// the CALLER's workflow ref, not the callee's. The referenced_workflows API lookup from
// the caller's run object is the reliable way to identify the callee's repo and ref.
//
// In the workflow_call context, GITHUB_RUN_ID and GITHUB_REPOSITORY are set to
// the caller's run and repo. The caller's run object includes referenced_workflows
Expand All @@ -937,7 +946,7 @@ engine: copilot
process.env.GITHUB_RUN_ID = "12345";
// GITHUB_REPOSITORY is the caller's repo in a workflow_call context
process.env.GITHUB_REPOSITORY = "caller-owner/caller-repo";
// GH_AW_CONTEXT_WORKFLOW_REF (from ${{ github.workflow_ref }}) may still point to caller
// GH_AW_CONTEXT_WORKFLOW_REF (from ${{ github.workflow_ref }}) resolves to the caller
process.env.GH_AW_CONTEXT_WORKFLOW_REF = "caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main";
process.env.GITHUB_WORKFLOW_REF = "caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main";
});
Expand Down Expand Up @@ -994,7 +1003,7 @@ engine: copilot

await main();

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

Expand Down Expand Up @@ -1044,13 +1053,29 @@ engine: copilot
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "caller-owner", repo: "caller-repo" }));
});

it("should not call referenced_workflows API for non-workflow_call events", async () => {
it("should call referenced_workflows API even for non-workflow_call events", async () => {
// In reusable workflows, GITHUB_EVENT_NAME reflects the original trigger event (e.g.,
// "push"), not "workflow_call". We must try referenced_workflows regardless of event name.
process.env.GITHUB_EVENT_NAME = "push";
mockGithub.rest.actions.getWorkflowRun.mockResolvedValueOnce({
data: {
referenced_workflows: [
{
path: "callee-owner/callee-repo/.github/workflows/callee-workflow.lock.yml@refs/heads/main",
sha: "deadbeef",
ref: "refs/heads/main",
},
],
},
});
mockGithub.rest.repos.getContent.mockResolvedValue({ data: null });

await main();

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

it("should prefer sha over ref from referenced_workflows entry", async () => {
Expand Down Expand Up @@ -1112,7 +1137,7 @@ engine: copilot

// API must not be called with a NaN run_id
expect(mockGithub.rest.actions.getWorkflowRun).not.toHaveBeenCalled();
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("run ID is unavailable or invalid"));
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Run ID is unavailable or invalid"));
// Falls back to caller repo from GH_AW_CONTEXT_WORKFLOW_REF
expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "caller-owner", repo: "caller-repo" }));
});
Expand Down
10 changes: 6 additions & 4 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,12 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script")))
steps = append(steps, " env:\n")
steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_FILE: \"%s\"\n", lockFilename))
// Inject the GitHub Actions context workflow_ref expression so that check_workflow_timestamp_api.cjs
// can identify the source repo correctly for cross-repo workflow_call invocations.
// Unlike the GITHUB_WORKFLOW_REF env var (which always reflects the top-level caller in workflow_call),
// ${{ github.workflow_ref }} correctly refers to the current reusable workflow being executed.
// Inject the GitHub Actions context workflow_ref expression as GH_AW_CONTEXT_WORKFLOW_REF
// for check_workflow_timestamp_api.cjs. Note: despite what was previously documented,
// ${{ github.workflow_ref }} resolves to the CALLER's workflow ref in reusable workflow
// contexts, not the callee's. The referenced_workflows API lookup in the script is the
// primary mechanism for resolving the callee's repo; GH_AW_CONTEXT_WORKFLOW_REF serves
// as a fallback when the API is unavailable or finds no matching entry.
steps = append(steps, " GH_AW_CONTEXT_WORKFLOW_REF: \"${{ github.workflow_ref }}\"\n")
steps = append(steps, " with:\n")
steps = append(steps, " script: |\n")
Expand Down
Loading