-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Expand file tree
/
Copy pathaction.yml
More file actions
295 lines (272 loc) · 11.6 KB
/
Copy pathaction.yml
File metadata and controls
295 lines (272 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
name: 'Find reusable build from prior run'
description: >-
Searches recent workflow runs across three tiers (same branch, base branch,
then any open PR branch) for a run whose `build-source-hash` commit status
matches the current fingerprint for that specific workflow run AND whose
required build artifacts are still available. If a match is found, outputs the
run id so a subsequent `actions/download-artifact` step can pull the artifacts
directly instead of triggering a fresh native build.
The third (cross-PR) tier is required because GitHub's `listWorkflowRuns`
`branch` parameter filters against `head_branch` — the PR source branch for
`pull_request` events — so branch-scoped lookups can never discover other
PRs' runs. The cross-PR tier drops the branch filter and instead uses
`event: pull_request` to let the fingerprint itself act as the cross-PR
deduplication key.
inputs:
fingerprint:
description: 'The @expo/fingerprint hash the candidate must match'
required: true
artifact-names:
description: 'JSON array of artifact names that must all be present on the candidate run'
required: true
metadata-artifact-name:
description: >-
Optional build metadata artifact name that must be present on the
candidate run. The caller should download and validate this artifact
before reusing native build artifacts.
required: false
default: ''
github-token:
description: 'GitHub token with `actions: read` and `statuses: read` permissions'
required: true
workflow-file:
description: 'Workflow filename whose runs will be searched'
required: false
default: 'ci.yml'
base-branch:
description: 'Fallback branch when no same-branch match is found'
required: false
default: 'main'
status-context:
description: 'Commit status context that carries the fingerprint'
required: false
default: 'build-source-hash'
max-candidates-per-branch:
description: 'How many recent runs to inspect per branch-scoped tier (same-branch, base-branch)'
required: false
default: '10'
max-candidates-cross-pr:
description: >-
How many recent `pull_request`-event runs (across all branches) to inspect
in the cross-PR tier. The fingerprint filter is highly discriminating, so
the practical cost is one `listCommitStatusesForRef` call per candidate
until a run-specific status match is found.
required: false
default: '30'
main-branch-only:
description: >-
When true, only search the base branch (typically `main`) for reusable
builds. Used for PRs with test-only changes that should not compile fresh
native artifacts.
required: false
default: 'false'
outputs:
found:
description: "'true' when a reusable run was found"
value: ${{ steps.lookup.outputs.found }}
run-id:
description: 'Workflow run id that produced the reusable artifacts'
value: ${{ steps.lookup.outputs.run-id }}
source-sha:
description: 'Commit SHA of the reusable run'
value: ${{ steps.lookup.outputs.source-sha }}
source-branch:
description: 'Branch of the reusable run (same-branch or base-branch)'
value: ${{ steps.lookup.outputs.source-branch }}
runs:
using: 'composite'
steps:
- name: Search prior runs for matching fingerprint
id: lookup
uses: actions/github-script@v7
continue-on-error: true
env:
TARGET_FINGERPRINT: ${{ inputs.fingerprint }}
ARTIFACT_NAMES_JSON: ${{ inputs.artifact-names }}
METADATA_ARTIFACT_NAME: ${{ inputs.metadata-artifact-name }}
WORKFLOW_FILE: ${{ inputs.workflow-file }}
BASE_BRANCH: ${{ inputs.base-branch }}
STATUS_CONTEXT: ${{ inputs.status-context }}
MAX_CANDIDATES: ${{ inputs.max-candidates-per-branch }}
MAX_CANDIDATES_CROSS_PR: ${{ inputs.max-candidates-cross-pr }}
MAIN_BRANCH_ONLY: ${{ inputs.main-branch-only }}
HEAD_BRANCH: ${{ github.head_ref || github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
CURRENT_RUN_ID: ${{ github.run_id }}
with:
github-token: ${{ inputs.github-token }}
script: |
const {
TARGET_FINGERPRINT,
ARTIFACT_NAMES_JSON,
METADATA_ARTIFACT_NAME,
WORKFLOW_FILE,
BASE_BRANCH,
STATUS_CONTEXT,
MAX_CANDIDATES,
MAX_CANDIDATES_CROSS_PR,
MAIN_BRANCH_ONLY,
HEAD_BRANCH,
HEAD_SHA,
CURRENT_RUN_ID,
} = process.env;
const setNotFound = () => {
core.setOutput('found', 'false');
core.setOutput('run-id', '');
core.setOutput('source-sha', '');
core.setOutput('source-branch', '');
};
if (!TARGET_FINGERPRINT) {
core.warning('No fingerprint provided; skipping lookup');
setNotFound();
return;
}
let requiredArtifacts;
try {
requiredArtifacts = JSON.parse(ARTIFACT_NAMES_JSON);
} catch (err) {
core.warning(`Could not parse artifact-names input: ${err.message}`);
setNotFound();
return;
}
if (!Array.isArray(requiredArtifacts) || requiredArtifacts.length === 0) {
core.warning('artifact-names must be a non-empty JSON array');
setNotFound();
return;
}
const metadataArtifactName = METADATA_ARTIFACT_NAME.trim();
const allRequiredArtifacts = metadataArtifactName
? [...requiredArtifacts, metadataArtifactName]
: requiredArtifacts;
const maxCandidates = Number(MAX_CANDIDATES) || 10;
const maxCandidatesCrossPr = Number(MAX_CANDIDATES_CROSS_PR) || 30;
const currentRunId = String(CURRENT_RUN_ID);
// Three-tier discovery:
// 1. same-branch — fastest path, catches retries and new commits
// on the current PR.
// 2. base-branch — catches post-merge CI runs on `main`. Only
// matches `push`-event runs (pull_request runs
// have head_branch=<source branch>, not main).
// 3. cross-pr — searches recent `pull_request` runs across
// ALL source branches so two unrelated PRs with
// the same fingerprint can reuse each other's
// artifacts. This tier deliberately drops the
// `branch` filter; without it, branch-scoped
// lookups can never discover another PR's run
// (GitHub filters `branch` against head_branch,
// which is the PR source branch).
const tiers = [];
const mainBranchOnly = MAIN_BRANCH_ONLY === 'true';
if (!mainBranchOnly) {
tiers.push({
label: `same-branch (branch=${HEAD_BRANCH})`,
params: { branch: HEAD_BRANCH, per_page: maxCandidates },
});
}
if (BASE_BRANCH && (mainBranchOnly || BASE_BRANCH !== HEAD_BRANCH)) {
tiers.push({
label: `base-branch (branch=${BASE_BRANCH})`,
params: { branch: BASE_BRANCH, per_page: maxCandidates },
});
}
if (!mainBranchOnly) {
tiers.push({
label: `cross-pr (event=pull_request, any branch, last ${maxCandidatesCrossPr} runs)`,
params: { event: 'pull_request', per_page: maxCandidatesCrossPr },
// Skip runs already visited by the same-branch tier to avoid
// wasting API calls on duplicates.
skipHeadBranch: HEAD_BRANCH,
});
}
async function hasRunFingerprintStatus(sha, runId) {
try {
const runPathPattern = new RegExp(`/actions/runs/${runId}(?:$|[/?#])`);
const statuses = await github.paginate(
github.rest.repos.listCommitStatusesForRef,
{
owner: context.repo.owner,
repo: context.repo.repo,
ref: sha,
per_page: 100,
},
);
return statuses.some((status) => {
const targetUrl = String(status.target_url || '');
return (
status.context === STATUS_CONTEXT &&
status.description === TARGET_FINGERPRINT &&
runPathPattern.test(targetUrl)
);
});
} catch (err) {
core.info(`listCommitStatusesForRef failed for ${sha}: ${err.message}`);
return false;
}
}
async function hasAllArtifacts(runId) {
try {
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
per_page: 100,
},
);
const available = new Set(
artifacts
.filter((a) => !a.expired)
.map((a) => a.name),
);
const missing = allRequiredArtifacts.filter((n) => !available.has(n));
if (missing.length > 0) {
core.info(`Run ${runId} missing artifacts: ${missing.join(', ')}`);
return false;
}
return true;
} catch (err) {
core.info(`listWorkflowRunArtifacts failed for ${runId}: ${err.message}`);
return false;
}
}
const seenRunIds = new Set();
seenRunIds.add(currentRunId);
for (const tier of tiers) {
core.info(`Searching tier: ${tier.label}`);
let runs;
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: WORKFLOW_FILE,
...tier.params,
});
runs = data.workflow_runs || [];
} catch (err) {
core.warning(`listWorkflowRuns failed for tier "${tier.label}": ${err.message}`);
continue;
}
for (const run of runs) {
const runIdStr = String(run.id);
if (seenRunIds.has(runIdStr)) continue;
seenRunIds.add(runIdStr);
if (tier.skipHeadBranch && run.head_branch === tier.skipHeadBranch) continue;
if (run.status !== 'completed') continue;
if (!(await hasRunFingerprintStatus(run.head_sha, run.id))) {
core.info(`Run ${run.id} has no run-specific ${STATUS_CONTEXT} status matching fingerprint ${TARGET_FINGERPRINT}`);
continue;
}
if (!(await hasAllArtifacts(run.id))) continue;
core.info(
`Match: tier="${tier.label}" run=${run.id} sha=${run.head_sha} branch=${run.head_branch} url=${run.html_url}`,
);
core.setOutput('found', 'true');
core.setOutput('run-id', runIdStr);
core.setOutput('source-sha', run.head_sha);
core.setOutput('source-branch', run.head_branch || '');
return;
}
}
core.info('No reusable build found across any tier');
setNotFound();