Uh oh!
There was an error while loading. Please reload this page.
gate: fail when a GA'd major's release page still describes itself as a pre-release (v16+) - #8950
Merged
os-project-manager merged 1 commit intoAug 16, 2026
Conversation
…#8892) check-release-notes proves a released major HAS a page; nothing proved the page says the release happened. Both of the last two GA cuts shipped a page still describing itself as a pre-release (v17 for a day, v16 for three weeks) with CI fully green each time. New gate check:release-page-status asserts, for every GA'd major from v16 on, that the release-status blockquote and the releases index entry both describe a shipped release. Scope cutoff and its reason are printed on the OK path and the failure path, so the limitation is legible without reading the source. The instrument is packages/spec/CHANGELOG.md cross-checked against packages/spec/package.json — both in-tree, so the reading cannot vary with checkout depth. Git tags corroborate in the one direction that cannot be wrong (a tag that exists proves a release shipped; an absent tag proves nothing), because a tag list silently under-reports in an incomplete clone. No version ordering anywhere: `sort -V` ranks a prerelease above its release, so the gate asks set membership only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RB4waLuNbdruCo6X9oobm
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
os-project-manager
marked this pull request as ready for review
August 16, 2026 02:38
Uh oh!
There was an error while loading. Please reload this page.
os-project-manager
deleted the
claude/issue-8892-ga-release-page-status-gate
branch
August 16, 2026 02:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#8892
check:release-notesproves a released major has a page. Nothing proved the page says the release happened. That gap produced the same defect on both of the last two GA cuts, with CI fully green each time, and both were found by a human reading the page.This adds
check:release-page-status— a new gate asserting that for every GA'd major from v16 on, the release page's status blockquote and the releases index entry both describe a shipped release.No
content/docs/releases/file is edited by this PR. Both in-scope pages were re-verified correct onorigin/mainand the gate is green on today'smainwith nothing to fix first. The releases directory is read-only input to this gate.Zone 1 — the ruled constraints, and where each is discharged
SCOPE_FLOOR_MAJOR = 16, a plain>=with no exception setSCOPE_NOTEis printed on the failure path and the OK path both, and carries the maintainer ruling verbatim and untranslated--updatepath. The self-test asserts the cutoff is hole-free across v6..v40 — a cutoff with holes is a baseline wearing a cutoff's namecontent/docs/releases/git status)The cutoff as it prints on both paths:
The two hazards, both neutralized structurally rather than carefully
Hazard 1 —
sort -Vranks a prerelease above its releaseAvoided by construction, not by care: the gate never asks which version is latest. The only question it asks is set membership — "does a GA release exist for major N?" — so there is no ordering surface for the trap to live on. The prerelease exclusion is an end-anchored heading match, not a comparison:
## 17.0.0-rc.6is not a GA heading wherever it appears.Pinned in
--self-testby feeding the same headings in both orders and demanding the same answer. In the order where asort -V | tail -1gate would pick17.0.0-rc.6as "latest", this gate still returns GA for 17.Hazard 2 — an incomplete clone under-reports tags, silently
Reproduced independently in this worktree's clone while building the gate:
A tag-sourced gate in this checkout concludes v17 never went GA and reds a correct page.
The instrument is therefore
packages/spec/CHANGELOG.md, cross-checked againstpackages/spec/package.json. Both are in the tree, so the reading cannot vary with checkout depth or network reachability, and the CHANGELOG is whatchangeset versionwrites at release time — the changesets source of truth, and already the source the siblingcheck-release-notes.mjsreads.Tags are still used, but wired so their unreliability cannot produce a verdict:
Three further instrument guards, each on "the reading is not measuring what it appears to measure":
packages/spec/package.jsonis at a GA version, its major must appear in the parsed GA set — two in-tree sources disagreeing means the parse is wrong;Guard 2 is asserted in one direction only, deliberately. During a
changeset prewindow the package sits at18.0.0-rc.0while the GA set correctly stops at 17, and a pre window on a minor (18.1.0-rc.0) coexists with a GA 18 in the set. Neither is a contradiction, so neither is asserted — the reverse direction would red the whole pre window. Both non-cases are pinned in the self-test.This is visible on this very PR's local run, which prints
corroborated by 1 local GA tag(s)— 16, not 17, because this clone lacks the 17.0.0 tag — and still passes v17 correctly.npm dist-tags, considered and rejected
npm view @objectstack/spec dist-tagsdoes not depend on checkout depth, but it puts a network round-trip inside a lint gate. This repo'scheck:*family is offline by construction, and a gate that reds when a runner cannot reach the registry teaches people to ignore it. The in-tree pair gives the same independence with no network.Zone 2 — what counts as "the page says pre-release"
Scanning discipline.
v17.mdxcarries hundreds of legitimaterc.Nmentions. The scan is scoped to the first blockquote after the frontmatter (where both v16 and v17 put the claim) plus the single index line for that major — its trailing status parenthetical when it has one, so mid-sentence prose like "(21 dead methods out, 40+ real ones in)" is never mistaken for status. Blockquote lines are joined before matching, because the real stale v17 status broke "preparing to ship" across a line break.Two rules, deliberately different in kind.
A phrase list of present-tense claims of unreleasedness:
preparing to ship,is/are currently published as,the latest published pre-release is,nothing reaches the latest tag,Until changeset pre exit,ships through Changesets pre-mode. Every one is keyed so the corrected wording that replaced it does not match — the v16 fix says "no longer publish as" where the stale one said "is currently published as"; the v17 fix says "changeset pre exitran with the cut" where the stale one said "Untilchangeset pre exit". Mentioning an rc version is never itself a signal.A positive released-assertion: the blockquote must state that a version of that major is released, in a small named family of forms (
is/was/has been+released/published/generally available/GA/shipped, orreleased/published/shipped on). This is the half that catches a novel stale wording — the phrase list alone only ever catches the two shapes that already shipped, which is how this defect recurred. The accepted forms are printed in the failure message, so an author reworded into a red build is told what to write instead of guessing.This is the contract-first choice: a GA'd page must make a machine-checkable released-claim rather than the gate tolerating whatever prose arrives. It is also the reason a page with no status blockquote at all fails rather than passing vacuously — "nothing to scan" is the quietest way for a status gate to certify nothing.
The index rule mirrors it: the status field must not label the series a pre-release, nor present a prerelease version of that major as the status. It correctly tells
(final release: 16.1.0)apart from(current series: 17.0.0-rc.N, pre-release).New script rather than extending
check-release-notes.mjsThe issue left this to the implementer. Split, because the two gates need contradictory predicates on the same file:
releasedMajors()is unanchored, so## 17.0.0-rc.0yields major 17. That is correct for its question — a page should exist during the RC window too. Reusing it here would demand that v18's page claim to be released the moment18.0.0-rc.0publishes, inverting the defect.FLOOR_MAJOR = 9plus aKNOWN_MISSINGset for backfill, versus a hole-free v16 cutoff with nothing exempted). Merging them puts two major-sets and two exception policies in one script, where the next reader has to work out which applies to which assertion.Extracting a shared
gaMajors()helper was considered and rejected for the same reason: the predicates are deliberately different, so a shared module would be a single name meaning two things. Each parse is stated where it is used, and the new one's header says explicitly what it does differently and why.check-release-notes.mjsis left untouched.Ablation — the gate seen failing, four times, against the real defect
Every ablation reverts a real historical file out of git history, not a synthetic fixture. Each was restored with
git checkout HEAD --and verified byte-identical (sha256 + emptygit status) before the next.v17.mdxat the pre-fix refpreparing-to-ship,latest-published-prerelease,nothing-reaches-latest-tag,until-pre-exit,ships-through-pre-mode) + the missing released-assertion. v16 and the index untouchedv16.mdxat the pre-fix refcurrently-published-asplus, independently, the missing released-assertionindex.mdxat the pre-fix ref, pages left correctprerelease-version, v17prerelease-wordandprerelease-versioncontent/docs/releases/directory as it stood on 2026-08-13, when both stale pages were live and CI was greenv16.mdx(2),v17.mdx(6),index.mdx(3)Ablation D is also the proof that the cutoff is real rather than decorative: at that ref
v9.mdxsaid "published at 9.0.0" (tag 9.11.0),v12.mdxsaid "published at 12.0.0" (tag 12.6.0) andv14.mdxsaid "current: 14.5.0" (tag 14.8.0) — all measurably stale, all correctly unreported. Zero out-of-scope pages were swept in, and equally, none was baselined.Meta-ablation — the self-test itself proven able to fail
A self-test that cannot fail is the same defect one layer up, so both halves were checked by mutating the committed script and restoring it:
preparing-to-shipsignal ⇒ self-test RED, exit 1, on exactly the twopage/REDassertions that name it. The catching half discriminates.rc.Nmention" signal — the false positive the header warns against ⇒ self-test RED, exit 1, on all threepage/GREENand scan-scope assertions, and the live run went red on the correct v16 page. The not-catching half discriminates too.A first attempt at a meta-ablation was a no-op and is worth recording: changing the blockquote line-join separator changed nothing, because the following whitespace collapse re-joins regardless. Reported as a no-op rather than as a pass — the mutation never reached the assertion, so it proved nothing, and the two above were run instead.
Gates
Derived from the real changed paths with
node scripts/pm/dispatch-gates.mjs, then run. Union taken at36dfb441f, the final commit, on a clean tree — all exit 0, judged by exit status:check:release-notesis not in the derived set; it was run anyway because this PR reasons about its predicate, to confirm the sibling gate is unaffected.check:type-check-debtwas derived (via thelint.ymlpath literal) and not run: its--re-measurespawnstscacross the workspace, and this diff adds no package, touches notsconfig, no packagetypecheckscript and no ledger entry, so it cannot move a raw error count. The read-only half,check:type-check-coverage, passed. That one is CI's to run.A control-character self-scan over the three changed files is clean beyond
check:nul-bytes.Merge note
Branched from
a8189aef4. PR #8945 is queued and also adds ascripts/gate, apackage.jsonscript entry and alint.ymlstep — a different script file, the same two shared files. If it merges first, both entries must survive the resolution; neither side's entry may be dropped. This PR's hunks sit next tocheck:release-notesinpackage.jsonand next to the "Release-notes drift guard" step inlint.yml.scripts/and workflows are not published package sources, so this carries no changeset and is labelledskip-changeset.Generated by Claude Code
Generated by Claude Code