Uh oh!
There was an error while loading. Please reload this page.
feat(tooling): guard release section coverage and index currency - #10802
Conversation
) check-release-page-status is keyed on the MAJOR, so any 17.x.y released-claim satisfies it and a page saying "17.0.0 is released" passes forever however many 17.x minors ship after it. Reconstructed on this base, that gate exits 0 on the exact #10232 state. Adds check-release-section-coverage.mjs: every published <major>.<minor>.0 must have a heading naming it on its major's page, and the index entry's trailing parenthetical must name the newest release of that major. Findings are advisory (::warning:: + step summary, job green); a broken instrument is always fatal. Advisory is measured, not timid: hard-failing would have red 2748 PRs across the two real gaps (17.1.0 5h44m/36 PRs, 16.1.0 25 days/2712 PRs). Floor inherited from the sibling gate and pinned by self-test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
✅ ACCEPT — reviewer of record: |
| instrument | findings | strict | exit |
|---|---|---|---|
| ok | some | no | 0 — advisory |
| ok | some | yes | 1 |
| broken | none | no | 1 ⭐ a broken instrument fails even in advisory mode |
| ok | none | either | 0 |
That third row is the one that makes the advisory default honest rather than timid, and it is a fixture, not a sentence. The measurement behind the default is the right kind too — 17.1.0 ran 5h44m, 16.1.0 ran 25 days, so hard-failing would have red 2748 PRs for a debt none of them created. Timed from git rather than estimated.
Also correct: isEntrypoint(import.meta.url) rather than a hand-typed import.meta.url === pathToFileURL(process.argv[1]).href guard. That hand-typed shape is a known trap in this repo and was fixed on PR #10777 this week.
⚠️ One gap, NOT blocking, recorded so it is not re-derived
This gate is not nameable by dispatch derivation for its own population. I extracted every path literal from the added source and applied hintCovers' rule (scripts/pm/dispatch-gates.mjs:920) by hand:
- nameable:
packages/spec/CHANGELOG.md,docs/releases-maintenance.md,scripts/check-release-page-status.mjs— all contain/; - its actual population — the release pages — appears only as the bare literal
v17.mdx: single segment, no/, not dot-prefixed ⇒ refused as too generic.
So an edit under content/docs/releases/ would not name this gate. That is the escapable-literal species tracked at #10705 (in flight now), and this is its prospective form — a gate that lands invisible on the day it ships, exactly like the check-plugin-teardown-shape.mjs case whose author only dodged it by knowing about the trap.
⭐ Why it does not block the merge: release pages are never edited in a code PR (CLAUDE.md — they are written centrally at release time), so the population this gate cannot be named for is one that essentially never appears in a dispatch's change set. The practical harm is close to zero; the shape is still worth a ledger row. I have passed the measurement to the #10705 agent with that caveat attached, so it lands as a qualified row rather than a straight one.
If you want it nameable anyway, the sanctioned escape is the subtree spelling (ROOT_DIR_WATCH_HINTS-style, as check-role-word.mjs uses for skills/**) — but do not loosen the :920 refusal itself; its docblock prices that at +139084 fabricated pairs.
Flipping ready and arming. .github/workflows/lint.yml — whichever lands second will need its branch updated. I am arming both; if the second conflicts I will merge base in and re-push rather than leave it sitting.
Generated by Claude Code
|
Fixes#10268
check:release-page-status(#8892) asserts that a GA'd major's page says it is released, but its predicate is keyed on the major:releasedAssertionRe(17)is satisfied by any17.x.yreleased-claim. A page saying "17.0.0 is released" therefore passes forever, however many 17.x minors ship after it. The index arm has the same shape — it rejects a prerelease parenthetical, never a superseded stable one.This is the third occurrence of the class: #8886/#8882 (v16 and v17 stale at the GA cut, which produced the existing gate), #8917 ("16.1.0 is documented on no page at all" — same defect, previous major, produced no gate), #10232 (17.1.0 published with a 69-package train and no section).
The defect, reproduced on the base
The pre-#10232 v17 page and index, taken from real history (
fdde7b2024^), against today's CHANGELOG (which carries## 17.1.0). The page had no17.1heading of any level; the index read(current series: 17.0.0, released 2026-08-14):After, on that same tree:
And the inverse — today's real tree, which does carry both sections — stays green:
OK — 4 published minor(s) across 2 GA major(s) in scope (v16, v17).No prose is generated, and none can be
Curated release prose cannot be generated from changesets without losing what makes it curated — #10232's section took a full read of 69 package changelogs (314 entries) and judgement about what is user-facing. This gate detects the absence only, from artifacts already in the tree, and names the missing minor.
Severity: advisory, decided by measurement
The CHANGELOG heading lands in the version commit that publishes, so a hard failure starts the instant a minor ships and lasts until someone writes prose. Both real gaps, timed from git history:
A hard fail would have red 2748 PRs for a debt none of them created — and 16.1.0 is the ordinary case, not the pathological one. Every hard-fail variant collapses to that same number: a
latest-tag trigger fires at the identical instant (the tag moves in the same commit); a shrink-only ledger seeded with today's uncovered minors is seeded empty (measured below), so it degenerates to a plain hard fail on the next minor; a grace window is time-dependent and not reproducible in CI.So findings are reported and the run exits 0. That follows this repo's own precedent rather than inventing one —
half-state-patrol.yml: "Findings never fail anything … The job DOES fail when the sweep could not run", because a half-state "is a fact about a live shared board, not about whichever PR happens to run CI next." An uncovered minor is a fact about the release history, same shape. The loud-but-non-blocking mechanism (::warning::+ step summary, job green) isprerelease-pin-watch.yml's, and--strictpromotes findings to exit 1 for a caller that owns the remedy — the same escape hatch that workflow's own--strictprovides.What is fatal: the instrument. An empty or unreadable CHANGELOG parse, or a parse yielding no in-scope GA major, exits 1 in every mode including advisory. Verified: a CHANGELOG containing only
## 17.0.0-rc.6exits 1 with no--strict.Corpus delta today (base
699132f259)In scope (v16+): zero. 4 published minors — 16.0.0, 16.1.0, 17.0.0, 17.1.0 — all covered; both index entries current. So this lands green with no baseline, no ledger and no exception list.
Below the floor, assertion 1 would fire on 24 minors — v9 0/12, v12 0/7, v13 0/1, v14 5/9 (
14.1.0,14.6.0,14.7.0,14.8.0) — and assertion 2 on three entries with no status parenthetical at all (v9, v12, v13). That answers the card's second unchecked item: the v16 floor is not a formality, it is what lets this ship unbaselined.The floor is inherited, and the inheritance is enforced
The sibling gate floors at v16 under the 2026-08-15 ruling. The self-test reads
scripts/check-release-page-status.mjsas text and pins the twoSCOPE_FLOOR_MAJORvalues equal, so moving one without the other fails here rather than drifting. (Text, not import: that script runs its whole CLI at module load — it is inKNOWN_IMPORT_UNSAFE.)docs/releases-maintenance.mdalready prescribes the step — the card's first unchecked itemIt does, with a caveat that shaped the severity. Section 3, "Cadence that scales with rapid iteration":
Folding a minor in is already the documented first option, so this gate enforces an existing step rather than inventing one — and that is now stated in the gate's header. But the trailing "or leave them to the generated per-package changelogs" is an explicit sanctioned alternative, which is independently why a finding here is a report and not a build failure: no gate should hard-fail a state its own process document permits.
Two version traps, both live in the real corpus
11.1.0and11.10.0(major 9 likewise). An unguarded\b11\.1reads the 11.10.0 heading as coverage of 11.1. The matcher end-guards the minor.sort -Vis not semver — it ranks a prerelease above its release, sosort -V | tail -1over17.0.0and17.0.0-rc.6answers17.0.0-rc.6. Assertion 2 needs a newest-version comparison (which the sibling gate avoided needing at all); it is a numeric triple compare over a GA-only set, so there is no prerelease left to mis-rank.An RC heading is also not coverage:
## Landed since 17.2.0-rc.0does not satisfy minor 17.2, or a minor that only ever appeared as a prerelease would read as documented.Duties not duplicated
Page and index existence are not re-asserted.
check:release-notesowns "a released major has a page" andcheck:release-page-statusowns "the page and index entry exist". Two gates reporting one fact means two reds for one fix; a missing page is noted and skipped.Verification
Self-test: 41 cases, at
2690cd2f48.Ablation, both new assertions, mutation confirmed on disk by anchor count (not by an editor's exit code):
coverageFindingsnever firesindexCurrencyFindingsnever firesRestored and proved byte-identical — worktree blob
23e631182cff2e851e6be7e63d8e6358c2d93496equals the committed blob,git status --porcelainempty.Gates re-derived with
node scripts/pm/dispatch-gates.mjsat head2690cd2f48(12 families; unchanged from the first derivation), all exit codes captured before any pipe:check:cross-package-test-inputs0 ·check:node-version0 ·check:required-contexts0 ·check:shard-attestation0 ·check:type-check-coverage0 ·check:workflow-status-functions0 ·check-aggregator-roster0 ·check-release-notes0 ·check-release-page-status0 ·check-release-section-coverage0 (+--self-test0) ·check:nul-bytes0.Run by hand because
dispatch-gatesis measurably blind to both (#10784):check:entry-guard0 — "128 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 86 export bindings, 76 of them inert on import (10 known-unsafe, SHRINK-ONLY)"; the new script usesisEntrypointand is import-safe, so it does not joinKNOWN_IMPORT_UNSAFE.check:parse-guard0 — "127 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs."Declared narrowing:
check:type-check-debtis--self-test && --re-measure. The self-test half passes;--re-measuredeliberately refuses without a built dependency closure (55 workspace packages) and this diff contains zero TypeScript — two files, one.mjsand one workflow — so the ledger cannot move. CI builds the closure before that step and runs it there.No changeset: gate tooling publishes nothing. No new
pnpm check:*alias — rootpackage.jsonis inside the #9465 fence, solint.ymlcallsnode scripts/…directly. No release page was edited.Generated by Claude Code
Generated by Claude Code