diff --git a/actions/setup/js/resolve_host_repo.cjs b/actions/setup/js/resolve_host_repo.cjs index 1811202323a..1fca3b11dfe 100644 --- a/actions/setup/js/resolve_host_repo.cjs +++ b/actions/setup/js/resolve_host_repo.cjs @@ -10,13 +10,19 @@ * so the expression introduced in #20301 incorrectly fell back to github.repository * (the caller's repo) instead of the platform repo. * - * GITHUB_WORKFLOW_REF always reflects the currently executing workflow file, not the - * triggering event. Its format is: + * GITHUB_WORKFLOW_REF reflects the currently executing workflow file for most triggers, but + * in cross-org workflow_call scenarios it resolves to the TOP-LEVEL CALLER's workflow ref, + * not the reusable (callee) workflow being executed. Its format is: * owner/repo/.github/workflows/file.yml@refs/heads/main * - * When the platform workflow runs cross-repo (called via uses:), GITHUB_WORKFLOW_REF - * starts with the platform repo slug, while GITHUB_REPOSITORY is the caller repo. - * Comparing the two lets us detect cross-repo invocations without relying on event_name. + * When the platform workflow runs cross-repo (called via uses: from the same org), + * GITHUB_WORKFLOW_REF starts with the platform repo slug, while GITHUB_REPOSITORY is the + * caller repo. Comparing the two lets us detect cross-repo invocations without relying on + * event_name. + * + * For cross-org workflow_call, GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY both resolve to + * the caller's repo. In that case we fall back to the referenced_workflows API lookup to + * find the actual callee (platform) repo and ref. * * In a caller-hosted relay pinned to a feature branch (e.g. uses: platform/.github/workflows/ * gateway.lock.yml@feature-branch), the @feature-branch portion is encoded in @@ -24,13 +30,101 @@ * the correct branch rather than the platform repo's default branch. * * SEC-005: The targetRepo and targetRef values are resolved solely from trusted system - * environment variables (GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, GITHUB_REF) set by the - * GitHub Actions runtime. They are not derived from user-supplied input, so no allowlist - * check is required in this handler. + * environment variables (GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, GITHUB_REF) and the + * GitHub Actions API (referenced_workflows), set/provided by the GitHub Actions runtime. + * They are not derived from user-supplied input, so no allowlist check is required here. * - * @safe-outputs-exempt SEC-005: values sourced from trusted runtime env vars only + * @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runtime env vars and referenced_workflows API only */ +// Matches the "owner/repo" prefix from a GitHub workflow path of the form "owner/repo/...". +const REPO_PREFIX_RE = /^([^/]+\/[^/]+)\//; + +/** + * Attempts to resolve the callee repository and ref from the referenced_workflows API. + * + * This is used as a fallback when GITHUB_WORKFLOW_REF points to the same repo as + * GITHUB_REPOSITORY (cross-org workflow_call scenario), because in that case + * GITHUB_WORKFLOW_REF reflects the caller's workflow ref, not the callee's. + * + * @param {string} currentRepo - The value of GITHUB_REPOSITORY (owner/repo) + * @returns {Promise<{repo: string, ref: string} | null>} Resolved callee repo and ref, or null + */ +async function resolveFromReferencedWorkflows(currentRepo) { + const rawRunId = process.env.GITHUB_RUN_ID; + const runId = rawRunId ? parseInt(rawRunId, 10) : typeof context.runId === "number" ? context.runId : NaN; + if (!Number.isFinite(runId)) { + core.info("Run ID is unavailable or invalid, cannot perform referenced_workflows lookup"); + return null; + } + + const [runOwner, runRepo] = currentRepo.split("/"); + try { + core.info(`Checking for cross-org callee via referenced_workflows API (run ${runId}, repo ${currentRepo})`); + 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`); + for (const wf of referencedWorkflows) { + core.info(` referenced workflow: path=${wf.path} sha=${wf.sha || "(none)"} ref=${wf.ref || "(none)"}`); + } + + // Collect all referenced workflows from a different repo than the caller. + // In cross-org workflow_call, the callee (platform) repo is different from currentRepo. + // If multiple cross-repo candidates are found we cannot safely pick one, so we bail out. + const crossRepoCandidates = []; + for (const wf of referencedWorkflows) { + const pathRepoMatch = wf.path.match(REPO_PREFIX_RE); + const entryRepo = pathRepoMatch ? pathRepoMatch[1] : ""; + if (entryRepo && entryRepo !== currentRepo) { + crossRepoCandidates.push({ wf, repo: entryRepo }); + } + } + core.info(`Found ${crossRepoCandidates.length} cross-repo candidate(s) (excluding current repo ${currentRepo})`); + + if (crossRepoCandidates.length === 0) { + core.info("No cross-org callee found in referenced_workflows, using current repo"); + return null; + } + + if (crossRepoCandidates.length > 1) { + core.info(`Referenced workflows lookup is ambiguous; found ${crossRepoCandidates.length} cross-repo candidates, not selecting one`); + for (const candidate of crossRepoCandidates) { + core.info(` Candidate referenced workflow path: ${candidate.wf.path}`); + } + return null; + } + + const matchingEntry = crossRepoCandidates[0].wf; + const calleeRepo = crossRepoCandidates[0].repo; + + // Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref. + const pathRefMatch = matchingEntry.path.match(/@(.+)$/); + let calleeRefSource; + if (matchingEntry.sha) { + calleeRefSource = "sha"; + } else if (matchingEntry.ref) { + calleeRefSource = "ref"; + } else if (pathRefMatch) { + calleeRefSource = "path"; + } else { + calleeRefSource = "none"; + } + const calleeRef = matchingEntry.sha || matchingEntry.ref || (pathRefMatch ? pathRefMatch[1] : ""); + core.info(`Resolved callee repo from referenced_workflows: ${calleeRepo} @ ${calleeRef || "(default branch)"} (source: ${calleeRefSource})`); + core.info(` Referenced workflow path: ${matchingEntry.path}`); + return { repo: calleeRepo, ref: calleeRef }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + core.info(`Could not fetch referenced_workflows from API: ${msg}, using current repo`); + return null; + } +} + /** * @returns {Promise} */ @@ -38,13 +132,18 @@ async function main() { const workflowRef = process.env.GITHUB_WORKFLOW_REF || ""; const currentRepo = process.env.GITHUB_REPOSITORY || ""; + core.info(`GITHUB_WORKFLOW_REF: ${workflowRef || "(not set)"}`); + core.info(`GITHUB_REPOSITORY: ${currentRepo || "(not set)"}`); + core.info(`GITHUB_RUN_ID: ${process.env.GITHUB_RUN_ID || "(not set)"}`); + // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref // The regex captures everything before the third slash segment (i.e., the owner/repo prefix). - const repoMatch = workflowRef.match(/^([^/]+\/[^/]+)\//); + const repoMatch = workflowRef.match(REPO_PREFIX_RE); const workflowRepo = repoMatch ? repoMatch[1] : ""; + core.info(`Parsed workflow repo from GITHUB_WORKFLOW_REF: ${workflowRepo || "(could not parse)"}`); // Fall back to currentRepo when GITHUB_WORKFLOW_REF cannot be parsed - const targetRepo = workflowRepo || currentRepo; + let targetRepo = workflowRepo || currentRepo; // Extract the ref portion after '@' from GITHUB_WORKFLOW_REF. // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref @@ -57,24 +156,49 @@ async function main() { // scenarios GITHUB_REF is the *caller* repo's ref, not the callee's, and using it // would check out the wrong branch. const refMatch = workflowRef.match(/@(.+)$/); - const targetRef = refMatch ? refMatch[1] : ""; + let targetRef = refMatch ? refMatch[1] : ""; + core.info(`Parsed workflow ref from GITHUB_WORKFLOW_REF: ${targetRef || "(none — will use default branch)"}`); + + // Cross-org workflow_call detection: when GITHUB_WORKFLOW_REF points to the same repo as + // GITHUB_REPOSITORY, it means GITHUB_WORKFLOW_REF is resolving to the caller's workflow + // (not the callee's). This happens in cross-org workflow_call invocations where GitHub + // Actions sets GITHUB_WORKFLOW_REF to the top-level caller's workflow ref rather than the + // reusable workflow being executed. In that case, fall back to the referenced_workflows API + // to find the actual callee (platform) repo and ref. + // + // Note: GITHUB_EVENT_NAME inside a reusable workflow reflects the ORIGINAL trigger event + // (e.g., "push", "issues"), NOT "workflow_call", so we cannot use event_name to detect + // this scenario. + if (workflowRepo && workflowRepo === currentRepo) { + core.info(`Cross-org workflow_call detected (workflowRepo === currentRepo = ${currentRepo}): falling back to referenced_workflows API`); + const resolved = await resolveFromReferencedWorkflows(currentRepo); + if (resolved) { + targetRepo = resolved.repo; + targetRef = resolved.ref || targetRef; + } else { + core.info("referenced_workflows lookup returned no result; keeping current repo as target"); + } + } else if (!workflowRepo) { + core.info("Could not parse workflowRepo from GITHUB_WORKFLOW_REF; falling back to GITHUB_REPOSITORY"); + } else { + core.info(`Same-org cross-repo invocation: workflowRepo=${workflowRepo}, currentRepo=${currentRepo}`); + } - core.info(`GITHUB_WORKFLOW_REF: ${workflowRef}`); - core.info(`GITHUB_REPOSITORY: ${currentRepo}`); core.info(`Resolved host repo for activation checkout: ${targetRepo}`); - core.info(`Resolved host ref for activation checkout: ${targetRef}`); + core.info(`Resolved host ref for activation checkout: ${targetRef || "(default branch)"}`); if (targetRepo !== currentRepo && targetRepo !== "") { core.info(`Cross-repo invocation detected: platform repo is "${targetRepo}", caller is "${currentRepo}"`); await core.summary.addRaw(`**Activation Checkout**: Checking out platform repo \`${targetRepo}\` @ \`${targetRef}\` (caller: \`${currentRepo}\`)`).write(); } else { - core.info(`Same-repo invocation: checking out ${targetRepo} @ ${targetRef}`); + core.info(`Same-repo invocation: checking out ${targetRepo} @ ${targetRef || "(default branch)"}`); } // Compute the repository name (without owner prefix) for use cases that require // only the repo name, such as actions/create-github-app-token which expects // `repositories` to contain repo names only when `owner` is also provided. const targetRepoName = targetRepo.split("/").at(-1); + core.info(`target_repo=${targetRepo} target_repo_name=${targetRepoName} target_ref=${targetRef || "(default branch)"}`); core.setOutput("target_repo", targetRepo); core.setOutput("target_repo_name", targetRepoName); diff --git a/actions/setup/js/resolve_host_repo.test.cjs b/actions/setup/js/resolve_host_repo.test.cjs index a4a0afb1b98..cdf3937d31a 100644 --- a/actions/setup/js/resolve_host_repo.test.cjs +++ b/actions/setup/js/resolve_host_repo.test.cjs @@ -14,14 +14,41 @@ const mockCore = { }, }; +const mockGetWorkflowRun = vi.fn(); +const mockGithub = { + rest: { + actions: { + getWorkflowRun: mockGetWorkflowRun, + }, + }, +}; + +const mockContext = { + runId: 99999, +}; + // Set up global mocks before importing the module global.core = mockCore; +global.github = mockGithub; +global.context = mockContext; + +/** + * Sets up a one-time mock response for getWorkflowRun with no referenced workflows. + * Used for same-repo and same-org cross-repo tests where the API should not change the result. + */ +function mockNoReferencedWorkflowsOnce() { + mockGetWorkflowRun.mockResolvedValueOnce({ data: { referenced_workflows: [] } }); +} describe("resolve_host_repo.cjs", () => { let main; beforeEach(async () => { vi.clearAllMocks(); + // Defensive reset of mock implementation as a safety measure. + // All tests use *Once variants, but mockReset() ensures no state leaks + // if a test adds a persistent mock or if a future test omits the Once variant. + mockGetWorkflowRun.mockReset(); mockCore.summary.addRaw.mockReturnThis(); mockCore.summary.write.mockResolvedValue(undefined); @@ -33,6 +60,9 @@ describe("resolve_host_repo.cjs", () => { delete process.env.GITHUB_WORKFLOW_REF; delete process.env.GITHUB_REPOSITORY; delete process.env.GITHUB_REF; + delete process.env.GITHUB_RUN_ID; + // Reset context.runId to the default value to prevent test state leakage + mockContext.runId = 99999; }); it("should output the platform repo when invoked cross-repo", async () => { @@ -58,6 +88,7 @@ describe("resolve_host_repo.cjs", () => { it("should output the current repo when same-repo invocation", async () => { process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; process.env.GITHUB_REPOSITORY = "my-org/platform-repo"; + mockNoReferencedWorkflowsOnce(); await main(); @@ -68,6 +99,7 @@ describe("resolve_host_repo.cjs", () => { it("should not write step summary for same-repo invocations", async () => { process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; process.env.GITHUB_REPOSITORY = "my-org/platform-repo"; + mockNoReferencedWorkflowsOnce(); await main(); @@ -211,6 +243,7 @@ describe("resolve_host_repo.cjs", () => { it("should output target_repo_name when same-repo invocation", async () => { process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; process.env.GITHUB_REPOSITORY = "my-org/platform-repo"; + mockNoReferencedWorkflowsOnce(); await main(); @@ -235,4 +268,192 @@ describe("resolve_host_repo.cjs", () => { expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("refs/heads/feature-branch")); expect(mockCore.summary.write).toHaveBeenCalled(); }); + + describe("cross-org workflow_call scenarios", () => { + it("should resolve callee repo via referenced_workflows API when GITHUB_WORKFLOW_REF matches GITHUB_REPOSITORY", async () => { + // Cross-org workflow_call: GITHUB_WORKFLOW_REF points to the caller's repo (not the callee), + // so workflowRepo === currentRepo. The referenced_workflows API returns the actual callee. + process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "caller-org/caller-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + mockGetWorkflowRun.mockResolvedValueOnce({ + data: { + referenced_workflows: [ + { + path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main", + sha: "abc123def456", + ref: "refs/heads/main", + }, + ], + }, + }); + + await main(); + + expect(mockGetWorkflowRun).toHaveBeenCalledWith({ + owner: "caller-org", + repo: "caller-repo", + run_id: 12345, + }); + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "platform-org/platform-repo"); + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo"); + // sha is preferred over ref + expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "abc123def456"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved callee repo from referenced_workflows")); + }); + + it("should use ref from referenced_workflows entry when sha is absent", async () => { + process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "caller-org/caller-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + mockGetWorkflowRun.mockResolvedValueOnce({ + data: { + referenced_workflows: [ + { + path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/feature", + sha: undefined, + ref: "refs/heads/feature", + }, + ], + }, + }); + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "platform-org/platform-repo"); + expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "refs/heads/feature"); + }); + + it("should fall back to path-parsed ref when sha and ref are absent in referenced_workflows", async () => { + process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "caller-org/caller-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + mockGetWorkflowRun.mockResolvedValueOnce({ + data: { + referenced_workflows: [ + { + path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/stable", + sha: undefined, + ref: undefined, + }, + ], + }, + }); + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "platform-org/platform-repo"); + expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "refs/heads/stable"); + }); + + it("should log cross-repo detection and write step summary for cross-org callee", async () => { + process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "caller-org/caller-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + mockGetWorkflowRun.mockResolvedValueOnce({ + data: { + referenced_workflows: [ + { + path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main", + sha: "abc123", + ref: "refs/heads/main", + }, + ], + }, + }); + + await main(); + + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected")); + expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("platform-org/platform-repo")); + expect(mockCore.summary.write).toHaveBeenCalled(); + }); + + it("should fall back to GITHUB_REPOSITORY when referenced_workflows has no cross-org entry", async () => { + // workflowRepo === currentRepo but no cross-org entry (same-org same-repo, no callee) + process.env.GITHUB_WORKFLOW_REF = "my-org/my-repo/.github/workflows/my-workflow.lock.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "my-org/my-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + mockGetWorkflowRun.mockResolvedValueOnce({ data: { referenced_workflows: [] } }); + + await main(); + + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/my-repo"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No cross-org callee found in referenced_workflows")); + }); + + it("should fall back to GITHUB_REPOSITORY when referenced_workflows has multiple cross-org entries (ambiguous)", async () => { + // Cannot safely select one callee when multiple cross-repo workflows are referenced. + process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "caller-org/caller-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + mockGetWorkflowRun.mockResolvedValueOnce({ + data: { + referenced_workflows: [ + { + path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main", + sha: "abc123", + ref: "refs/heads/main", + }, + { + path: "other-org/other-repo/.github/workflows/other.lock.yml@refs/heads/main", + sha: "def456", + ref: "refs/heads/main", + }, + ], + }, + }); + + await main(); + + // Falls back to currentRepo since the result is ambiguous + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "caller-org/caller-repo"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Referenced workflows lookup is ambiguous")); + }); + + it("should fall back gracefully when referenced_workflows API call fails", async () => { + process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "caller-org/caller-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + mockGetWorkflowRun.mockRejectedValueOnce(new Error("API unavailable")); + + await main(); + + // Should fall back to the currentRepo (caller) — not ideal but safe degradation + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "caller-org/caller-repo"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("API unavailable")); + }); + + it("should fall back gracefully when GITHUB_RUN_ID is missing", async () => { + process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "caller-org/caller-repo"; + delete process.env.GITHUB_RUN_ID; + mockContext.runId = NaN; + + await main(); + + expect(mockGetWorkflowRun).not.toHaveBeenCalled(); + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "caller-org/caller-repo"); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Run ID is unavailable or invalid")); + }); + + it("should not call referenced_workflows API for normal cross-repo (same-org) invocations", async () => { + // workflowRepo !== currentRepo → no API call needed + process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; + process.env.GITHUB_REPOSITORY = "my-org/app-repo"; + process.env.GITHUB_RUN_ID = "12345"; + + await main(); + + expect(mockGetWorkflowRun).not.toHaveBeenCalled(); + expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); + }); + }); });