|
4 | 4 | /** |
5 | 5 | * Resolves the target repository and ref for the activation job checkout. |
6 | 6 | * |
7 | | - * Uses GITHUB_WORKFLOW_REF to determine the platform (host) repository and branch/ref |
8 | | - * regardless of the triggering event. This fixes cross-repo activation for event-driven |
9 | | - * relays (e.g. on: issue_comment, on: push) where github.event_name is NOT 'workflow_call', |
10 | | - * so the expression introduced in #20301 incorrectly fell back to github.repository |
11 | | - * (the caller's repo) instead of the platform repo. |
| 7 | + * Uses the job.workflow_* context fields to determine the platform (host) |
| 8 | + * repository and pin the checkout to the exact executing commit SHA. |
12 | 9 | * |
13 | | - * GITHUB_WORKFLOW_REF reflects the currently executing workflow file for most triggers, but |
14 | | - * in cross-org workflow_call scenarios it resolves to the TOP-LEVEL CALLER's workflow ref, |
15 | | - * not the reusable (callee) workflow being executed. Its format is: |
16 | | - * owner/repo/.github/workflows/file.yml@refs/heads/main |
| 10 | + * These fields are passed via environment variables (JOB_WORKFLOW_REPOSITORY, |
| 11 | + * JOB_WORKFLOW_SHA, etc.) to avoid shell injection — the ${{ }} expressions |
| 12 | + * are evaluated in the env: block, not interpolated into script source. |
17 | 13 | * |
18 | | - * When the platform workflow runs cross-repo (called via uses: from the same org), |
19 | | - * GITHUB_WORKFLOW_REF starts with the platform repo slug, while GITHUB_REPOSITORY is the |
20 | | - * caller repo. Comparing the two lets us detect cross-repo invocations without relying on |
21 | | - * event_name. |
| 14 | + * job.workflow_repository provides the owner/repo of the currently executing |
| 15 | + * workflow file, correctly identifying the platform repo in all relay patterns: |
| 16 | + * cross-repo workflow_call, event-driven relays (on: issue_comment, on: push), |
| 17 | + * and cross-org scenarios. |
22 | 18 | * |
23 | | - * For cross-org workflow_call, GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY both resolve to |
24 | | - * the caller's repo. In that case we fall back to the referenced_workflows API lookup to |
25 | | - * find the actual callee (platform) repo and ref. |
| 19 | + * job.workflow_sha provides the immutable commit SHA of the workflow being |
| 20 | + * executed, ensuring the activation checkout pins to the exact revision rather |
| 21 | + * than a moving branch/tag ref. |
26 | 22 | * |
27 | | - * In a caller-hosted relay pinned to a feature branch (e.g. uses: platform/.github/workflows/ |
28 | | - * gateway.lock.yml@feature-branch), the @feature-branch portion is encoded in |
29 | | - * GITHUB_WORKFLOW_REF. Emitting it as target_ref allows the activation checkout to use |
30 | | - * the correct branch rather than the platform repo's default branch. |
31 | | - * |
32 | | - * SEC-005: The targetRepo and targetRef values are resolved solely from trusted system |
33 | | - * environment variables (GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, GITHUB_REF) and the |
34 | | - * GitHub Actions API (referenced_workflows), set/provided by the GitHub Actions runtime. |
35 | | - * They are not derived from user-supplied input, so no allowlist check is required here. |
36 | | - * |
37 | | - * @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runtime env vars and referenced_workflows API only |
| 23 | + * @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runner context via env vars only |
38 | 24 | */ |
39 | 25 |
|
40 | | -// Matches the "owner/repo" prefix from a GitHub workflow path of the form "owner/repo/...". |
41 | | -const REPO_PREFIX_RE = /^([^/]+\/[^/]+)\//; |
42 | | - |
43 | | -/** |
44 | | - * Attempts to resolve the callee repository and ref from the referenced_workflows API. |
45 | | - * |
46 | | - * This is used as a fallback when GITHUB_WORKFLOW_REF points to the same repo as |
47 | | - * GITHUB_REPOSITORY (cross-org workflow_call scenario), because in that case |
48 | | - * GITHUB_WORKFLOW_REF reflects the caller's workflow ref, not the callee's. |
49 | | - * |
50 | | - * @param {string} currentRepo - The value of GITHUB_REPOSITORY (owner/repo) |
51 | | - * @returns {Promise<{repo: string, ref: string} | null>} Resolved callee repo and ref, or null |
52 | | - */ |
53 | | -async function resolveFromReferencedWorkflows(currentRepo) { |
54 | | - const rawRunId = process.env.GITHUB_RUN_ID; |
55 | | - const runId = rawRunId ? parseInt(rawRunId, 10) : typeof context.runId === "number" ? context.runId : NaN; |
56 | | - if (!Number.isFinite(runId)) { |
57 | | - core.info("Run ID is unavailable or invalid, cannot perform referenced_workflows lookup"); |
58 | | - return null; |
59 | | - } |
60 | | - |
61 | | - const [runOwner, runRepo] = currentRepo.split("/"); |
62 | | - try { |
63 | | - core.info(`Checking for cross-org callee via referenced_workflows API (run ${runId}, repo ${currentRepo})`); |
64 | | - const runResponse = await github.rest.actions.getWorkflowRun({ |
65 | | - owner: runOwner, |
66 | | - repo: runRepo, |
67 | | - run_id: runId, |
68 | | - }); |
69 | | - |
70 | | - const referencedWorkflows = runResponse.data.referenced_workflows || []; |
71 | | - core.info(`Found ${referencedWorkflows.length} referenced workflow(s) in run`); |
72 | | - for (const wf of referencedWorkflows) { |
73 | | - core.info(` referenced workflow: path=${wf.path} sha=${wf.sha || "(none)"} ref=${wf.ref || "(none)"}`); |
74 | | - } |
75 | | - |
76 | | - // Collect all referenced workflows from a different repo than the caller. |
77 | | - // In cross-org workflow_call, the callee (platform) repo is different from currentRepo. |
78 | | - // If multiple cross-repo candidates are found we cannot safely pick one, so we bail out. |
79 | | - const crossRepoCandidates = []; |
80 | | - for (const wf of referencedWorkflows) { |
81 | | - const pathRepoMatch = wf.path.match(REPO_PREFIX_RE); |
82 | | - const entryRepo = pathRepoMatch ? pathRepoMatch[1] : ""; |
83 | | - if (entryRepo && entryRepo !== currentRepo) { |
84 | | - crossRepoCandidates.push({ wf, repo: entryRepo }); |
85 | | - } |
86 | | - } |
87 | | - core.info(`Found ${crossRepoCandidates.length} cross-repo candidate(s) (excluding current repo ${currentRepo})`); |
88 | | - |
89 | | - if (crossRepoCandidates.length === 0) { |
90 | | - core.info("No cross-org callee found in referenced_workflows, using current repo"); |
91 | | - return null; |
92 | | - } |
93 | | - |
94 | | - if (crossRepoCandidates.length > 1) { |
95 | | - core.info(`Referenced workflows lookup is ambiguous; found ${crossRepoCandidates.length} cross-repo candidates, not selecting one`); |
96 | | - for (const candidate of crossRepoCandidates) { |
97 | | - core.info(` Candidate referenced workflow path: ${candidate.wf.path}`); |
98 | | - } |
99 | | - return null; |
100 | | - } |
101 | | - |
102 | | - const matchingEntry = crossRepoCandidates[0].wf; |
103 | | - const calleeRepo = crossRepoCandidates[0].repo; |
104 | | - |
105 | | - // Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref. |
106 | | - const pathRefMatch = matchingEntry.path.match(/@(.+)$/); |
107 | | - let calleeRefSource; |
108 | | - if (matchingEntry.sha) { |
109 | | - calleeRefSource = "sha"; |
110 | | - } else if (matchingEntry.ref) { |
111 | | - calleeRefSource = "ref"; |
112 | | - } else if (pathRefMatch) { |
113 | | - calleeRefSource = "path"; |
114 | | - } else { |
115 | | - calleeRefSource = "none"; |
116 | | - } |
117 | | - const calleeRef = matchingEntry.sha || matchingEntry.ref || (pathRefMatch ? pathRefMatch[1] : ""); |
118 | | - core.info(`Resolved callee repo from referenced_workflows: ${calleeRepo} @ ${calleeRef || "(default branch)"} (source: ${calleeRefSource})`); |
119 | | - core.info(` Referenced workflow path: ${matchingEntry.path}`); |
120 | | - return { repo: calleeRepo, ref: calleeRef }; |
121 | | - } catch (error) { |
122 | | - const msg = error instanceof Error ? error.message : String(error); |
123 | | - core.info(`Could not fetch referenced_workflows from API: ${msg}, using current repo`); |
124 | | - return null; |
125 | | - } |
126 | | -} |
127 | | - |
128 | 26 | /** |
129 | 27 | * @returns {Promise<void>} |
130 | 28 | */ |
131 | 29 | async function main() { |
132 | | - const workflowRef = process.env.GITHUB_WORKFLOW_REF || ""; |
| 30 | + const targetRepo = process.env.JOB_WORKFLOW_REPOSITORY || ""; |
| 31 | + const targetRef = process.env.JOB_WORKFLOW_SHA || ""; |
| 32 | + const targetRepoName = targetRepo.split("/").pop() || ""; |
133 | 33 | const currentRepo = process.env.GITHUB_REPOSITORY || ""; |
134 | 34 |
|
135 | | - core.info(`GITHUB_WORKFLOW_REF: ${workflowRef || "(not set)"}`); |
136 | | - core.info(`GITHUB_REPOSITORY: ${currentRepo || "(not set)"}`); |
137 | | - core.info(`GITHUB_RUN_ID: ${process.env.GITHUB_RUN_ID || "(not set)"}`); |
138 | | - |
139 | | - // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref |
140 | | - // The regex captures everything before the third slash segment (i.e., the owner/repo prefix). |
141 | | - const repoMatch = workflowRef.match(REPO_PREFIX_RE); |
142 | | - const workflowRepo = repoMatch ? repoMatch[1] : ""; |
143 | | - core.info(`Parsed workflow repo from GITHUB_WORKFLOW_REF: ${workflowRepo || "(could not parse)"}`); |
144 | | - |
145 | | - // Fall back to currentRepo when GITHUB_WORKFLOW_REF cannot be parsed |
146 | | - let targetRepo = workflowRepo || currentRepo; |
147 | | - |
148 | | - // Extract the ref portion after '@' from GITHUB_WORKFLOW_REF. |
149 | | - // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref |
150 | | - // The ref may be a full ref like "refs/heads/feature-branch", a short name like "main", |
151 | | - // a tag like "refs/tags/v1.0.0", or a commit SHA like "abc123def". |
152 | | - // |
153 | | - // When GITHUB_WORKFLOW_REF has no '@' segment (e.g., env var not set or malformed), |
154 | | - // fall back to an empty string so that actions/checkout uses the repository's default |
155 | | - // branch. We intentionally do NOT fall back to GITHUB_REF here because in cross-repo |
156 | | - // scenarios GITHUB_REF is the *caller* repo's ref, not the callee's, and using it |
157 | | - // would check out the wrong branch. |
158 | | - const refMatch = workflowRef.match(/@(.+)$/); |
159 | | - let targetRef = refMatch ? refMatch[1] : ""; |
160 | | - core.info(`Parsed workflow ref from GITHUB_WORKFLOW_REF: ${targetRef || "(none — will use default branch)"}`); |
161 | | - |
162 | | - // Cross-org workflow_call detection: when GITHUB_WORKFLOW_REF points to the same repo as |
163 | | - // GITHUB_REPOSITORY, it means GITHUB_WORKFLOW_REF is resolving to the caller's workflow |
164 | | - // (not the callee's). This happens in cross-org workflow_call invocations where GitHub |
165 | | - // Actions sets GITHUB_WORKFLOW_REF to the top-level caller's workflow ref rather than the |
166 | | - // reusable workflow being executed. In that case, fall back to the referenced_workflows API |
167 | | - // to find the actual callee (platform) repo and ref. |
168 | | - // |
169 | | - // Note: GITHUB_EVENT_NAME inside a reusable workflow reflects the ORIGINAL trigger event |
170 | | - // (e.g., "push", "issues"), NOT "workflow_call", so we cannot use event_name to detect |
171 | | - // this scenario. |
172 | | - if (workflowRepo && workflowRepo === currentRepo) { |
173 | | - core.info(`Cross-org workflow_call detected (workflowRepo === currentRepo = ${currentRepo}): falling back to referenced_workflows API`); |
174 | | - const resolved = await resolveFromReferencedWorkflows(currentRepo); |
175 | | - if (resolved) { |
176 | | - targetRepo = resolved.repo; |
177 | | - targetRef = resolved.ref || targetRef; |
178 | | - } else { |
179 | | - core.info("referenced_workflows lookup returned no result; keeping current repo as target"); |
180 | | - } |
181 | | - } else if (!workflowRepo) { |
182 | | - core.info("Could not parse workflowRepo from GITHUB_WORKFLOW_REF; falling back to GITHUB_REPOSITORY"); |
183 | | - } else { |
184 | | - core.info(`Same-org cross-repo invocation: workflowRepo=${workflowRepo}, currentRepo=${currentRepo}`); |
185 | | - } |
186 | | - |
187 | | - core.info(`Resolved host repo for activation checkout: ${targetRepo}`); |
188 | | - core.info(`Resolved host ref for activation checkout: ${targetRef || "(default branch)"}`); |
189 | | - |
190 | | - if (targetRepo !== currentRepo && targetRepo !== "") { |
191 | | - core.info(`Cross-repo invocation detected: platform repo is "${targetRepo}", caller is "${currentRepo}"`); |
192 | | - await core.summary.addRaw(`**Activation Checkout**: Checking out platform repo \`${targetRepo}\` @ \`${targetRef}\` (caller: \`${currentRepo}\`)`).write(); |
| 35 | + core.info("Resolving host repo via job.workflow_* context"); |
| 36 | + core.info(`job.workflow_repository = ${targetRepo}`); |
| 37 | + core.info(`job.workflow_sha = ${targetRef}`); |
| 38 | + core.info(`job.workflow_ref = ${process.env.JOB_WORKFLOW_REF || ""}`); |
| 39 | + core.info(`job.workflow_file_path = ${process.env.JOB_WORKFLOW_FILE_PATH || ""}`); |
| 40 | + core.info(`github.repository = ${currentRepo}`); |
| 41 | + core.info(""); |
| 42 | + core.info(`Resolved target_repo = ${targetRepo}`); |
| 43 | + core.info(`Resolved target_repo_name = ${targetRepoName}`); |
| 44 | + core.info(`Resolved target_ref = ${targetRef}`); |
| 45 | + |
| 46 | + if (targetRepo && targetRepo !== currentRepo) { |
| 47 | + core.info( |
| 48 | + `Cross-repo invocation detected: platform repo "${targetRepo}" differs from caller "${currentRepo}"` |
| 49 | + ); |
193 | 50 | } else { |
194 | | - core.info(`Same-repo invocation: checking out ${targetRepo} @ ${targetRef || "(default branch)"}`); |
| 51 | + core.info( |
| 52 | + `Same-repo invocation: platform and caller are both "${targetRepo}"` |
| 53 | + ); |
195 | 54 | } |
196 | 55 |
|
197 | | - // Compute the repository name (without owner prefix) for use cases that require |
198 | | - // only the repo name, such as actions/create-github-app-token which expects |
199 | | - // `repositories` to contain repo names only when `owner` is also provided. |
200 | | - const targetRepoName = targetRepo.split("/").at(-1); |
201 | | - core.info(`target_repo=${targetRepo} target_repo_name=${targetRepoName} target_ref=${targetRef || "(default branch)"}`); |
202 | | - |
203 | 56 | core.setOutput("target_repo", targetRepo); |
204 | 57 | core.setOutput("target_repo_name", targetRepoName); |
205 | 58 | core.setOutput("target_ref", targetRef); |
|
0 commit comments