ci: give each merge its own concurrency group so the coverage gate survives - #6049
Conversation
…rvives `ci.yml`'s concurrency group fell back to `github.ref` on `push` — `refs/heads/main` for every merge — so each merge cancelled the previous merge's still-running CI. The push lane is the only lane that runs the coverage gate (`test-coverage` + `coverage-report` are push-only), and the merged 4-shard report is what enforces `coverage.thresholds` for a commit. Measured over the 64 completed push-lane runs on `main` between 2026-08-23T06:34Z and 2026-08-24T13:56Z: the merged report was produced for 20, five lost it to a red suite, and 39 — 61% — lost it to cancellation. Over the 30 strictly consecutive push runs of 2026-08-24 alone it is 15 of 28 completed (54%). The lane needs ~13.5 min while the median inter-merge interval is 8.8 min. The group now carries `github.sha` on `push`. The `pull_request` group (a bare PR number, `cancel-in-progress: true`) and the `merge_group` fallback to `github.ref` are both unchanged. Not `cancel-in-progress: false`: that does not serialise. GitHub holds one pending run per group and discards the rest — 95 of 200 release runs never executed (objectui#5395). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
PM: ACCEPT — ruling on the open question is A, as landedThis is the strongest card of the round. The measurement is the deliverable and it was done properly. Ruling: keep A at workflow level. Do not split into
|
Fixes #5422
Measure first, then fix — per the dispatch ruling on #5422. The measurement said the loss is material (54–61% against a 20% threshold), so option A is implemented.
The measurement
Method.
GET /actions/workflows/ci.yml/runsfiltered tobranch=main, event=push, three pages. Every record's owneventandhead_branchfields were re-checked client-side rather than trusted from the filter. Per-run delivery was then read fromlist_workflow_run_artifacts: the coverage lane's deliverable today is thecoverage-reportartifact (the merged 4-shard report that enforcescoverage.thresholds), so its presence is the "did the gate deliver for this commit" signal.Primary window — 30 strictly consecutive push-lane runs, 2026-08-24T04:19Z .. 13:56Z (28 completed):
Corroboration — all 66 unique push-lane runs collected, 2026-08-23T06:34Z .. 2026-08-24T13:56Z (64 completed):
Why. The sharded lane needs ~13.5 min end to end (measured: shards ~12 min + the report job ~1 min; the 18 fully-green runs span 13.1–14.1 min). The median inter-merge interval is 8.8 min (p25 2.6, p75 20.4), and 63% of intervals are shorter than the lane. The card body's "median 25.2 min" is from 2026-08-19/20 and no longer holds.
Counter-probes
A zero is not a reading, and this lane has a documented history of misleading queries (#5393's retracted finding 2).
list_workflow_run_artifactscall that returnstotal_count: 0for cancelled runs returns acoverage-reportartifact for runs that delivered — e.g. run32735665660and run32633543905. Critically it also returns one for two runs whose run-level conclusion iscancelled(32730945699,32650838484): they were cancelled after the gate had already reported. Those two are counted as delivered, not lost.cancelledwas rejected as a proxy precisely because of (1) — it overstates loss. Every cancelled run was checked individually.pull_requestruns, which I initially read as theeventfilter being ignored. That was wrong: the contaminating file was a saved result from an earlier session, and my own three filtered pages were clean (30 push/main runs each). Re-derived from my own responses only. The filter works — independently confirmed byevent=merge_groupreturningtotal_count: 0(consistent with 合并队列声称「已强制」却从未产生过一次 merge_group 构建(repo-wide 0),必需集实测不含 4 个 shard / Type Check / Lint —— #3523 的第 3 步从未落地,而 AGENTS.md §9 已按「队列会替你兜住」反转了 auto-merge 禁令 #4986, the merge queue that has never produced a build) andevent=pull_request+branch=mainreturning 0.The change
Resolution per trigger — GitHub's
||yields the first truthy operand,&&yields its right operand when the left is truthy:pull_request.number(event_name == 'push' && sha)pull_requestci-CI-{number}— unchangedpushtrue && sha→ the shaci-CI-{sha}— new, one per commitmerge_groupfalse && …→falsegithub.ref=ci-CI-gh-readonly-queue/main/pr-{n}-{sha}— unchangedThe PR lane's
cancel-in-progress: trueis untouched, as the dispatch required. Two consecutive merges no longer share a group, so neither can cancel the other.Option A says "give the coverage lane its own
concurrencygroup". That mechanism does not exist. A job-levelconcurrency:only decides whether a job waits for another job in the same group; it grants no exemption from the workflow-levelcancel-in-progress, which cancels the whole run and every job in it. A per-sha group ontest-coveragealone would not have protected it. The group therefore had to move at workflow level, on thepushbranch of the expression.The consequence — and it is a real one the PM may want to rule on — is that this protects the whole push lane, not only the coverage jobs. Bursty merges no longer collapse into one surviving run, so
Type Check,Build DocsandBuild & E2Ealso run to completion and runner minutes rise. That is the cost option A itself named ("costs runner minutes when merges come in bursts"). Nothing is weakened: every merged commit now gets its own verdict, which is the only post-merge signal this repository has while #4986 leaves the merge queue validating nothing. If the narrower blast radius is wanted, the only way to get it is to split the coverage lane into its own workflow file — a much larger change that moves theTest (coverage)check between workflows, so I did not take it unasked.⛔ Option B is refuted — not taken
cancel-in-progress: falseon agithub.refgroup does not serialise. Withcancel-in-progressunset GitHub holds one pending run per group and discards the rest — measured on this repository's release lane at 95 of 200 runs never executing at all (#5395). It trades cancellation for silent dropping and is strictly worse than the status quo. The correction is posted on #5422 so the option text is not re-proposed.Verification, and its limit
fd788e60a:yaml.safe_loadresolvesconcurrency.groupto the single-line stringci-${{ github.workflow }}-${{ github.event.pull_request.number || (github.event_name == 'push' && github.sha) || github.ref }}with no embedded newline;cancel-in-progressis stilltrue.||/&&truthiness rules were simulated over the four cases, confirming the PR group is byte-identical to before, themerge_groupfallback is unchanged, and two different merge SHAs produce different groups.pnpm vitest run scripts/__tests__ --maxWorkers=2→Test Files 64 passed (64),Tests 1711 passed (1711), run onfd788e60aafter the final commit. 22 of those files readci.yml, includingmerge-queue-reporting.test.tsandci-cd-pipeline-doc.test.ts. No pin needed updating: this change adds no job, renames no job, and adds norun:command, which is the granularity those tests judge at.node scripts/check-changeset-presence.mjs→ exit 0, printing "No source of a released package changed in this range, so no changeset is owed." An empty-frontmatter changeset is included anyway, per the repo convention for a non-publishing change.node scripts/check-control-bytes.mjs→ exit 0, "scanned 4990 tracked text file(s)".--format jsonoutput for the changed file reports"File ignored because no matching configuration was supplied."Every block ineslint.config.*targets**/*.{ts,tsx}or narrower, and the diff contains no.ts/.tsxfile at all, so it cannot move any untouched file's verdict. A repo-widepnpm lintwould judge exactly zero of the changed bytes.Two dispatch-clue corrections:
scripts/check-workflow-*.mjsdoes not exist in this repository, and there is no actionlint/yamllint gate — the workflow-pinning coverage is entirely inscripts/__tests__/.Out of scope and untouched, as instructed:
changeset-release.yml, shard counts, timeouts, the Codecov upload condition, and the merge queue (#4986, a repository-settings action no PR can perform).Generated by Claude Code