Repository navigation
319 lines (298 loc) · 14.4 KB
/
Copy pathfork_ci_status.yml
File metadata and controls
319 lines (298 loc) · 14.4 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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# # Fork CI status
#
# A pull request from a fork to `main` runs `rust.yml` on the fork. This
# workflow mirrors that run as one `Rust (fork)` check on the pull request, so
# reviewers see the result without opening the fork. It never checks out or
# runs code from the pull request: on `pull_request_target` it reads the fork's
# public run list and creates the check, and on a schedule it updates the
# active or missing runs of recently updated pull requests plus a rotating
# slice of all open pull requests. The check is informational. The merge
# queue runs the full workflow on the merged result and stays the gate.
#
# See docs/source/contributor-guide/index.md, "Pull Requests from Forks".
name: Fork CI status
on:
pull_request_target:
types: [opened, reopened, synchronize]
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
inputs:
hours:
description: "Update every pull request updated within this many hours"
type: number
default: 6
permissions:
actions: read
checks: write
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || 'update' }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
jobs:
bridge:
name: Mirror the fork run
runs-on: ubuntu-slim
timeout-minutes: 10
steps:
- name: Create or update the check
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
UPDATE_HOURS: ${{ inputs.hours || 6 }}
with:
script: |
const CHECK = 'Rust (fork)';
const WORKFLOW = 'rust.yml';
const upstream = { owner: context.repo.owner, repo: context.repo.repo };
const GUIDE = 'https://datafusion.apache.org/contributor-guide/#pull-requests-from-forks';
// Older pull requests are revisited this many at a time, so a rerun
// or a late run on a quiet pull request is mirrored within hours.
const SWEEP_SIZE = 25;
// A check run accepts a fixed set of conclusions; a workflow run can
// report others, such as startup_failure.
const CONCLUSIONS = new Set([
'action_required', 'cancelled', 'failure', 'neutral',
'skipped', 'stale', 'success', 'timed_out',
]);
const conclusionOf = (run) =>
CONCLUSIONS.has(run.conclusion) ? run.conclusion : 'failure';
const statusOf = (run) => {
if (run.status === 'completed') return 'completed';
return run.status === 'in_progress' ? 'in_progress' : 'queued';
};
const isForkPrToMain = (pr) =>
Boolean(pr.head.repo) && pr.head.repo.fork === true && pr.base.ref === 'main';
const HOW_UPDATED =
'Active or missing runs on recently updated pull requests are checked every 15 minutes. ' +
'Completed runs and older pull requests are checked by a rotating sweep, which can take a few hours. ' +
'The merge queue runs the full workflow on the merged result before this pull request merges.';
// The check for a run. `text` is read back by the scheduled update.
const runPayload = (fork, sha, run) => {
const status = statusOf(run);
return {
status,
...(status === 'completed' ? { conclusion: conclusionOf(run) } : {}),
details_url: run.html_url,
output: {
title: `Fork run ${status === 'completed' ? run.conclusion : status}`,
summary: [
`[Rust workflow run on ${fork.owner}/${fork.repo}](${run.html_url}) for this commit.`,
'',
'If the fork run failed for reasons unrelated to this pull request, rerun it on the fork.',
HOW_UPDATED,
].join('\n'),
text: JSON.stringify({
owner: fork.owner,
repo: fork.repo,
head_sha: sha,
run_id: run.id,
run_attempt: run.run_attempt,
}),
},
};
};
// The check when the fork has no run for the commit. The workflow's
// state on the fork says whether workflows are enabled there; a
// push that changes only documentation or Markdown does not start
// rust.yml even when they are. The scheduled update keeps looking
// for a run in every case.
const missingPayload = (fork, sha, state) => {
const disabled = typeof state === 'string' && state.startsWith('disabled');
const active = state === 'active';
const advice = disabled
? [
'1. Enable workflows on your fork: open its **Actions** tab and confirm the prompt.',
'2. Push again. A push that changes only documentation or Markdown does not start',
' the Rust workflow.',
]
: active
? [
'A push that changes only documentation or Markdown does not start the Rust',
'workflow, and a run can take a moment to appear. This check keeps looking for one.',
]
: [
'GitHub reports no Rust workflow on the fork, which usually means workflows are not',
'enabled there. Enable them on the fork\'s **Actions** tab and push again. A push that',
'changes only documentation or Markdown does not start the Rust workflow.',
];
return {
status: 'completed',
conclusion: disabled ? 'action_required' : active ? 'skipped' : 'neutral',
output: {
title: disabled ? 'Workflows are disabled on the fork' : 'No fork run for this commit',
summary: [
`No Rust workflow run was found on ${fork.owner}/${fork.repo} for ${sha}.`,
'',
...advice,
'',
HOW_UPDATED,
'',
`See ${GUIDE}`,
].join('\n'),
text: JSON.stringify({ owner: fork.owner, repo: fork.repo, head_sha: sha, run_id: null }),
},
};
};
// The fork creates its run a few seconds after the push.
async function findRun(fork, sha, attempts) {
for (let attempt = 0; attempt < attempts; attempt++) {
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner: fork.owner,
repo: fork.repo,
workflow_id: WORKFLOW,
head_sha: sha,
per_page: 10,
});
// A newer manual run can replace a cancelled push run.
const run = data.workflow_runs[0];
if (run) return run;
} catch (error) {
// 404 when the fork has Actions disabled or lacks the workflow.
core.info(`No run list for ${fork.owner}/${fork.repo}: ${error.message}`);
}
if (attempt + 1 < attempts) {
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
return null;
}
// `active`, or `disabled_fork` until the owner enables workflows;
// null when the workflow cannot be read.
async function workflowState(fork) {
try {
const { data } = await github.rest.actions.getWorkflow({
owner: fork.owner,
repo: fork.repo,
workflow_id: WORKFLOW,
});
return data.state || null;
} catch (error) {
core.info(`No workflow state for ${fork.owner}/${fork.repo}: ${error.message}`);
return null;
}
}
async function createCheck(pr, attempts) {
const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
const sha = pr.head.sha;
const run = await findRun(fork, sha, attempts);
const payload = run
? runPayload(fork, sha, run)
: missingPayload(fork, sha, await workflowState(fork));
await github.rest.checks.create({ ...upstream, name: CHECK, head_sha: sha, ...payload });
core.info(`Created ${CHECK} for ${sha}: ${payload.conclusion || payload.status}`);
}
async function notify() {
const pr = context.payload.pull_request;
// Only fork PRs to main skip jobs upstream; release branches have
// no merge queue and keep the full run.
if (!isForkPrToMain(pr)) {
core.info('Not a fork pull request to main; nothing to mirror.');
return;
}
await createCheck(pr, 12);
}
// Discover the latest run again, since a manual run can replace the
// recorded run, and the fork can delete old runs.
async function syncCheck(pr, includeCompleted = true) {
const { data } = await github.rest.checks.listForRef({
...upstream,
ref: pr.head.sha,
check_name: CHECK,
per_page: 5,
});
const check = data.check_runs[0];
if (!check) {
// Recover if the initial notification failed before creating a check.
await createCheck(pr, 1);
return;
}
if (!check.output || !check.output.text) return;
let params;
try {
params = JSON.parse(check.output.text);
} catch {
return;
}
// Completed runs only need the rotating sweep to discover reruns.
// A completed check with no run must keep looking for a late run.
if (!includeCompleted && check.status === 'completed' && params.run_id) return;
const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
const sha = pr.head.sha;
const run = await findRun(fork, sha, 1);
if (!run) return;
const payload = runPayload(fork, sha, run);
if (
payload.status === check.status &&
(payload.conclusion || null) === check.conclusion &&
run.id === params.run_id &&
run.run_attempt === params.run_attempt
) {
return;
}
await github.rest.checks.update({ ...upstream, check_run_id: check.id, ...payload });
core.info(`#${pr.number}: ${check.status}/${check.conclusion} -> ${payload.status}/${payload.conclusion || ''}`);
}
const listing = { ...upstream, state: 'open', sort: 'updated', direction: 'desc' };
// The number of open pull requests, from the last-page link of a
// one-item page: one request, whatever the count.
async function openPullRequestCount() {
const response = await github.rest.pulls.list({ ...listing, per_page: 1 });
const last = /[?&]page=(\d+)>;\s*rel="last"/.exec(response.headers.link || '');
return last ? Number(last[1]) : response.data.length;
}
async function update() {
const hours = Number(process.env.UPDATE_HOURS) || 6;
const cutoff = Date.now() - hours * 3600 * 1000;
const seen = new Set();
// Select the sweep first so a recent completed PR in this slice
// is checked once, even though it also appears in the window.
const slices = Math.max(1, Math.ceil((await openPullRequestCount()) / SWEEP_SIZE));
const slot = Math.floor(Date.now() / (15 * 60 * 1000));
const page = (slot % slices) + 1;
const { data: slice } = await github.rest.pulls.list({ ...listing, per_page: SWEEP_SIZE, page });
const sweepNumbers = new Set(slice.map((pr) => pr.number));
// Pull requests updated within the window, newest first. Stop at
// the window: GITHUB_TOKEN allows 1,000 requests per hour per
// repository.
const pages = github.paginate.iterator(github.rest.pulls.list, { ...listing, per_page: 100 });
for await (const page of pages) {
let stale = false;
for (const pr of page.data) {
if (new Date(pr.updated_at).getTime() < cutoff) {
stale = true;
break;
}
seen.add(pr.number);
if (isForkPrToMain(pr)) await syncCheck(pr, sweepNumbers.has(pr.number));
}
if (stale) break;
}
// Check the rest of the slice, including older pull requests.
// A fork rerun does not update the upstream pull request.
for (const pr of slice) {
if (seen.has(pr.number) || !isForkPrToMain(pr)) continue;
await syncCheck(pr);
}
}
if (context.eventName === 'pull_request_target') {
await notify();
} else {
await update();
}