From 4afd3c1b612b67215674e726805b38dd26a312ed Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Fri, 10 Apr 2026 21:53:18 +0100 Subject: [PATCH 1/2] feat: replace resolve_host_repo.cjs with job.workflow_* context Replace the 208-line JavaScript host repo resolver with a simplified version that reads job.workflow_repository and job.workflow_sha from environment variables instead of parsing GITHUB_WORKFLOW_REF and falling back to the referenced_workflows API. Key improvements: - job.workflow_repository directly provides the platform repo, eliminating GITHUB_WORKFLOW_REF parsing and the referenced_workflows API fallback for cross-org scenarios - job.workflow_sha provides an immutable commit SHA for checkout pinning, instead of a potentially moving branch/tag ref - Context values passed via env: block to avoid shell injection - Extensive logging of all job.workflow_* fields for observability - Deletes the old resolve_host_repo.cjs and its 459-line test file --- .../smoke-workflow-call-with-inputs.lock.yml | 5 + .../workflows/smoke-workflow-call.lock.yml | 5 + actions/setup/js/resolve_host_repo.cjs | 215 ++------ actions/setup/js/resolve_host_repo.test.cjs | 459 ------------------ pkg/workflow/compiler_activation_job.go | 41 +- pkg/workflow/compiler_activation_job_test.go | 21 +- .../basic-copilot.golden | 6 +- .../with-imports.golden | 6 +- 8 files changed, 79 insertions(+), 679 deletions(-) delete mode 100644 actions/setup/js/resolve_host_repo.test.cjs diff --git a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml index 3ed5e948980..2e7a4aa7d21 100644 --- a/.github/workflows/smoke-workflow-call-with-inputs.lock.yml +++ b/.github/workflows/smoke-workflow-call-with-inputs.lock.yml @@ -110,6 +110,11 @@ jobs: - name: Resolve host repo for activation checkout id: resolve-host-repo uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + JOB_WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + JOB_WORKFLOW_REF: ${{ job.workflow_ref }} + JOB_WORKFLOW_FILE_PATH: ${{ job.workflow_file_path }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); diff --git a/.github/workflows/smoke-workflow-call.lock.yml b/.github/workflows/smoke-workflow-call.lock.yml index b5f9a2d4a9e..184cfb01e29 100644 --- a/.github/workflows/smoke-workflow-call.lock.yml +++ b/.github/workflows/smoke-workflow-call.lock.yml @@ -113,6 +113,11 @@ jobs: - name: Resolve host repo for activation checkout id: resolve-host-repo uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + JOB_WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + JOB_WORKFLOW_REF: ${{ job.workflow_ref }} + JOB_WORKFLOW_FILE_PATH: ${{ job.workflow_file_path }} with: script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); diff --git a/actions/setup/js/resolve_host_repo.cjs b/actions/setup/js/resolve_host_repo.cjs index 1fca3b11dfe..d664b024c71 100644 --- a/actions/setup/js/resolve_host_repo.cjs +++ b/actions/setup/js/resolve_host_repo.cjs @@ -4,202 +4,55 @@ /** * Resolves the target repository and ref for the activation job checkout. * - * Uses GITHUB_WORKFLOW_REF to determine the platform (host) repository and branch/ref - * regardless of the triggering event. This fixes cross-repo activation for event-driven - * relays (e.g. on: issue_comment, on: push) where github.event_name is NOT 'workflow_call', - * so the expression introduced in #20301 incorrectly fell back to github.repository - * (the caller's repo) instead of the platform repo. + * Uses the job.workflow_* context fields to determine the platform (host) + * repository and pin the checkout to the exact executing commit SHA. * - * 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 + * These fields are passed via environment variables (JOB_WORKFLOW_REPOSITORY, + * JOB_WORKFLOW_SHA, etc.) to avoid shell injection — the ${{ }} expressions + * are evaluated in the env: block, not interpolated into script source. * - * 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. + * job.workflow_repository provides the owner/repo of the currently executing + * workflow file, correctly identifying the platform repo in all relay patterns: + * cross-repo workflow_call, event-driven relays (on: issue_comment, on: push), + * and cross-org scenarios. * - * 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. + * job.workflow_sha provides the immutable commit SHA of the workflow being + * executed, ensuring the activation checkout pins to the exact revision rather + * than a moving branch/tag 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 - * GITHUB_WORKFLOW_REF. Emitting it as target_ref allows the activation checkout to use - * 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) 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 GitHub Actions runtime env vars and referenced_workflows API only + * @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runner context via env vars 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} */ async function main() { - const workflowRef = process.env.GITHUB_WORKFLOW_REF || ""; + const targetRepo = process.env.JOB_WORKFLOW_REPOSITORY || ""; + const targetRef = process.env.JOB_WORKFLOW_SHA || ""; + const targetRepoName = targetRepo.split("/").pop() || ""; 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(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 - let targetRepo = workflowRepo || currentRepo; - - // Extract the ref portion after '@' from GITHUB_WORKFLOW_REF. - // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref - // The ref may be a full ref like "refs/heads/feature-branch", a short name like "main", - // a tag like "refs/tags/v1.0.0", or a commit SHA like "abc123def". - // - // When GITHUB_WORKFLOW_REF has no '@' segment (e.g., env var not set or malformed), - // fall back to an empty string so that actions/checkout uses the repository's default - // branch. We intentionally do NOT fall back to GITHUB_REF here because in cross-repo - // 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(/@(.+)$/); - 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(`Resolved host repo for activation checkout: ${targetRepo}`); - 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(); + core.info("Resolving host repo via job.workflow_* context"); + core.info(`job.workflow_repository = ${targetRepo}`); + core.info(`job.workflow_sha = ${targetRef}`); + core.info(`job.workflow_ref = ${process.env.JOB_WORKFLOW_REF || ""}`); + core.info(`job.workflow_file_path = ${process.env.JOB_WORKFLOW_FILE_PATH || ""}`); + core.info(`github.repository = ${currentRepo}`); + core.info(""); + core.info(`Resolved target_repo = ${targetRepo}`); + core.info(`Resolved target_repo_name = ${targetRepoName}`); + core.info(`Resolved target_ref = ${targetRef}`); + + if (targetRepo && targetRepo !== currentRepo) { + core.info( + `Cross-repo invocation detected: platform repo "${targetRepo}" differs from caller "${currentRepo}"` + ); } else { - core.info(`Same-repo invocation: checking out ${targetRepo} @ ${targetRef || "(default branch)"}`); + core.info( + `Same-repo invocation: platform and caller are both "${targetRepo}"` + ); } - // 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); core.setOutput("target_ref", targetRef); diff --git a/actions/setup/js/resolve_host_repo.test.cjs b/actions/setup/js/resolve_host_repo.test.cjs deleted file mode 100644 index cdf3937d31a..00000000000 --- a/actions/setup/js/resolve_host_repo.test.cjs +++ /dev/null @@ -1,459 +0,0 @@ -// @ts-check -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; - -// Mock the global objects that GitHub Actions provides -const mockCore = { - info: vi.fn(), - warning: vi.fn(), - error: vi.fn(), - setFailed: vi.fn(), - setOutput: vi.fn(), - summary: { - addRaw: vi.fn().mockReturnThis(), - write: vi.fn().mockResolvedValue(undefined), - }, -}; - -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); - - const module = await import("./resolve_host_repo.cjs"); - main = module.main; - }); - - afterEach(() => { - 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 () => { - 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"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); - }); - - it("should log a cross-repo detection message and write step summary", async () => { - 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"; - - await main(); - - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected")); - expect(mockCore.summary.addRaw).toHaveBeenCalled(); - expect(mockCore.summary.write).toHaveBeenCalled(); - }); - - 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(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Same-repo invocation")); - }); - - 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(); - - expect(mockCore.summary.write).not.toHaveBeenCalled(); - }); - - it("should fall back to GITHUB_REPOSITORY when GITHUB_WORKFLOW_REF is empty", async () => { - process.env.GITHUB_WORKFLOW_REF = ""; - process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/fallback-repo"); - }); - - it("should fall back to GITHUB_REPOSITORY when GITHUB_WORKFLOW_REF has unexpected format", async () => { - process.env.GITHUB_WORKFLOW_REF = "not-a-valid-ref"; - process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/fallback-repo"); - }); - - it("should handle event-driven relay (issue_comment) that calls a cross-repo workflow", async () => { - // This is the exact scenario from the bug report: - // An issue_comment event in app-repo triggers a relay that calls the platform workflow. - // GITHUB_WORKFLOW_REF reflects the platform workflow, GITHUB_REPOSITORY is the caller. - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/my-workflow.lock.yml@main"; - process.env.GITHUB_REPOSITORY = "my-org/app-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected")); - }); - - it("should fall back to empty string when GITHUB_REPOSITORY is also undefined", async () => { - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main"; - delete process.env.GITHUB_REPOSITORY; - - await main(); - - // workflowRepo parsed from GITHUB_WORKFLOW_REF is "my-org/platform-repo" - // currentRepo is "" since env var is deleted - // targetRepo = workflowRepo || currentRepo = "my-org/platform-repo" - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); - }); - - it("should log GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY", async () => { - 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"; - - await main(); - - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKFLOW_REF:")); - expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_REPOSITORY:")); - }); - - it("should output target_ref extracted from GITHUB_WORKFLOW_REF", async () => { - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/feature-branch"; - process.env.GITHUB_REPOSITORY = "my-org/app-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "refs/heads/feature-branch"); - }); - - it("should output target_ref for a short branch ref (not refs/heads/...)", async () => { - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@main"; - process.env.GITHUB_REPOSITORY = "my-org/app-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "main"); - }); - - it("should output target_ref for a feature branch in a caller-hosted relay", async () => { - // This is the exact scenario from the bug report: - // relay is pinned to @feature-branch, activation should check out feature-branch - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/platform-gateway.lock.yml@refs/heads/my-feature"; - process.env.GITHUB_REPOSITORY = "my-org/app-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo"); - expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "refs/heads/my-feature"); - }); - - it("should output empty target_ref when GITHUB_WORKFLOW_REF has no @ segment (no GITHUB_REF fallback)", async () => { - // When GITHUB_WORKFLOW_REF has no '@', we cannot determine the callee ref. - // We intentionally do NOT fall back to GITHUB_REF because in cross-repo scenarios - // GITHUB_REF is the caller's ref, not the callee's. Empty string tells actions/checkout - // to use the repository's default branch. - process.env.GITHUB_WORKFLOW_REF = "not-a-valid-ref"; - process.env.GITHUB_REF = "refs/heads/fallback-branch"; - process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", ""); - }); - - it("should output empty target_ref when GITHUB_WORKFLOW_REF is empty", async () => { - process.env.GITHUB_WORKFLOW_REF = ""; - delete process.env.GITHUB_REF; - process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", ""); - }); - - it("should output target_ref for a tag ref", async () => { - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/tags/v1.0.0"; - process.env.GITHUB_REPOSITORY = "my-org/app-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "refs/tags/v1.0.0"); - }); - - it("should output target_ref for a commit SHA", async () => { - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@abc123def456"; - process.env.GITHUB_REPOSITORY = "my-org/app-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "abc123def456"); - }); - - it("should output target_repo_name when invoked cross-repo", async () => { - 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"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo"); - }); - - 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(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo"); - }); - - it("should output target_repo_name without owner prefix when falling back to GITHUB_REPOSITORY", async () => { - process.env.GITHUB_WORKFLOW_REF = ""; - process.env.GITHUB_REPOSITORY = "my-org/fallback-repo"; - - await main(); - - expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "fallback-repo"); - }); - - it("should include target_ref in step summary for cross-repo invocations", async () => { - process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/feature-branch"; - process.env.GITHUB_REPOSITORY = "my-org/app-repo"; - - await main(); - - 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"); - }); - }); -}); diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 4814e6b4fa3..7977374ed13 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -50,13 +50,10 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate steps = append(steps, generateOTLPHeadersMaskStep()) } - // When a workflow_call trigger is present, resolve the platform (host) repository before - // generating aw_info so that target_repo can be included in aw_info.json and used by - // the checkout step. This is necessary for event-driven relays (e.g. on: issue_comment) - // where github.event_name is not 'workflow_call', making the previous expression - // (github.event_name == 'workflow_call' && github.action_repository || github.repository) - // unreliable. GITHUB_WORKFLOW_REF always reflects the executing workflow's repo regardless - // of how it was triggered. + // When a workflow_call trigger is present, resolve the platform (host) repository using + // the job.workflow_* context fields. job.workflow_repository identifies the + // platform repo and job.workflow_sha pins the checkout to the exact executing revision, + // correctly handling all relay patterns including cross-repo and cross-org scenarios. if hasWorkflowCallTrigger(data.On) && !data.InlinedImports { compilerActivationJobLog.Print("Adding resolve-host-repo step for workflow_call trigger") steps = append(steps, c.generateResolveHostRepoStep()) @@ -615,26 +612,24 @@ func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *Workflow } // generateResolveHostRepoStep generates a step that resolves the platform (host) repository -// for the activation job checkout by inspecting GITHUB_WORKFLOW_REF at runtime. +// for the activation job checkout using the job.workflow_* context fields. // -// This step replaces the previous compile-time expression +// job.workflow_repository provides the owner/repo of the currently executing workflow file, +// correctly identifying the platform repo in all relay patterns (cross-repo workflow_call, +// event-driven relays like on: issue_comment, on: push, and cross-org scenarios). // -// github.event_name == 'workflow_call' && github.action_repository || github.repository -// -// which only worked when the outermost trigger was workflow_call. For event-driven relays -// (e.g. on: issue_comment, on: push) the event_name is the native event, so the old -// expression always fell back to github.repository (the caller's repo), causing the -// activation job to check out the wrong repository. -// -// GITHUB_WORKFLOW_REF always contains the path of the currently executing workflow file -// (owner/repo/.github/workflows/file.yml@ref), regardless of the triggering event. -// Comparing its owner/repo prefix with GITHUB_REPOSITORY reliably detects cross-repo -// invocations for all relay patterns. +// job.workflow_sha provides the immutable commit SHA of the workflow being executed, ensuring +// the activation checkout pins to the exact revision rather than a moving branch/tag ref. func (c *Compiler) generateResolveHostRepoStep() string { var step strings.Builder step.WriteString(" - name: Resolve host repo for activation checkout\n") step.WriteString(" id: resolve-host-repo\n") step.WriteString(fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script"))) + step.WriteString(" env:\n") + step.WriteString(" JOB_WORKFLOW_REPOSITORY: ${{ job.workflow_repository }}\n") + step.WriteString(" JOB_WORKFLOW_SHA: ${{ job.workflow_sha }}\n") + step.WriteString(" JOB_WORKFLOW_REF: ${{ job.workflow_ref }}\n") + step.WriteString(" JOB_WORKFLOW_FILE_PATH: ${{ job.workflow_file_path }}\n") step.WriteString(" with:\n") step.WriteString(" script: |\n") step.WriteString(generateGitHubScriptWithRequire("resolve_host_repo.cjs")) @@ -665,9 +660,9 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) // The agent job uses only the user-specified permissions (no automatic contents:read augmentation). // For workflow_call triggers, checkout the callee (platform) repository using the target_repo - // output from the resolve-host-repo step. That step parses GITHUB_WORKFLOW_REF at runtime to - // determine the platform repo, correctly handling event-driven relays where event_name is not - // 'workflow_call' (e.g. on: issue_comment, on: push). + // and target_ref outputs from the resolve-host-repo step. That step uses job.workflow_repository + // and job.workflow_sha to identify the platform repo and pin to the exact commit, + // correctly handling all relay patterns including cross-repo and cross-org scenarios. // // Skip when inlined-imports is enabled: content is embedded at compile time and no // runtime-import macros are used, so the callee's .md files are not needed at runtime. diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go index 0c42822d1c5..22608614daa 100644 --- a/pkg/workflow/compiler_activation_job_test.go +++ b/pkg/workflow/compiler_activation_job_test.go @@ -12,13 +12,14 @@ import ( // workflowCallRepo is the expression injected into the repository: field of the // activation-job checkout step when a workflow_call trigger is detected. -// The resolve-host-repo step (which runs before checkout) parses GITHUB_WORKFLOW_REF -// at runtime to determine the platform repo, correctly handling both pure workflow_call -// relays and event-driven relays (e.g. on: issue_comment) where event_name != 'workflow_call'. +// The resolve-host-repo step uses job.workflow_repository to identify +// the platform repo, correctly handling all relay patterns including cross-repo +// and cross-org scenarios. const workflowCallRepo = "${{ steps.resolve-host-repo.outputs.target_repo }}" // workflowCallRef is the expression injected into the ref: field of the activation-job // checkout step when a workflow_call trigger is detected without inlined imports. +// Uses job.workflow_sha for immutable pinning to the exact executing revision. const workflowCallRef = "${{ steps.resolve-host-repo.outputs.target_ref }}" func TestGenerateCheckoutGitHubFolderForActivation_WorkflowCall(t *testing.T) { @@ -225,8 +226,8 @@ func TestGenerateGitHubFolderCheckoutStep(t *testing.T) { } } -// TestGenerateResolveHostRepoStep verifies that the resolve-host-repo step is correctly -// generated and does not contain the broken event_name-based expression. +// TestGenerateResolveHostRepoStep verifies that the resolve-host-repo step uses +// job.workflow_* context fields to resolve the platform repository. func TestGenerateResolveHostRepoStep(t *testing.T) { c := NewCompilerWithVersion("dev") c.SetActionMode(ActionModeDev) @@ -242,11 +243,11 @@ func TestGenerateResolveHostRepoStep(t *testing.T) { assert.Contains(t, result, "resolve_host_repo.cjs", "step should require resolve_host_repo.cjs") - // Verify the broken event_name expression is NOT present - assert.NotContains(t, result, "github.event_name == 'workflow_call'", - "step must not use the broken event_name-based expression") - assert.NotContains(t, result, "github.action_repository", - "step must not use github.action_repository (unreliable for event-driven relays)") + // Values must be passed via env vars, not interpolated into script source + assert.Contains(t, result, "JOB_WORKFLOW_REPOSITORY: ${{ job.workflow_repository }}", + "step should pass job.workflow_repository via env var") + assert.Contains(t, result, "JOB_WORKFLOW_SHA: ${{ job.workflow_sha }}", + "step should pass job.workflow_sha via env var") } // TestCheckoutDoesNotUseEventNameExpression verifies that the checkout step for diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden index ae43e83267b..a17ba7cb006 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/basic-copilot.golden @@ -52,8 +52,8 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.20" - GH_AW_INFO_AGENT_VERSION: "1.0.20" + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" GH_AW_INFO_WORKFLOW_NAME: "basic-copilot-test" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -291,7 +291,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.20 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: GH_HOST: github.com - name: Install AWF binary diff --git a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden index 4c1a2137724..fe471056be9 100644 --- a/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden +++ b/pkg/workflow/testdata/TestWasmGolden_CompileFixtures/with-imports.golden @@ -52,8 +52,8 @@ jobs: GH_AW_INFO_ENGINE_ID: "copilot" GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} - GH_AW_INFO_VERSION: "1.0.20" - GH_AW_INFO_AGENT_VERSION: "1.0.20" + GH_AW_INFO_VERSION: "1.0.21" + GH_AW_INFO_AGENT_VERSION: "1.0.21" GH_AW_INFO_WORKFLOW_NAME: "with-imports-test" GH_AW_INFO_EXPERIMENTAL: "false" GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" @@ -292,7 +292,7 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); - name: Install GitHub Copilot CLI - run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.20 + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.21 env: GH_HOST: github.com - name: Install AWF binary From e60010d2db20ef5c9fa8dd2819a2f59ff68a6ec6 Mon Sep 17 00:00:00 2001 From: Salman Muin Kayser Chishti Date: Fri, 10 Apr 2026 21:56:54 +0100 Subject: [PATCH 2/2] style: format resolve_host_repo.cjs with prettier --- actions/setup/js/resolve_host_repo.cjs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/resolve_host_repo.cjs b/actions/setup/js/resolve_host_repo.cjs index d664b024c71..d1c13651a34 100644 --- a/actions/setup/js/resolve_host_repo.cjs +++ b/actions/setup/js/resolve_host_repo.cjs @@ -44,13 +44,9 @@ async function main() { core.info(`Resolved target_ref = ${targetRef}`); if (targetRepo && targetRepo !== currentRepo) { - core.info( - `Cross-repo invocation detected: platform repo "${targetRepo}" differs from caller "${currentRepo}"` - ); + core.info(`Cross-repo invocation detected: platform repo "${targetRepo}" differs from caller "${currentRepo}"`); } else { - core.info( - `Same-repo invocation: platform and caller are both "${targetRepo}"` - ); + core.info(`Same-repo invocation: platform and caller are both "${targetRepo}"`); } core.setOutput("target_repo", targetRepo);