Repository navigation
229 lines (218 loc) · 11.7 KB
/
Copy pathfork_ci_status.yml
File metadata and controls
229 lines (218 loc) · 11.7 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
# 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.
# Mirror fork results as the informational CI (fork) check after upstream PR CI
# completes and through scheduled polling for late runs and reruns. Read fork
# runs and write upstream checks using reviewed default-branch code.
# No checkout, artifacts, or code from the pull request are used here.
# Test with Python 3.11+, PyYAML, and Node.js 24:
# node --test .github/ci/scripts/test-fork-ci-status.cjs
# Tests mock GitHub APIs. End-to-end testing requires a test repository with
# the upstream guards adapted and this workflow installed on its default branch.
name: Fork CI status
on:
workflow_run:
workflows: [CI]
types: [completed]
schedule:
- cron: '7,22,37,52 * * * *'
workflow_dispatch:
permissions:
actions: read
checks: write
pull-requests: read
# Like DataFusion, group notifications separately from scheduled/manual sweeps.
# workflow_run identifies the fork and branch rather than the PR number.
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name || 'update' }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: ${{ github.event_name == 'workflow_run' }}
jobs:
bridge:
if: github.repository == 'apache/arrow-rs' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request')
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
with:
script: |
// Adapted from apache/datafusion#25591. Fork results are informational:
// contributors control their workflows; only upstream merge-queue CI gates merging.
const CHECK = 'CI (fork)';
const WORKFLOW = 'ci.yml';
const upstream = { owner: context.repo.owner, repo: context.repo.repo };
const repository = upstream.owner + '/' + upstream.repo;
const GUIDE = 'https://github.com/apache/arrow-rs/blob/main/CONTRIBUTING.md#pull-requests-from-forks';
const SWEEP_SIZE = 25;
const listing = { ...upstream, state: 'open', base: 'main', sort: 'updated', direction: 'desc' };
const isForkPr = (pr) => pr.state === 'open' && pr.head.repo &&
pr.head.repo.full_name.toLowerCase() !== repository.toLowerCase() && pr.base.ref === 'main';
const externalId = (pr) => 'arrow-rs-fork-ci:' + pr.number;
const conclusions = new Set([
'action_required', 'cancelled', 'failure', 'neutral', 'skipped', 'success', 'timed_out',
]);
const UPDATE_NOTE =
'This check is informational: the contributor controls the fork workflow. ' +
'The upstream merge queue runs the required CI checks on the proposed merge result. ' +
'Active or missing runs on recently updated PRs are polled every 15 minutes; ' +
'a rotating sweep also discovers reruns on older PRs. Scheduled updates may be delayed.';
// An absent workflow/run is expected on a fork with Actions disabled. Other
// API errors must fail the bridge rather than falsely reporting missing CI.
async function optional(request) {
try {
return (await request()).data;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
async function syncCheck(pr, includeCompleted = true) {
if (!isForkPr(pr)) return;
const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
const sha = pr.head.sha;
const checks = await github.paginate(github.rest.checks.listForRef, {
...upstream, ref: sha, check_name: CHECK, per_page: 100,
});
const check = checks.find((item) =>
item.app?.slug === 'github-actions' && item.external_id === externalId(pr));
let previous = {};
try { previous = JSON.parse(check?.output?.text || '{}'); } catch {}
// A missing run must keep being polled, even though its check is completed.
if (!includeCompleted && check?.status === 'completed' && previous.run_id) return;
const data = await optional(() => github.rest.actions.listWorkflowRuns({
...fork, workflow_id: WORKFLOW, head_sha: sha, branch: pr.head.ref, per_page: 100,
}));
// Ignore PR merge-ref runs. The named CI workflow on push/manual
// events tests the branch head; contributors still control its code.
const run = (data?.workflow_runs || []).filter((item) =>
item.head_sha === sha && item.head_branch === pr.head.ref &&
['push', 'workflow_dispatch'].includes(item.event)
).sort((a, b) => b.id - a.id)[0];
let payload;
if (run) {
const status = run.status === 'completed' ? 'completed' :
run.status === 'in_progress' ? 'in_progress' : 'queued';
payload = {
status,
...(status === 'completed' ? {
conclusion: conclusions.has(run.conclusion) ? run.conclusion : 'failure',
} : {}),
details_url: run.html_url,
output: {
title: 'Fork CI: ' + (status === 'completed' ? run.conclusion : status),
summary: [
'[CI run on ' + pr.head.repo.full_name + '](' + run.html_url + ') for ' + sha + '.',
'Rerun failed checks in the fork. A green fork run tests the branch head, not its merge with main.',
UPDATE_NOTE,
].join('\n\n'),
text: JSON.stringify({ head_sha: sha, run_id: run.id, run_attempt: run.run_attempt }),
},
};
} else {
const workflow = await optional(() => github.rest.actions.getWorkflow({
...fork, workflow_id: WORKFLOW,
}));
const disabled = !workflow || workflow.state.startsWith('disabled');
payload = {
status: 'completed',
conclusion: disabled ? 'action_required' : 'neutral',
details_url: 'https://github.com/' + pr.head.repo.full_name + '/actions',
output: {
title: disabled ? 'Enable CI on your fork' : 'No fork CI run for this commit',
summary: [
'No CI run was found for ' + sha + ' on ' + pr.head.repo.full_name + '.',
'Enable workflows in the fork’s Actions tab, sync/rebase to include the fork CI workflow, ' +
'then push your branch or manually run CI on that branch. A newly pushed run may take a moment to appear.',
UPDATE_NOTE,
'[Fork CI setup](' + GUIDE + ')',
].join('\n\n'),
text: JSON.stringify({ head_sha: sha, run_id: null }),
},
};
}
if (check && check.status === payload.status &&
check.conclusion === (payload.conclusion || null) &&
check.output?.text === payload.output.text &&
check.output?.summary === payload.output.summary) return;
if (check) {
await github.rest.checks.update({ ...upstream, check_run_id: check.id, ...payload });
} else {
await github.rest.checks.create({
...upstream, name: CHECK, head_sha: sha, external_id: externalId(pr), ...payload,
});
}
core.info('#' + pr.number + ': ' + (payload.conclusion || payload.status));
}
async function notify() {
const run = context.payload.workflow_run;
if (run.event !== 'pull_request' || !run.head_repository ||
run.head_repository.full_name.toLowerCase() === repository.toLowerCase()) return;
// GitHub can leave workflow_run.pull_requests empty for fork PRs. Resolve
// from the API instead, and recheck the current head to ignore stale runs.
const prs = await github.paginate(github.rest.pulls.list, {
...listing, head: run.head_repository.owner.login + ':' + run.head_branch, per_page: 100,
});
for (const pr of prs) {
if (pr.head.sha === run.head_sha &&
pr.head.repo?.full_name.toLowerCase() === run.head_repository.full_name.toLowerCase()) {
await syncCheck(pr);
}
}
}
async function update() {
const errors = [];
// A failed update must not prevent unrelated PRs from being refreshed.
async function syncPr(pr, includeCompleted) {
try {
await syncCheck(pr, includeCompleted);
} catch (error) {
errors.push('#' + pr.number + ': ' + error.message);
}
}
const count = await github.rest.pulls.list({ ...listing, per_page: 1 });
const last = /[?&]page=(\d+)>;\s*rel="last"/.exec(count.headers.link || '');
const total = last ? Number(last[1]) : count.data.length;
if (!total) return;
const slices = Math.ceil(total / SWEEP_SIZE);
const slot = Math.floor(Date.now() / (15 * 60 * 1000));
const { data: slice } = await github.rest.pulls.list({
...listing, per_page: SWEEP_SIZE, page: (slot % slices) + 1,
});
const sweepNumbers = new Set(slice.map((pr) => pr.number));
const seen = new Set();
const cutoff = Date.now() - 6 * 3600 * 1000;
// Poll active and missing checks on recently updated PRs, and completed
// checks only in the rotating slice. This bounds expensive fork API calls.
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);
await syncPr(pr, sweepNumbers.has(pr.number));
}
if (stale) break;
}
for (const pr of slice) {
if (!seen.has(pr.number)) await syncPr(pr);
}
if (errors.length) core.setFailed(errors.join('\n'));
}
if (repository === 'apache/arrow-rs') {
if (context.eventName === 'workflow_run') await notify();
else await update();
}