diff --git a/actions/setup/js/check_workflow_timestamp_api.cjs b/actions/setup/js/check_workflow_timestamp_api.cjs index 9d45ef0c299..16cff685a64 100644 --- a/actions/setup/js/check_workflow_timestamp_api.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.cjs @@ -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. // - // 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}`; @@ -82,14 +83,18 @@ 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. + // Attempt referenced_workflows API lookup to detect cross-repo callee repo/ref. + // + // 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. + // + // 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 is unavailable or finds no 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. @@ -98,60 +103,66 @@ 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. + // Short-circuit: if the env workflow ref already ends with the current workflow file, + // the env vars already correctly identify the source (same-repo or non-reusable run). + // Skip the API call to avoid unnecessary rate-limit usage and permission noise. + // + // 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); + const envRefWithoutAt = workflowEnvRef.replace(/@.*$/, ""); + const envRefMatchesWorkflow = envRefWithoutAt.endsWith(`/.github/workflows/${workflowFile}`); + + if (envRefMatchesWorkflow) { + core.info("Env workflow ref already identifies this workflow, skipping referenced_workflows API lookup"); + } else 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; core.info(`GITHUB_WORKFLOW_REF: ${process.env.GITHUB_WORKFLOW_REF || "(not set)"}`); if (contextWorkflowRef) { - core.info(`GH_AW_CONTEXT_WORKFLOW_REF: ${contextWorkflowRef} (used for source repo resolution)`); + core.info(`GH_AW_CONTEXT_WORKFLOW_REF: ${contextWorkflowRef} (available as env fallback)`); } core.info(`GITHUB_REPOSITORY: ${currentRepo}`); core.info(`Resolved source repo: ${owner}/${repo} @ ${ref || "(default branch)"}`); diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index c693b0fe6a1..515e29ef235 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -794,25 +794,28 @@ engine: copilot }); }); - describe("cross-repo invocation via workflow_call (GH_AW_CONTEXT_WORKFLOW_REF fix)", () => { + describe("manual GH_AW_CONTEXT_WORKFLOW_REF fallback override", () => { // 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). + // 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 bypassed + // by the short-circuit (the env ref already ends with the current workflow file, meaning + // GH_AW_CONTEXT_WORKFLOW_REF was manually set to the callee's ref as a targeted override). 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. + // 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"; - // GH_AW_CONTEXT_WORKFLOW_REF is injected by the compiler from ${{ github.workflow_ref }} - // which correctly identifies the reusable workflow being executed. + // Manually inject GH_AW_CONTEXT_WORKFLOW_REF to exercise the fallback/override path. + // This value intentionally points to the callee repo (platform-repo) so the env ref + // ends with "/.github/workflows/test.lock.yml", triggering the short-circuit and + // bypassing the API lookup. process.env.GH_AW_CONTEXT_WORKFLOW_REF = "platform-owner/platform-repo/.github/workflows/test.lock.yml@refs/heads/main"; }); - it("should use GH_AW_CONTEXT_WORKFLOW_REF to identify source repo, not GITHUB_WORKFLOW_REF", async () => { + it("should use GH_AW_CONTEXT_WORKFLOW_REF override to identify source repo when env ref matches workflow file", async () => { const validHash = "c2a79263dc72f28c76177afda9bf0935481b26da094407a50155a6e0244084e3"; const lockFileContent = `# frontmatter-hash: ${validHash}\nname: Test\n`; const mdFileContent = "---\nengine: copilot\n---\n# Test"; @@ -827,7 +830,7 @@ engine: copilot await main(); - // Must use the platform repo (from GH_AW_CONTEXT_WORKFLOW_REF), not the caller repo + // Must use the platform repo (from GH_AW_CONTEXT_WORKFLOW_REF override), not the caller repo expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ owner: "platform-owner", repo: "platform-repo" })); expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ owner: "caller-owner", repo: "caller-repo" })); expect(mockCore.setFailed).not.toHaveBeenCalled(); @@ -840,7 +843,7 @@ engine: copilot await main(); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GH_AW_CONTEXT_WORKFLOW_REF: platform-owner/platform-repo/.github/workflows/test.lock.yml@refs/heads/main")); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKFLOW_REF: caller-owner/caller-repo/.github/workflows/caller.yml@refs/heads/main")); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("available as env fallback")); }); it("should detect cross-repo invocation using GH_AW_CONTEXT_WORKFLOW_REF source vs GITHUB_REPOSITORY", async () => { @@ -919,13 +922,30 @@ engine: copilot expect(mockGithub.rest.repos.getContent).toHaveBeenCalledWith(expect.objectContaining({ ref: "refs/heads/main" })); expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalledWith(expect.objectContaining({ ref: "abc123" })); }); + + it("should skip referenced_workflows API when env ref already matches the workflow file, even with a valid GITHUB_RUN_ID", async () => { + // Short-circuit: if the env ref ends with the current workflowFile, the API call is + // skipped to avoid unnecessary rate-limit usage in normal (non-reusable) runs. + process.env.GITHUB_RUN_ID = "99999"; + mockGithub.rest.repos.getContent.mockResolvedValue({ data: null }); + + await main(); + + // API must NOT be called — env ref already identifies this workflow + expect(mockGithub.rest.actions.getWorkflowRun).not.toHaveBeenCalled(); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("skipping referenced_workflows API lookup")); + }); }); 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 @@ -937,7 +957,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"; }); @@ -994,7 +1014,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")); }); @@ -1044,13 +1064,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 () => { @@ -1112,7 +1148,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" })); }); diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index de39f59714a..c460163900d 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -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")