Skip to content

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 into
mainfrom
claude/issue-8892-ga-release-page-status-gate
Aug 16, 2026
Merged

gate: fail when a GA'd major's release page still describes itself as a pre-release (v16+)#8950
os-project-manager merged 1 commit into
mainfrom
claude/issue-8892-ga-release-page-status-gate

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes#8892

check:release-notes proves 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 on origin/main and the gate is green on today's main with nothing to fix first. The releases directory is read-only input to this gate.


Zone 1 — the ruled constraints, and where each is discharged

RulingWhere it lands
Scope is v16+SCOPE_FLOOR_MAJOR = 16, a plain >= with no exception set
State the cutoff in the gate's own failure text, with the reasonSCOPE_NOTE is printed on the failure path and the OK path both, and carries the maintainer ruling verbatim and untranslated
Nothing baselinedNo baseline file, no exception list, no --update path. The self-test asserts the cutoff is hole-free across v6..v40 — a cutoff with holes is a baseline wearing a cutoff's name
Do not touch content/docs/releases/Not touched. Verified byte-identical after every ablation (sha256 + empty git status)
Prove it by ablation, not by a green runFour ablations against the real historical pages, plus two meta-ablations proving the self-test itself can fail. Below

The cutoff as it prints on both paths:

Scope: v16 and later ONLY. Release pages for v15 and earlier are deliberately NOT checked — maintainer ruling 2026-08-15, quoted verbatim and untranslated: 「只负责 v16 以后的,需要的话之前的版本删除也可以的」, confirming the boundary with 「v14 也不用管」. Older pages being stale is an ACCEPTED state, not a tolerated defect: this gate has NO baseline and NO exception list, and the clean cutoff is what keeps those two things apart. If an older page's status ever starts to matter, rewrite or delete that page — do not add an exception here, and do not move the v16 floor without a new ruling.


The two hazards, both neutralized structurally rather than carefully

Hazard 1 — sort -V ranks a prerelease above its release

Avoided 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.6 is not a GA heading wherever it appears.

Pinned in --self-test by feeding the same headings in both orders and demanding the same answer. In the order where a sort -V | tail -1 gate would pick 17.0.0-rc.6 as "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:

$ git tag --list '@objectstack/spec@17*'
@objectstack/spec@17.0.0-rc.0 rc.1 rc.2 rc.5 rc.6 # no 17.0.0, no rc.3, no rc.4
$ git ls-remote --tags origin 'refs/tags/@objectstack/spec@17*'
... @objectstack/spec@17.0.0 # it exists on the remote

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 against packages/spec/package.json. Both are in the tree, so the reading cannot vary with checkout depth or network reachability, and the CHANGELOG is what changeset version writes at release time — the changesets source of truth, and already the source the sibling check-release-notes.mjs reads.

Tags are still used, but wired so their unreliability cannot produce a verdict:

  • a tag that exists proves that release shipped, so a CHANGELOG parse that missed it is wrong — that direction can add a problem;
  • a tag that is absent proves nothing, so an empty or short tag list is silently accepted and never concludes "nothing shipped".

Three further instrument guards, each on "the reading is not measuring what it appears to measure":

  1. an empty CHANGELOG parse is reported as a broken instrument, never accepted as "nothing shipped";
  2. if packages/spec/package.json is at a GA version, its major must appear in the parsed GA set — two in-tree sources disagreeing means the parse is wrong;
  3. a run that finds zero in-scope majors fails, because a gate that silently checks nothing is worse than no gate.

Guard 2 is asserted in one direction only, deliberately. During a changeset pre window the package sits at 18.0.0-rc.0 while 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-tags does not depend on checkout depth, but it puts a network round-trip inside a lint gate. This repo's check:* 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.mdx carries hundreds of legitimate rc.N mentions. 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.

  1. 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 exit ran with the cut" where the stale one said "Until changeset pre exit". Mentioning an rc version is never itself a signal.

  2. 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, or released / 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.mjs

The 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.0 yields 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 moment 18.0.0-rc.0 publishes, inverting the defect.
  • The two gates also have different floors and different exception shapes (FLOOR_MAJOR = 9 plus a KNOWN_MISSING set 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.mjs is 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 + empty git status) before the next.

#AblationResult
Av17.mdx at the pre-fix refRED, exit 1, 6 problems — 5 phrase signals (preparing-to-ship, latest-published-prerelease, nothing-reaches-latest-tag, until-pre-exit, ships-through-pre-mode) + the missing released-assertion. v16 and the index untouched
Bv16.mdx at the pre-fix refRED, exit 1, 2 problems — currently-published-as plus, independently, the missing released-assertion
Cindex.mdx at the pre-fix ref, pages left correctRED, exit 1, 3 problems — v16 prerelease-version, v17 prerelease-word and prerelease-version
Dthe whole content/docs/releases/ directory as it stood on 2026-08-13, when both stale pages were live and CI was greenRED, exit 1, 11 problems, all confined to v16.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.mdx said "published at 9.0.0" (tag 9.11.0), v12.mdx said "published at 12.0.0" (tag 12.6.0) and v14.mdx said "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:

  • defanged the preparing-to-ship signal ⇒ self-test RED, exit 1, on exactly the two page/RED assertions that name it. The catching half discriminates.
  • added a naive "any rc.N mention" signal — the false positive the header warns against ⇒ self-test RED, exit 1, on all three page/GREEN and 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 at 36dfb441f, the final commit, on a clean tree — all exit 0, judged by exit status:

check:nul-bytes PASS (exit 0)
check:node-version PASS (exit 0)
check:required-contexts PASS (exit 0)
check:shard-attestation PASS (exit 0)
check:workflow-status-functions PASS (exit 0)
check:type-check-coverage PASS (exit 0)
check:release-notes PASS (exit 0)
check:release-page-status PASS (exit 0)
check:pm-dispatch-gates PASS (exit 0)

check:release-notes is 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-debt was derived (via the lint.yml path literal) and not run: its --re-measure spawns tsc across the workspace, and this diff adds no package, touches no tsconfig, no package typecheck script 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 a scripts/ gate, a package.json script entry and a lint.yml step — 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 to check:release-notes in package.json and next to the "Release-notes drift guard" step in lint.yml.

scripts/ and workflows are not published package sources, so this carries no changeset and is labelled skip-changeset.

Generated by Claude Code


Generated by Claude Code

…#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
@vercel

vercelBot commented Aug 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 16, 2026 2:29am

Request Review

@github-actionsgithub-actionsBot added size/l ci/cd dependencies Pull requests that update a dependency file labels Aug 16, 2026
@os-project-manageros-project-manager added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 16, 2026 — with Claude
@os-project-manager
os-project-manager marked this pull request as ready for review August 16, 2026 02:38
@os-project-manager
os-project-manager added this pull request to the merge queueAug 16, 2026
Merged via the queue into main with commit 0d0cf1aAug 16, 2026
30 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-8892-ga-release-page-status-gate branch August 16, 2026 02:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant

@os-project-manager