Repository navigation
ci: run the pull request tier for fork pull requests on the fork - #7311
Conversation
Nearly every pull request comes from a fork, and the pull request tier plus label runs were about 60% of the runner-hours ci.yml and ci_label.yml spent in the week to 2026-10-28. For a pull request from a fork into main, the tier now runs on the contributor's fork. The merge queue stays on ASF runners and still runs every suite on the merge result. - compute-changes.py: a push to a fork gets the `pr` tier, and a pull request from a fork into main loses it. Every `run-*` label suite and the `labeled` run are unchanged, as are pull requests from a branch of this repository, pull requests to a release branch, and pull requests opened inside a fork. - ci_fork.yml: calls ci.yml on a push to any fork branch except main. It is a separate workflow so a push to a branch of this repository cannot publish a skipped `Required Checks` next to a pull request's real one. Upstream its only job is skipped. - ci.yml: a fork push diffs against the fork's default branch, and its concurrency group is keyed on the branch so the next push cancels the run. - fork_ci_status.yml: mirrors the fork's run onto the pull request as an informational `CI (fork)` check, adapted from apache/arrow-rs#11788 and apache/datafusion#25591. It never checks out pull request code. The fork result is advisory because a contributor controls the workflow. - check-ci-config.py: policy cases for fork pushes and fork pull requests, the new environment wiring, and a check that the caller, the bridge and ci.yml's push trigger agree. - docs: contributor guide and workflows README.
|
Reviewed P2 —
|
A fork that enables scheduled workflows runs them on its owner's minutes. With no earlier scheduled run to diff against, a fork's nightly runs the whole nightly tier, and a fork's Miri run costs about twenty minutes a day. The nightly in ci.yml, miri.yml, stale.yml and codeql.yml now carry a `github.repository == 'apache/datafusion-comet'` guard on a scheduled event, and check-ci-config.py fails any workflow with a schedule trigger that lacks one. A push to a fork's default branch is a sync of commits the merge queue already tested. It now runs no job, instead of the pull request tier for what the sync changed. The caches it would warm save a branch run about nine minutes of fifty, so a sync would cost about as much as it saved. A pull request opened from a fork's main has nothing testing it, so the bridge says to open it from a branch rather than mirror a run that tested nothing.
github.head_ref is the branch name alone, so two pull requests from different forks whose branches share a name landed in one group, and a push to one cancelled the other's run. Nearly every pull request comes from a fork. Key on the pull request number instead, which still lets the next push to a pull request supersede its last run. Tests pin that same-named branches get separate groups, that a push to one pull request shares a group across commits, and that a label run has its own. The same pattern remains in codeql.yml, miri.yml and pr_title_check.yml.
- A fork push that diffs to nothing, such as a branch whose commits are already on the fork's default branch, runs the whole tier instead of reporting a green run for a change nobody built. - Preflight skips a push to a fork's default branch, whatever it is called, so a sync starts no run work at all. ci_fork.yml and the bridge use the repository's default branch rather than `main`. This replaces the same rule in compute-changes.py. - A pull request opened inside a fork leaves the PR tier to its branch push, so the same commit is not tested twice. A `run-*` label still runs its suite. - Preflight's header comment no longer claims it has no `if:`. - The bridge uses actions/github-script@v9 like the other workflows. - AGENTS.md says a fork pull request's PR tier runs on the fork.
- The bridge job skips a workflow_run for a pull request from a branch of this repository, which has no fork run to mirror, and drops a repository guard the job already had. Its fork-default-branch test reuses the shared harness. - Detect changes folds the fork push fallbacks into one empty-file check. - check-ci-config.py asserts its UPSTREAM matches compute-changes.py, shares the regex for a job that calls ci.yml, builds the new policy cases with a helper, checks that the bridge reads runs of ci_fork.yml, and lists its ninth invariant in the header. - ci_fork.yml no longer ignores queue branches, which the repository guard already skips. - Comments and the workflows README no longer repeat the cold-cache measurements or the same bullet twice.
|
cc @blaginin |
…urately A push to a fork's branch was diffed against the fork's own default branch. A fork that had not synced was behind upstream, so a branch cut from newer upstream code that reverts one of its files matched the fork's old copy, and the diff left the file out: the pull request showed it, the fork run did not test it. dev/ci/fork-push-changed-files.sh now fetches upstream main without the fork's credentials and diffs against that, which is the diff the pull request will have. It lists every file when the fetch fails, there is no merge base or the diff is empty. A test builds real repositories for the stale baseline revert, code followed by docs, a branch already on the fork's main, an unreachable upstream and an empty diff. The docs and workflow comments said the merge queue runs every suite. It runs the queue tier, with path filters; the nightly-only and label-only suites are not part of it.
|
Thanks for the careful review. It was of the first commit, so some of this has moved; the current head is f1a3040. The fork The stale baseline finding is right, and the "never fewer" claim was wrong. A fork push is now diffed against upstream On the queue coverage, the docs, the workflows README and the workflow comments now say the queue runs the queue tier with path filters, and that the nightly-only and The three inherited findings are outside this diff, so I have not touched |
sunchao
left a comment
There was a problem hiding this comment.
The upstream-main diff fixes the stale-fork case from the earlier review. The CI policy check, all seven real-Git diff tests, and all 36 bridge tests passed for f1a3040d.
I’m requesting changes because the advisory check can tell a contributor that CI is missing when their branch has a run, and the new schedule validator passes even when the guard that keeps the full nightly off forks is removed. I’ve also marked where the sync job and rerun cadence differ from the contributor guide. The status bridge itself has only been exercised with mocked APIs on this revision.
…valuation - ci_fork.yml now runs every fork branch except the fork's default branch, `main` included. ci.yml also triggers on push to `main`, so its Preflight skips a fork push unless ci_fork.yml called it, which it tells by the caller's `github.workflow`. A branch called `main` in a fork whose default branch is another name is therefore tested once, by the workflow the bridge looks up, instead of by ci.yml directly where the bridge reported no run. - required_checks skips itself when Preflight is skipped, so a fork's schedule or sync starts no runner, and nightly_report no longer reads a skipped aggregator as a failure. - check-ci-config.py evaluates the `if:` of each root job of every scheduled workflow for a scheduled run in a fork, and Preflight and ci_fork.yml's job for the pushes and events that matter, instead of matching text. A guard that mentions the repository in the wrong clause no longer passes. - The contributor guide says how often the bridge rechecks a result that is already complete.
sunchao
left a comment
There was a problem hiding this comment.
Thanks for following up on all four comments. I verified the fixes in 40eb6cf2: a fork with default develop now tests a branch named main through the workflow the bridge reads, removing just the fork-schedule guard now fails validation, and the aggregator and reporter both skip the intended events. The new rerun-cadence explanation matches the bridge.
I’m requesting changes for a remaining branch-identity issue. Distinct Git branch names can differ only in capitalization. One such branch can lose its build at the default-branch check, and two feature branches can cancel each other. I’ve also pointed out a regression test that still passes if the previous main trigger exclusion is restored.
One small wording change would finish the guide: the two bullets at ci.md lines 157 and 162 still say a fork’s main gets no CI. They should say that the fork’s default branch skips the Comet PR tier, consistent with the develop case you just fixed.
The policy check, all 37 bridge/concurrency tests, and all seven Git diff tests passed locally. Upstream Required Checks passed and the fork build was still running when I reviewed.
…Actions does GitHub Actions compares strings, branch names and concurrency group names without regard to case, and a fork can hold both `feature` and `Feature`. - A fork push no longer shares a concurrency group per branch, which would let a push to one cancel the other's run, and no expression can make the two keys differ. ci_fork.yml has a `supersede` job that cancels the older queued and running runs of exactly this branch instead, comparing with case. - The bridge treats a case variant of the default branch, such as `Main` with default `main`, as the default branch, because ci_fork.yml's job guard skips it and there is no run to find. - check-ci-config.py's condition evaluator compares strings without regard to case, checks both ci_fork.yml jobs against the same table including the case variant, and checks that the push trigger admits `main`, `Main` and ordinary branches, so restoring a `main` exclusion fails. - The contributor guide says the default branch skips the Comet PR tier, not that nothing runs on `main`.
|
On the two guide bullets at the end of your review: they now say the fork's default branch does not run the Comet PR tier, rather than that nothing runs on |
comphead
left a comment
There was a problem hiding this comment.
This follows the pattern DataFusion and arrow-rs adopted and that Spark's notify_test_workflow.yml/update_build_status.yml have used for years. The new workflows check out nothing, interpolate no event values into scripts, and the mirrored check stays advisory, so a fork controlling its own run cannot gate a merge. Four comments.
…rk diff
- fork_ci_status.yml starts with `permissions: {}` and the bridge job asks for
`checks: write` and `pull-requests: read` only. `actions: read` was never
used: every call to the fork's runs and workflows reads public data of
another repository. check-ci-config.py pins both.
- A fork push whose diff against upstream main is empty, with a merge base,
selects nothing: the pull request will show no change. Only a failed fetch or
a missing merge base lists every file. The earlier example for the empty case
was wrong, since commits only the fork's main has are listed.
- The bridge matches only a `push` run of ci_fork.yml, which has no other
trigger, and its two manual-run test cases go.
- ci.yml's profile table and the policy cases cover a fork pull request with
`run-all-spark-profiles`: only the other profiles run here, and the fork's
run covers the default profile and the lints.
Skipping the fork's default branch, and with it the tests of a sync, needed the default branch to be told apart by name in two job guards and the bridge, and Actions compares branch names without regard to case. Run it like every other branch instead. - ci_fork.yml's jobs skip only upstream. Its trigger ignores `asf-site`. - The bridge looks up the ci_fork.yml run for every pull request, and no longer answers a pull request from the default branch with a neutral status. - A push that adds nothing to upstream main, such as a sync, selects no suite because its diff against upstream is empty, so it starts only Preflight and Detect changes. It writes no cache either, and the guide says so. - ci.yml's Preflight still skips a fork push that did not come through ci_fork.yml, since ci.yml also triggers on push to `main`. - check-ci-config.py's ci_fork.yml table now expects a push to a fork's main to run, and the docs no longer describe a default-branch skip.
|
A change of direction on fork pushes, in dd828c0: a push to a fork's default branch is no longer skipped. It runs like any other branch push, so |
sunchao
left a comment
There was a problem hiding this comment.
I rechecked dd828c0 against Andy’s updated fork-push design and resolved my two default-branch threads. Changes made directly on a fork’s main select the expected suites, an empty sync selects none, and restoring main to branches-ignore now fails validation.
The remaining CI issue is that two branches in the same fork can cancel each other when they point to the same commit. I followed up in the existing concurrency discussion.
The config check, seven Git diff tests, and all 38 Node tests passed locally. The upstream and fork runs passed on this head. The advisory bridge still awaits its first hosted run from the default branch. Upstream run · Fork run
A fork push was keyed on its commit. A backup branch created at the commit a pull request is building has the same commit, so its run shared the group and, with cancel-in-progress, cancelled the pull request's build, and the bridge then reported the cancelled run. Key a fork push on its run id instead, so no other push shares its group. ci_fork.yml's supersede job still cancels older runs of exactly the same branch. The workflows README no longer says a sync starts only Preflight and Detect changes. It also starts the supersede job and the aggregator, so it says what the saving is: no build or test suite and no cache write.
sunchao
left a comment
There was a problem hiding this comment.
Approved at 6dc32f21. Fork pushes now get separate concurrency groups even when two branches point to the same commit. Restoring the old SHA-based key made the new regression test fail. The empty-sync documentation is accurate, and I verified and resolved the six remaining review threads.
I traced the fork CI failure to an existing order-sensitive assertion in CometParquetWriterSuite. Both partition files were created and closed, but they closed in the opposite order. The same assertion failed upstream on an unrelated PR before this fork run. The test, its recording filesystem, and the Linux build workflow are identical to the PR base. A separate test fix is open. Fork failure · Independent upstream failure · Test fix #7335
The CI config check, all 38 Node tests, and all seven Git diff tests passed on this head. Upstream CI passed. The informational bridge still needs its first hosted run after the workflow reaches the default branch. Upstream CI
|
Thank you @sunchao, @comphead and @ErikBPF for the thorough reviews. Between you they found a stale-baseline diff, the hole in the schedule guard, a runner started on skipped runs, the case-insensitive branch and group comparisons, the same-commit collision, the over-wide bridge permissions and a wrong example of mine in the empty-diff case, and each one changed the code. This is now in the merge queue. Two things to watch after it lands. The |
Which issue does this PR close?
Closes #4289. Supersedes #4570.
Rationale for this change
Nearly every pull request comes from a fork (194 of the 198 open ones), and the pull request tier plus the
run-*label runs were about 60% of the roughly 2,900 runner-hours thatci.ymlandci_label.ymlspent in the week to 2026-10-28. The merge queue was about 30% and the nightly about 8%.apache/datafusion(apache/datafusion#25591) andapache/arrow-rs(apache/arrow-rs#11788) moved this work onto the contributor's fork, using the fork's ownpushtrigger rather than a separate fork-only wrapper workflow, and treat the fork's result as informational. This applies the same arrangement to Comet. The merge queue stays on ASF runners and still runs the queue tier on the merge result (the PR tier plus Spark SQL on 4.1, Iceberg 1.11, macOS and the other queue-tier suites; the nightly-only andrun-*suites are not part of it), so nothing reachesmainon the strength of a fork run alone.What changes are included in this PR?
compute-changes.py: a push to a fork's branch gets theprtier, and a pull request from a fork intomainloses it upstream. A pull request opened inside a fork loses it too, since its branch push already ran the tier. Therun-*label suites and thelabeledrun still run here, as do a pull request from a branch of this repository and a pull request to a release branch. One effect on a fork pull request:run-all-spark-profilesnow brings forward only the four non-default profiles here, since the fork's run covers the default profile and the lints;ci.yml's profile table and the policy cases say so.ci_fork.yml(new): callsci.ymlon a push to any fork branch, the default branch included. It is a separate workflow, asci_label.ymlis, so a push to a branch of this repository cannot publish a skippedRequired Checksnext to a pull request's real one. Upstream its only job is skipped.ci.ymlalso triggers on push tomain, so itsPreflightskips a fork push unlessci_fork.ymlcalled it (told apart by the caller'sgithub.workflow), which leaves the bridge one workflow to look up. A newer push to a fork branch cancels the run in flight through asupersedejob that compares the branch name with case, because Actions compares group names,==and!=without regard to case and a fork can hold bothfeatureandFeature.Preflightinci.yml,miri.yml,stale.yml,codeql.ymland the bridge skip a scheduled run outside this repository.required_checksskips itself whenPreflightdoes, so a fork's schedule or sync starts no runner, andnightly_reportreads a skipped aggregator as nothing to report.ci.yml: a fork push is diffed against upstreammainbydev/ci/fork-push-changed-files.sh, which gives the diff its pull request will have. Diffing against the fork's ownmainwould omit a file that a branch reverts relative to newer upstream code when the fork is behind. The script lists every file when the fetch fails or there is no merge base, and nothing when the branch adds nothing to upstream, anddev/ci/test-fork-push-changed-files.pycovers those cases on real repositories. A fork push is keyed on its run id in the concurrency group, so a backup branch at the same commit cannot cancel a pull request's build. Preflight runs the bridge tests.fork_ci_status.yml(new): mirrors the fork's run onto the pull request as an informationalCI (fork)check, onworkflow_runand every 15 minutes. It is adapted from arrow-rs, never checks out pull request code, and needs onlychecks: write.dev/ci/test-fork-ci-status.cjscovers it against mocked GitHub APIs (38 tests).check-ci-config.py: policy cases for fork pushes and fork pull requests, the new environment wiring, and checks that the caller, the bridge andci.yml's push trigger agree. The guards onPreflight,ci_fork.yml's job and every root job of a scheduled workflow are checked by evaluating theirif:for concrete events (a fork's schedule, a push throughci_fork.yml, a push to a fork'smain), not by matching text. Each guard was mutation-tested.ci.yml's concurrency group is keyed on the pull request number, notgithub.head_ref. The branch name alone let two pull requests from different forks with the same branch name cancel each other's run. This is part of ci: concurrency group is keyed on the branch name, so same-named fork branches can cancel each other's CI #7315;codeql.yml,miri.ymlandpr_title_check.ymlhave the same pattern, so that issue stays open. The tests pin that same-named branches get separate groups.CI (fork)is advisory, not required, because a contributor controls the workflow that produced it. A green fork run that never ran the tests costs one queue slot. The earlier attempts mirrored the fork's result into a required check; arrow-rs and DataFusion decided against that, and so does this PR.How are these changes tested?
python3 dev/ci/check-ci-config.py,node --test dev/ci/test-fork-ci-status.cjsandactionlint --shellcheck=offpass locally. I removed each new guard in turn and confirmed the check fails.Comet CI (fork run), which ran the full PR tier and passed. With cold caches it took 59 minutes of wall time and 240 runner-minutes, with the native build at 23 minutes. With warm caches (from the fork'smainrun by the oldci.yml) it took 50 minutes and 213 runner-minutes, with the native build at under 5. A push that adds nothing to upstreammain, such as a sync of the fork, selects no build or test suite and writes to no build cache. For comparison, successful full PR runs on ASF runners over the previous week had a median wall time of 81 minutes. No job waited for a runner. This is one account and two runs.ci.ymlanddev/ci/**, which select the full Linux build, and its upstream run ran only Preflight, Detect changes and Required Checks.PR Build (Linux)and every other job were skipped, andRequired Checkspassed.fork_ci_status.yml.workflow_runandscheduleuse the copy on the default branch, so it cannot run until this merges. The bridge is covered by its mocked tests, as in the arrow-rs and DataFusion changes, and the first fork pull request after merge is its real test.Contributors need to enable Actions on their fork and rebase onto
main; the CI guide says so, andCI (fork)says "Enable CI on your fork" when it finds no run.