ci: move the Spark 3.4/3.5/4.0, Iceberg, macOS and benchmark suites behind a merge queue - #5843
Conversation
…mpute-changes.py
Every heavy job in `ci.yml` carried a four-line `${{ }}` expression combining
a path-filter output, an event-name test, an opt-in label test, and a special
case for `labeled` events. Ten jobs, ten near-identical copies, none of them
testable outside a real workflow run.
Fold that policy into `dev/ci/compute-changes.py` next to the path filters it
was already being ANDed with. Each job's gate becomes:
if: needs.changes.outputs.spark_3_5 == 'true'
and the routing lives in one readable table:
POLICY = {
"spark_3_5": ["pr", "push"],
"spark_3_4": ["push", "label:run-spark-3.4-tests"],
...
}
`ci.yml` loses 62 lines net. No behaviour changes: the `changes` job now
receives the event name, action, added label and PR labels, and applies the
same rules the expressions did.
The point of moving it is that it can now be tested. `check-ci-config.py`
grows `POLICY_CASES`, which pins the expected job set for each event shape --
including that a non-gating label such as dependabot's `dependencies` starts
nothing, which is issue apache#5007 and was previously only enforceable by reading
YAML carefully.
Writing `"pr"` alongside a `"label:"` tier reads as "runs on every PR, and also
when labelled" but the label check wins and the "pr" is dead, so
`check-ci-config.py` now rejects that combination rather than letting it be a
silent no-op.
Verified equivalent with a differential harness: the pre-refactor `if:`
expressions were extracted from ci.yml at the merge base, translated to Python,
and evaluated against the new POLICY over 1464 (job, event) combinations --
every event name, pull_request action, label set and added-label pairing. Zero
mismatches, and three seeded regressions (renaming a gating label, dropping a
label gate, dropping the labeled-event narrowing) are each caught.
Groundwork for apache#5838: adding the merge queue then means adding one tier to
POLICY rather than editing ten YAML expressions.
…status check
`main` currently requires no status checks, only one approving review. Adding
one is blocked by how the umbrella workflow reports: every heavy job in
`ci.yml` is a thin caller of a reusable workflow, and the check name a caller
publishes depends on whether it ran.
skipped by `if:` one check run named exactly `PR Build (Linux)`
actually ran only `PR Build (Linux) / Spark 4.1, JDK 17 [exec]` and
friends, and no bare `PR Build (Linux)` at all
Confirmed against bdd2aeb (md-only, everything skipped) and 36caf8e (heavy
jobs ran). No name is reported in both cases, so requiring the bare name would
block every code change and requiring a nested name would block every docs-only
change. Both hang waiting for a check that never arrives rather than failing,
and a required context that never reports also blocks the merge that would fix
`.asf.yaml` -- only INFRA can remove a required check by hand at that point.
Add `required_checks`, a flat job that `needs:` every other job and reports on
every event. It runs `if: always()` and treats `skipped` as a pass, so it goes
red only when an upstream job reports `failure` or `cancelled`.
Nothing requires it yet. This lands on its own so the check can be observed on
real pull requests before `.asf.yaml` names it, which is the step that is
expensive to get wrong.
`dev/ci/check-ci-config.py` gains two invariants: every `ci.yml` job except
`docs` must appear in `required_checks.needs`, and once `.asf.yaml` does
declare a required context, the job's `name:` must match it.
Part of apache#5838.
30044a5 to
15379bf
Compare
…up-3c01985b # Conflicts: # dev/ci/check-ci-config.py
The aggregator reads only the `needs` context, so it has no use for the
GITHUB_TOKEN. Declaring `permissions: {}` on the job drops the token
entirely and clears the CodeQL "Workflow does not contain permissions"
finding, without touching the token scope any other job in ci.yml gets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
Widen the job-id regexes to accept `-` and uppercase, which GitHub allows. A `spark-4-2:` job missing from `required_checks.needs` passed the coverage check silently before this change. Scope the `.asf.yaml` parse to the `main:` entry under `protected_branches`. It previously collected every `contexts:` list in the file, so a release branch requiring its own context produced a false failure that blamed main. Drop the em dashes from the new README prose. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
Once Required Checks is a required context, any red job evicts the PR from the merge queue. The first two runs on this branch each failed in a step that runs after the test verdict is known or before any test has started, and in neither case was a test involved. - java-test: mark the test-report upload continue-on-error. It runs on green jobs, nothing downstream consumes the reports, and a FinalizeArtifact 403 must not turn a passing run red. The failure-only uploads are unchanged. - iceberg_spark_test_reusable: route the shard-inventory upload through upload-artifact-retry. iceberg-spark-shard-coverage consumes it, so it already fell under the README rule for artifacts a later job downloads. - pr_build_linux: split Lint Scala (syntactic) into a retried no-op `cs launch scalafix -- --version` that populates the cache, then the real check under `--mode offline`, so a nonzero exit there is only ever a lint violation. - ci.yml preflight: retry the actionlint download and fetch the installer to a file instead of piping it into bash. Closes apache#5860 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
Turns on GitHub's merge queue for `main` and splits CI into two tiers, so an
iterating pull request stops paying for suites that only need to be green at
merge time.
PR tier build Linux/macOS, benchmark, Spark 4.1, Iceberg 1.11
queue tier all of the above, plus Spark 3.4/3.5/4.0 and Iceberg 1.8/1.9/1.10
push main docs deploy only
Because the routing policy now lives in `POLICY` in compute-changes.py, the
retiering is a table edit rather than ten rewritten `${{ }}` expressions:
`"push"` becomes `"queue"`, and Spark 3.5 gains a `run-spark-3.5-tests` label
so it can still be opted into from a pull request. `ci.yml` only gains the
`merge_group` trigger and the base/head SHAs to diff a queue branch against.
Spark 3.5 is the one that changes for contributors: it ran on every pull
request and is now queue-only. Spark 4.1 stays in the PR tier because it is
the default build profile and so the cheapest early warning that a change is
wrong.
Dropping the `push` tier from the heavy jobs is load-bearing, not cleanup. The
queue tests the exact tree that lands, so keeping the old push-to-main runs
would make every merge pay for CI twice and total usage would go up rather
than down. Iceberg 1.8/1.9/1.10 move into the queue for the same reason: they
were push-only, so they had nowhere else to run.
The queue itself is configured in `.asf.yaml`. Rulesets there accept a raw
Rulesets API payload, so a `merge_queue` rule needs no INFRA ticket; this is
the same approach apache/iceberg-python and apache/seatunnel use. GitHub
evaluates the ruleset and classic branch protection together, so the existing
one-approving-review requirement is unaffected, and release branches keep plain
protection because merge queue rules reject wildcard ref patterns.
`Required Checks`, added in the previous commit, becomes the single required
status check and therefore the only thing the queue waits on. `POLICY_CASES`
in check-ci-config.py is updated to pin the new tiers, including that push to
main now runs the site deploy and nothing else.
Two follow-ups this does not cover: `run-spark-3.4-tests` and
`run-spark-3.5-tests` still have to be created in repository settings, and a
flaky test in the queue tier now blocks everyone's merges rather than one pull
request, which is worth a heads-up on dev@ before this is enabled.
Part of apache#5838.
15379bf to
713e596
Compare
The Linux build already covers rustfmt and the Rust/JVM compile on every pull request, and macOS runners are the scarcest capacity we have. The benchmark check only compiles and lints benchmark sources, so a break there cannot change a PR's verdict. Both now run in the queue tier, with `run-macos-tests` and `run-benchmark-check` labels as escape hatches. Also update the per-job comments in ci.yml that still described the opt-in suites as main-only, and drop a duplicated `required_checks` box from the pipeline diagram in the workflows README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
| "build_macos": ["pr", "push"], | ||
| "benchmark": ["pr", "push"], | ||
| # docs deploys to asf-site, so it must not run from a pull request. | ||
| "build_linux": ["pr", "queue"], |
There was a problem hiding this comment.
Thanks @blaginin. I went ahead and addressed this in the current PR
|
Thanks @andygrove I'll check this today |
A ListArtifacts request answered "(403) Forbidden: Error from intermediary" fails actions/download-artifact before a byte is fetched. It is usually the first step of a test shard, so the shard never runs and the job is red for a reason that has nothing to do with the change. Once Required Checks gates the merge queue, that evicts the PR. Add download-artifact-retry alongside upload-artifact-retry: same three attempts and backoff, the subset of inputs this repository uses, and nothing to undo between attempts. Use it at every download that has a checkout; merge-fallback-logs deliberately skips checkout, so it stays on the plain action. check-ci-config.py recognises both spellings when pairing downloads with their producers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
A pull request can restore actions/cache entries saved on its own branch or on main, and nowhere else. The merge queue runs on a throwaway gh-readonly-queue/* branch, so the caches it saves are deleted with it. With no test job left on push, a Cargo.lock or pom.xml change would leave main's cargo-registry, Maven and TPC-H/TPC-DS caches stale, and every later pull request would pay the delta on top of the restore-keys prefix match. Put build_linux back on push, as the one job that refreshes them, and say why in POLICY, the policy test case and the workflows README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B
sunchao
left a comment
There was a problem hiding this comment.
Correctness
Reviewed e106db138b5a2f439a8d688131d154f25df1ab7a against f29a236128b3c0b175bb77b5b1f5192356c3056a. This moves macOS, benchmark compilation and Spark 3.5 out of the default PR tier, moves the older Spark/Iceberg suites from post-merge runs into the queue, and keeps Linux builds on push to refresh main's caches. The queue event diffs its base and head, then applies the existing path filters and the new event-policy table.
Two P2 issues remain in the artifact-download changes: the new shared action is missing from all consuming path filters, and the optional fallback-log merge job invokes it without checking out the repository. The inline comments describe their separate triggers. The latter does not affect current umbrella calls, which leave collect-fallback-logs false.
No Spark expression, operator, test contents or engine semantics change here. Spark 3.4/3.5/4.0/4.1 and Iceberg coverage is being rescheduled; this source review does not establish runtime semantic equivalence. Local configuration checks passed, and independent YAML/routing checks confirmed both gaps with in-memory corrective controls. The required-name guard also rejected a renamed context, a missing dependency and reuse of the required name on label runs.
Preflight passed, including the configuration guard and actionlint. Its checkout was 27a801114a82e356ea988bc76640fa4b33f4e0a1, with the reviewed base/head as parents and the same tree as this head. The CI run was still in progress when checked. No local native/JVM suites or live merge-queue enforcement tests were run.
Performance
Deferring these suites reduces repeated work while a PR is being revised. Keeping only Linux and docs on push also avoids repeating most queue work after landing.
The stated five-PRs-per-pipeline saving needs correction: GitHub documents that max_entries_to_merge controls landing limits after checks pass and does not combine merge_group builds. With ALLGREEN, successive entries still require their own successful queue validations. Build concurrency limits simultaneous groups, not the number of validations charged to a batch. No measured queue throughput or runner-minute reduction is established here. GitHub merge-queue behavior
Design
Keeping path selection and event policy separate makes the tier changes easy to inspect. The existing flat aggregate remains the required context, while label-triggered runs publish a separate name; docs deployment stays outside the merge decision. These choices preserve a stable status name without forcing every reusable-workflow matrix name into branch protection.
The ASF raw-ruleset handler supports this payload shape and coerces its numeric parameters. The read-only ruleset list and public branch metadata returned no rulesets and no required status contexts. Full protection settings were not available from the protection endpoint, so this is proposed configuration, not a verified rollout. The macOS and benchmark opt-in labels were already present when checked.
Abstraction & complexity
The small download wrapper centralizes bounded retries without changing the callers' artifact names or extraction paths. Its final attempt still propagates failure, and the explicit string default for merge-multiple preserves the underlying action's boolean-input contract. Registering this shared action alongside the upload action in routing checks, and retaining the upstream action in the checkout-free fallback job, would close the two concrete integration gaps without adding another abstraction.
| name: "Download Artifact (with retry)" | ||
| description: > | ||
| Drop-in replacement for actions/download-artifact that retries the download |
There was a problem hiding this comment.
Correctness
[P2] Register the new shared action in the consuming path filters. FILTERS lists upload-artifact-retry/** but never download-artifact-retry/**. For an edit confined to this new action, the current compute() returns every output false on both a PR update and merge_group, so none of its Linux/macOS/Spark/Iceberg consumers exercise the change. The existing config guard also passes that configuration. Add this path to the ten consuming filters and a corresponding ROUTING_CASES entry, as for the upload wrapper. I verified that adding only those dependencies in memory restores all ten queue routes.
There was a problem hiding this comment.
Fixed in 049fa83. .github/actions/download-artifact-retry/** now sits in the same ten filters that already listed the upload wrapper, with the matching ROUTING_CASES entry beside it. Dropping it from one filter fails the check with expected iceberg_1_11=true, got false, so it can't quietly go missing again.
| uses: actions/download-artifact@v8 | ||
| # Not wrapped in download-artifact-retry: that local action needs a | ||
| # checkout, which this job deliberately skips. | ||
| uses: ./.github/actions/download-artifact-retry |
There was a problem hiding this comment.
Correctness
[P2] Keep this download usable without a checkout. When a caller enables collect-fallback-logs, this job starts on its own runner and its first step now references ./.github/actions/download-artifact-retry, but no step has checked out that directory. The action cannot be loaded, so the merged fallback-log artifact is no longer produced. The preceding comment describes the intended exception, but the uses value does the opposite. Restore actions/download-artifact@v8 here, or add a checkout before the local action. Current umbrella callers leave the option false, which explains why ordinary CI does not exercise this regression. GitHub's local-action requirement
There was a problem hiding this comment.
Good catch, and the worst combination: the comment was right and the uses: was wrong. Restored actions/download-artifact@v8 in f36626d.
Since no caller sets collect-fallback-logs, a comment was never going to hold that line, so I also added a fifth invariant to check-ci-config.py — any uses: ./.github/actions/... in a workflow needs an actions/checkout earlier in the same job. Reintroducing the bad uses: now fails preflight naming exactly that step.
FILTERS listed the upload wrapper but not the download one, so an edit confined to .github/actions/download-artifact-retry/ set every output false and none of its ten Linux/macOS/Spark/Iceberg consumers ran, on a pull request or in the queue alike. Add the path to the same ten filters, and a ROUTING_CASES entry so a future deletion is caught the way the upload wrapper's already is.
… action merge-fallback-logs runs on its own runner with no checkout, so it cannot load a local composite action. Its own comment already said so, but the `uses:` had been switched to ./.github/actions/download-artifact-retry, which means the merged fallback-log artifact would simply not be produced. No current caller sets collect-fallback-logs, so nothing exercises it. Restore actions/download-artifact@v8 there, and add a fifth invariant to check-ci-config.py: any `uses: ./.github/actions/...` in a workflow needs an actions/checkout earlier in the same job. Verified by reintroducing the bad `uses:` and watching the check fail on exactly that step.
The description of max_entries_to_merge was wrong. GitHub documents that merge limits do not combine merge_group builds: every queue entry gets its own build of itself plus everything ahead of it, and the limit only caps how many already-green entries land in one merge operation. So it saves no CI, and "one pipeline validates up to five PRs" was not a real property. max_entries_to_build is the dial that matters. It caps how many pipelines are in flight, and with them how fast the queue drains: about 19 merges a day at 2 and the ~2.5h pipeline we see today, against a median of 6 merges a day and a busiest day of 22 over the last month. Left at 2 for now, but worth raising if the queue backs up. The ALLGREEN comment was also describing HEADGREEN's behaviour. Under ALLGREEN every entry's own build must be green, not just the head's. None of this changes the case for the split: the saving comes from the PR tier being small, not from batching inside the queue. Thanks to @sunchao for catching the claim.
|
Thanks @sunchao. You're right about the queue parameters, and it's worse than a wording problem — GitHub says outright that merge limits do not combine While rewriting those comments I noticed the The correction also moves where the real constraint sits. With every entry getting its own build, The case for the split itself is unaffected: the saving comes from the PR tier being small, not from anything the queue batches. And agreed on the rollout caveat — the ruleset and the |
|
I checked the ASF side of this against the asfyaml source and GitHub's merge queue docs, and read the workflows at
Rollout riskRehearse Make the merge_group diff fail open, as cheap insurance: git diff --name-only "$MQ_BASE_SHA"..."$MQ_HEAD_SHA" > changed_files.txt || {
echo "::warning::merge_group diff failed; running everything"
git ls-tree -r --name-only "$MQ_HEAD_SHA" > changed_files.txt
}Add a project-side bypass actor. Gaps in the diffThe
The explicit saves are guarded on In the queue, a fork PR's code runs with a write-capable permissions:
contents: read
jobs:
docs:
permissions:
contents: write # pushes to asf-siteFour workflows sit outside the umbrella and outside the queue. Tuning
The cost model turns on the rebuild rate, not the tier sizes. Per merged PR with A full pipeline is several times
Minor
|
|
CI failure is unrelated: |

Which issue does this PR close?
Closes #5838.
Rationale for this change
CI load on pull requests is dominated by suites that don't really need to be green until merge time. A merge queue lets us move them there: the queue tests the exact tree that will land, so an iterating PR can run a smaller set without losing any coverage on
main.The part I expected to be a blocker turned out not to be. Merge queue is self-serve in
.asf.yamlnow. Rulesets there accept a raw GitHub Rulesets API payload, andasfyaml/feature/github/rulesets.pypasses it through with no rule-type allowlist (_to_payload_rulesetreturns the raw mapping after coercing the numeric keys), so amerge_queuerule needs no INFRA ticket. apache/iceberg-python, apache/seatunnel and apache/texera all run queues configured this way. Worth noting apache/datafusion is not the template even though it's the obvious place to look: their.asf.yamlhas no queue config andgh api repos/apache/datafusion/rulesetsreturns[], because INFRA enabled theirs directly in classic branch protection via INFRA-27154 before asfyaml supported rulesets.What changes are included in this PR?
CI splits into two tiers:
Three things change for contributors:
run-spark-3.5-testslabel as an escape hatch. Spark 4.1 stays in the PR tier because it's the default build profile, so it's the cheapest early warning that something is wrong.run-macos-testsas the escape hatch. macOS runners are the scarcest capacity we have, and the Linux build already covers rustfmt and the Rust/JVM compile on every PR, so the PR tier loses nothing but platform-specific coverage.run-benchmark-checkas the escape hatch. It only compiles and lints benchmark sources, so a break there can't change a PR's correctness verdict.The Linux build keeps a
pusharm for one reason:actions/cachescoping. A PR can only restore caches saved on its own branch or onmain, and the queue runs on a throwawaygh-readonly-queue/*branch whose caches are deleted with it, so something has to refreshmain's cargo-registry, Maven and TPC-H/TPC-DS caches after aCargo.lockorpom.xmlchange. This was @blaginin's suggestion in review.Dropping the
pusharm from the other heavy jobs is load-bearing rather than cleanup. The queue already tested the tree that lands, so keeping the old push-to-main runs would make every merge pay for CI twice and total usage would go up, not down. Iceberg 1.8/1.9/1.10 move into the queue for the same reason. They were push-only, so otherwise they'd have nowhere left to run.On the
.asf.yamlside: aMerge Queueruleset targeting~DEFAULT_BRANCH(merge queue rules reject wildcard ref patterns, so release branches keep plain protection),Required Checksas the single required status check, andallow_auto_mergeso a PR can be queued before its checks finish.apache/rootis a bypass actor so a wedged queue can be recovered without a ticket. GitHub evaluates the ruleset and classic branch protection together, so the existing one-approving-review requirement is unaffected.The queue parameter worth arguing about is
max_entries_to_build: 2. I had the cost model wrong here at first and @sunchao caught it: merge limits do not combinemerge_groupbuilds, somax_entries_to_merge: 5saves no CI at all. Every entry gets its own build of itself plus everything ahead of it in the queue, and that limit only caps how many already-green entries land in one merge operation. Build concurrency is therefore the throughput ceiling: two pipelines at the ~2.5h we see today drains roughly 19 merges a day, against a median of 6 and a busiest day of 22 over the last month onmain. I have left it at 2 with those numbers written into the.asf.yamlcomment, since raising it costs that many concurrent ASF runners, but it is a one-line change if we would rather start at 3. None of this changes the case for the split — the saving comes from the PR tier being small, not from batching inside the queue.How are these changes tested?
A
download-artifact-retryaction mirrors the existing upload wrapper, after aListArtifacts403 killed a Spark 4.1 shard on this PR before it ran a single test. Every download that has a checkout uses it;merge-fallback-logsstays on the published action because it deliberately skips checkout.check-ci-config.pynow holds both halves of that in place, after @sunchao found the wrapper was missing from the ten consuming path filters and thatmerge-fallback-logshad been switched to the local action anyway. The wrapper is registered in those filters with aROUTING_CASESentry, and a new fifth invariant rejects anyuses: ./.github/actions/...in a job with no precedingactions/checkout. I confirmed each guard fires by reintroducing the bug it catches.preflightrunsdev/ci/check-ci-config.py, which (since #5842) enforces that every job is registered with the aggregator and that theRequired Checksname matches the context this PR adds to.asf.yaml. Since.asf.yamlnow declares that context, the name-match half of the guard is live for the first time on this PR. I confirmed it fires by renaming the job locally.actionlint,prettier --check "**/*.md"and a YAML parse of both changed configs are clean.Since #5850 moved the routing policy into
compute-changes.py, the tier change here is aPOLICYtable edit covered byPOLICY_CASES, rather than ten rewritten YAML expressions. Those cases are written out longhand rather than derived fromPOLICY, so every tier move has to be stated twice. I confirmed the check fails if macOS is put back in the PR tier.ci.ymlonly gains themerge_grouptrigger and the SHAs to diff a queue branch against.The
merge_grouppaths can't really be exercised until the ruleset is live. #5842 landedRequired Checkson its own first so we could confirm it reports correctly on real PRs.Notes for reviewers
run-macos-testsandrun-benchmark-checkneed creating in repository settings before this merges. Therun-spark-*-testsandrun-iceberg-testslabels already exist. Acontains()on a label nobody can add is simply always false, so a missing label makes the escape hatch look like it silently does nothing.🤖 Generated with Claude Code
https://claude.ai/code/session_01BtAqq4YJsk8uk42c7vHk8B