Skip to content

Commit 00c8ecb

Browse files
kumarUjjawalkosiew
andauthored
ci: run the Rust workflow for fork pull requests on the fork (#25591)
## Which issue does this PR close? Part of #25148. ## Rationale for this change Fork pull requests run the full Rust workflow upstream. Many contributors also run that workflow on their forks, which adds demand on shared ASF runners. This PR uses fork runs for feedback on pull requests to `main`. The fork run tests the branch head. The merge queue still runs all 27 Rust jobs on the merged result before merge. ## What changes are included in this PR? - Upstream skips 24 Rust jobs for fork pull requests to `main`. `check-fmt`, `check-workflow-tool-installs`, and `examples-check-datafusion-crates` still run. - A `Rust (fork)` check shows the fork run for the pull request's head commit. Scheduled updates pick up late runs and reruns. - Pull requests from upstream branches and fork pull requests to release branches keep the full Rust workflow. - CI and local lints validate the skip conditions, job dependencies, and artifact consumers. The bridge uses `pull_request_target` and the GitHub API. It never checks out or runs pull request code. The fork check is informational because contributors control their fork's workflows. It is not a required check for merge. ## What is the testing strategy for this PR? Local checks passed for the job conditions, dependencies, and artifact guards. Bridge probes with stubbed API responses covered missing runs, late runs, reruns, and pagination beyond 500 open pull requests. Hosted end-to-end validation of the new bridge remains outstanding. `pull_request_target` uses the base branch's workflow, so this pull request cannot exercise the new bridge on GitHub. ## Are there any user-facing changes? Contributors must enable GitHub Actions on their forks. The contributor guide and PR template explain this requirement. Pushes that change only documentation or Markdown do not start the Rust workflow. --------- Co-authored-by: kosiew <kosiew@gmail.com>
1 parent 79650ec commit 00c8ecb

11 files changed

Lines changed: 1155 additions & 187 deletions

File tree

‎.github/pull_request_template.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
> [!IMPORTANT]
2+
>
3+
> **Fork CI setup:** For pull requests from a fork to `main`, enable workflows in your fork's **Actions** tab before you push.
4+
> See the [fork CI setup guide](https://datafusion.apache.org/contributor-guide/#pull-requests-from-forks).
5+
16
## Which issue does this PR close?
27

38
<!--
@@ -10,7 +15,7 @@ We generally require a GitHub issue to be filed for all bug fixes and enhancemen
1015

1116
<!--
1217
Why are you proposing this change? If this is already explained clearly in the issue then this section is not needed.
13-
Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes.
18+
Explaining clearly why changes are proposed helps reviewers understand your changes and offer better suggestions for fixes.
1419
1520
Please explain the problem you are trying to solve in terms of the user-visible
1621
behavior, rather than the implementation.

‎.github/workflows/dev.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ jobs:
8383
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
8484
- run: pip install pyyaml
8585
- run: python3 ci/scripts/check_asf_yaml_status_checks.py
86+
- run: python3 ci/scripts/check_rust_workflow_skips.py
87+
- run: python3 ci/scripts/test_rust_workflow_skips.py
88+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
89+
with:
90+
node-version: "24"
91+
- run: node --test ci/scripts/test_fork_ci_status.cjs
8692

8793
typos:
8894
name: Spell Check with Typos
Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
# # Fork CI status
19+
#
20+
# A pull request from a fork to `main` runs `rust.yml` on the fork. This
21+
# workflow mirrors that run as one `Rust (fork)` check on the pull request, so
22+
# reviewers see the result without opening the fork. It never checks out or
23+
# runs code from the pull request: on `pull_request_target` it reads the fork's
24+
# public run list and creates the check, and on a schedule it updates the
25+
# active or missing runs of recently updated pull requests plus a rotating
26+
# slice of all open pull requests. The check is informational. The merge
27+
# queue runs the full workflow on the merged result and stays the gate.
28+
#
29+
# See docs/source/contributor-guide/index.md, "Pull Requests from Forks".
30+
name: Fork CI status
31+
32+
on:
33+
pull_request_target:
34+
types: [opened, reopened, synchronize]
35+
schedule:
36+
- cron: "*/15 * * * *"
37+
workflow_dispatch:
38+
inputs:
39+
hours:
40+
description: "Update every pull request updated within this many hours"
41+
type: number
42+
default: 6
43+
44+
permissions:
45+
actions: read
46+
checks: write
47+
pull-requests: read
48+
49+
concurrency:
50+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || 'update' }}
51+
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
52+
53+
jobs:
54+
bridge:
55+
name: Mirror the fork run
56+
runs-on: ubuntu-slim
57+
timeout-minutes: 10
58+
steps:
59+
- name: Create or update the check
60+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
61+
env:
62+
UPDATE_HOURS: ${{ inputs.hours || 6 }}
63+
with:
64+
script: |
65+
const CHECK = 'Rust (fork)';
66+
const WORKFLOW = 'rust.yml';
67+
const upstream = { owner: context.repo.owner, repo: context.repo.repo };
68+
const GUIDE = 'https://datafusion.apache.org/contributor-guide/#pull-requests-from-forks';
69+
// Older pull requests are revisited this many at a time, so a rerun
70+
// or a late run on a quiet pull request is mirrored within hours.
71+
const SWEEP_SIZE = 25;
72+
73+
// A check run accepts a fixed set of conclusions; a workflow run can
74+
// report others, such as startup_failure.
75+
const CONCLUSIONS = new Set([
76+
'action_required', 'cancelled', 'failure', 'neutral',
77+
'skipped', 'stale', 'success', 'timed_out',
78+
]);
79+
const conclusionOf = (run) =>
80+
CONCLUSIONS.has(run.conclusion) ? run.conclusion : 'failure';
81+
const statusOf = (run) => {
82+
if (run.status === 'completed') return 'completed';
83+
return run.status === 'in_progress' ? 'in_progress' : 'queued';
84+
};
85+
const isForkPrToMain = (pr) =>
86+
Boolean(pr.head.repo) && pr.head.repo.fork === true && pr.base.ref === 'main';
87+
88+
const HOW_UPDATED =
89+
'Active or missing runs on recently updated pull requests are checked every 15 minutes. ' +
90+
'Completed runs and older pull requests are checked by a rotating sweep, which can take a few hours. ' +
91+
'The merge queue runs the full workflow on the merged result before this pull request merges.';
92+
93+
// The check for a run. `text` is read back by the scheduled update.
94+
const runPayload = (fork, sha, run) => {
95+
const status = statusOf(run);
96+
return {
97+
status,
98+
...(status === 'completed' ? { conclusion: conclusionOf(run) } : {}),
99+
details_url: run.html_url,
100+
output: {
101+
title: `Fork run ${status === 'completed' ? run.conclusion : status}`,
102+
summary: [
103+
`[Rust workflow run on ${fork.owner}/${fork.repo}](${run.html_url}) for this commit.`,
104+
'',
105+
'If the fork run failed for reasons unrelated to this pull request, rerun it on the fork.',
106+
HOW_UPDATED,
107+
].join('\n'),
108+
text: JSON.stringify({
109+
owner: fork.owner,
110+
repo: fork.repo,
111+
head_sha: sha,
112+
run_id: run.id,
113+
run_attempt: run.run_attempt,
114+
}),
115+
},
116+
};
117+
};
118+
119+
// The check when the fork has no run for the commit. The workflow's
120+
// state on the fork says whether workflows are enabled there; a
121+
// push that changes only documentation or Markdown does not start
122+
// rust.yml even when they are. The scheduled update keeps looking
123+
// for a run in every case.
124+
const missingPayload = (fork, sha, state) => {
125+
const disabled = typeof state === 'string' && state.startsWith('disabled');
126+
const active = state === 'active';
127+
const advice = disabled
128+
? [
129+
'1. Enable workflows on your fork: open its **Actions** tab and confirm the prompt.',
130+
'2. Push again. A push that changes only documentation or Markdown does not start',
131+
' the Rust workflow.',
132+
]
133+
: active
134+
? [
135+
'A push that changes only documentation or Markdown does not start the Rust',
136+
'workflow, and a run can take a moment to appear. This check keeps looking for one.',
137+
]
138+
: [
139+
'GitHub reports no Rust workflow on the fork, which usually means workflows are not',
140+
'enabled there. Enable them on the fork\'s **Actions** tab and push again. A push that',
141+
'changes only documentation or Markdown does not start the Rust workflow.',
142+
];
143+
return {
144+
status: 'completed',
145+
conclusion: disabled ? 'action_required' : active ? 'skipped' : 'neutral',
146+
output: {
147+
title: disabled ? 'Workflows are disabled on the fork' : 'No fork run for this commit',
148+
summary: [
149+
`No Rust workflow run was found on ${fork.owner}/${fork.repo} for ${sha}.`,
150+
'',
151+
...advice,
152+
'',
153+
HOW_UPDATED,
154+
'',
155+
`See ${GUIDE}`,
156+
].join('\n'),
157+
text: JSON.stringify({ owner: fork.owner, repo: fork.repo, head_sha: sha, run_id: null }),
158+
},
159+
};
160+
};
161+
162+
// The fork creates its run a few seconds after the push.
163+
async function findRun(fork, sha, attempts) {
164+
for (let attempt = 0; attempt < attempts; attempt++) {
165+
try {
166+
const { data } = await github.rest.actions.listWorkflowRuns({
167+
owner: fork.owner,
168+
repo: fork.repo,
169+
workflow_id: WORKFLOW,
170+
head_sha: sha,
171+
per_page: 10,
172+
});
173+
// A newer manual run can replace a cancelled push run.
174+
const run = data.workflow_runs[0];
175+
if (run) return run;
176+
} catch (error) {
177+
// 404 when the fork has Actions disabled or lacks the workflow.
178+
core.info(`No run list for ${fork.owner}/${fork.repo}: ${error.message}`);
179+
}
180+
if (attempt + 1 < attempts) {
181+
await new Promise((resolve) => setTimeout(resolve, 5000));
182+
}
183+
}
184+
return null;
185+
}
186+
187+
// `active`, or `disabled_fork` until the owner enables workflows;
188+
// null when the workflow cannot be read.
189+
async function workflowState(fork) {
190+
try {
191+
const { data } = await github.rest.actions.getWorkflow({
192+
owner: fork.owner,
193+
repo: fork.repo,
194+
workflow_id: WORKFLOW,
195+
});
196+
return data.state || null;
197+
} catch (error) {
198+
core.info(`No workflow state for ${fork.owner}/${fork.repo}: ${error.message}`);
199+
return null;
200+
}
201+
}
202+
203+
async function createCheck(pr, attempts) {
204+
const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
205+
const sha = pr.head.sha;
206+
const run = await findRun(fork, sha, attempts);
207+
const payload = run
208+
? runPayload(fork, sha, run)
209+
: missingPayload(fork, sha, await workflowState(fork));
210+
await github.rest.checks.create({ ...upstream, name: CHECK, head_sha: sha, ...payload });
211+
core.info(`Created ${CHECK} for ${sha}: ${payload.conclusion || payload.status}`);
212+
}
213+
214+
async function notify() {
215+
const pr = context.payload.pull_request;
216+
// Only fork PRs to main skip jobs upstream; release branches have
217+
// no merge queue and keep the full run.
218+
if (!isForkPrToMain(pr)) {
219+
core.info('Not a fork pull request to main; nothing to mirror.');
220+
return;
221+
}
222+
await createCheck(pr, 12);
223+
}
224+
225+
// Discover the latest run again, since a manual run can replace the
226+
// recorded run, and the fork can delete old runs.
227+
async function syncCheck(pr, includeCompleted = true) {
228+
const { data } = await github.rest.checks.listForRef({
229+
...upstream,
230+
ref: pr.head.sha,
231+
check_name: CHECK,
232+
per_page: 5,
233+
});
234+
const check = data.check_runs[0];
235+
if (!check) {
236+
// Recover if the initial notification failed before creating a check.
237+
await createCheck(pr, 1);
238+
return;
239+
}
240+
if (!check.output || !check.output.text) return;
241+
let params;
242+
try {
243+
params = JSON.parse(check.output.text);
244+
} catch {
245+
return;
246+
}
247+
// Completed runs only need the rotating sweep to discover reruns.
248+
// A completed check with no run must keep looking for a late run.
249+
if (!includeCompleted && check.status === 'completed' && params.run_id) return;
250+
const fork = { owner: pr.head.repo.owner.login, repo: pr.head.repo.name };
251+
const sha = pr.head.sha;
252+
const run = await findRun(fork, sha, 1);
253+
if (!run) return;
254+
const payload = runPayload(fork, sha, run);
255+
if (
256+
payload.status === check.status &&
257+
(payload.conclusion || null) === check.conclusion &&
258+
run.id === params.run_id &&
259+
run.run_attempt === params.run_attempt
260+
) {
261+
return;
262+
}
263+
await github.rest.checks.update({ ...upstream, check_run_id: check.id, ...payload });
264+
core.info(`#${pr.number}: ${check.status}/${check.conclusion} -> ${payload.status}/${payload.conclusion || ''}`);
265+
}
266+
267+
const listing = { ...upstream, state: 'open', sort: 'updated', direction: 'desc' };
268+
269+
// The number of open pull requests, from the last-page link of a
270+
// one-item page: one request, whatever the count.
271+
async function openPullRequestCount() {
272+
const response = await github.rest.pulls.list({ ...listing, per_page: 1 });
273+
const last = /[?&]page=(\d+)>;\s*rel="last"/.exec(response.headers.link || '');
274+
return last ? Number(last[1]) : response.data.length;
275+
}
276+
277+
async function update() {
278+
const hours = Number(process.env.UPDATE_HOURS) || 6;
279+
const cutoff = Date.now() - hours * 3600 * 1000;
280+
const seen = new Set();
281+
282+
// Select the sweep first so a recent completed PR in this slice
283+
// is checked once, even though it also appears in the window.
284+
const slices = Math.max(1, Math.ceil((await openPullRequestCount()) / SWEEP_SIZE));
285+
const slot = Math.floor(Date.now() / (15 * 60 * 1000));
286+
const page = (slot % slices) + 1;
287+
const { data: slice } = await github.rest.pulls.list({ ...listing, per_page: SWEEP_SIZE, page });
288+
const sweepNumbers = new Set(slice.map((pr) => pr.number));
289+
290+
// Pull requests updated within the window, newest first. Stop at
291+
// the window: GITHUB_TOKEN allows 1,000 requests per hour per
292+
// repository.
293+
const pages = github.paginate.iterator(github.rest.pulls.list, { ...listing, per_page: 100 });
294+
for await (const page of pages) {
295+
let stale = false;
296+
for (const pr of page.data) {
297+
if (new Date(pr.updated_at).getTime() < cutoff) {
298+
stale = true;
299+
break;
300+
}
301+
seen.add(pr.number);
302+
if (isForkPrToMain(pr)) await syncCheck(pr, sweepNumbers.has(pr.number));
303+
}
304+
if (stale) break;
305+
}
306+
307+
// Check the rest of the slice, including older pull requests.
308+
// A fork rerun does not update the upstream pull request.
309+
for (const pr of slice) {
310+
if (seen.has(pr.number) || !isForkPrToMain(pr)) continue;
311+
await syncCheck(pr);
312+
}
313+
}
314+
315+
if (context.eventName === 'pull_request_target') {
316+
await notify();
317+
} else {
318+
await update();
319+
}

0 commit comments

Comments
 (0)