Uh oh!
There was an error while loading. Please reload this page.
ci(changeset): report changesets a change modified but did not add - #6435
Merged
Conversation
A hand-picked `changesets`-style filename can land on one that already exists, and the overwrite is silent in both directions that should catch it: `git status` reports ` M` rather than `??`, so it reads as your own new file landing, and a deleted release declaration is flagged by nothing downstream. The cost lands on a third party — whichever earlier pull request's declaration disappears — and surfaces only when a package fails to bump. New `scripts/check-changeset-overwrite.mjs`, run by a second job in `changeset-guard.yml` (its own checkout at `fetch-depth: 0`, since this one reads a diff). It names every `.changeset/*.md` the change modified or deleted without having added it, prints the declaration that was there and what is now gone from it, and recognizes the release emptying the queue as the non-finding it is. REPORT-ONLY, measured rather than cautious: across all 5281 first-parent commits on `main`, 12 commits modified a pre-existing changeset (19 files) and all 19 were legitimate — bump levels corrected when the pending release line changed, prose corrections, authors amending their own unreleased changeset. Blocking would have failed every one. `OS_CHANGESET_OVERWRITE_ENFORCE=1` flips it for whoever revisits that with a new measurement. The resolver, `git diff` wrapper and frontmatter reader are imported from `check-changeset-presence.mjs` rather than copied a third time; the second copy of that resolver inherited a real defect and had to be fixed to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe
This was referenced Aug 26, 2026
os-warren
marked this pull request as ready for review
August 26, 2026 00:42
Uh oh!
There was an error while loading. Please reload this page.
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#6336
A hand-picked
changesets-style filename can land on one that already exists, and the overwrite is silent. This adds a report-only gate that names it.Why this is worth a gate rather than a guideline
The cost lands on a THIRD PARTY and is invisible at the time it happens. The agent that picks a colliding name loses nothing; whichever earlier PR's release declaration vanishes pays, and only discovers it when a package silently fails to bump. Both signals that should catch it fail:
git statusshowsM, not??. An agent checking "did my new file appear" sees a modification and reads it as its own write landing. Nothing says you replaced someone else's file.The base rate is real: 424 accumulated changeset files against an
adjective-animal-verbname space.⭐ What actually caught the near-miss on #6165 was neither a gate nor a test:
check-changeset-presencereported "0 changeset(s) added", and the dev read that gate's own detection logic instead of assuming the gate was wrong. That thin thread is what this PR replaces.⭐ The historical measurement — it chose report-only over blocking
Triage ruled "report-only first if the population has legitimate modify cases, and measure that before choosing." Measured over all 5281 first-parent commits on
main(2026-01-13 .. 2026-08-25, full unshallowed history),git log --first-parent --diff-filter=MD -- ':(glob).changeset/*.md':Every one of the 19 modifications was legitimate:
c3194207d"the pending release is 17.2.0, not 18.0.0" (4 filesmajortominor),8f82c6120"pending changesets stay minor" (5 files),9dc88f7d9majortopatchafter review,e7ab048d7minortopatch;e222b24c7"eleven" to "ten" locale packs,606105310a typo'd package name@objectstack/consolecorrected to@object-ui/console;8c0d52ece,d22ae31ce,bebaebd39,cbfbeeab8,8feed91c9,cc4ca459d.⇒ The guard's premise — a PR modifying a pre-existing changeset is almost always a mistake — is not true of this repository's history, 19 for 19 against. A blocking gate would have failed every one of those PRs. Hence report-only, per the ruling.
OS_CHANGESET_OVERWRITE_ENFORCE=1flips it, so revisiting it is a decision rather than a rewrite.The 88 deletions are dominated by the release itself: 82 delete changesets alongside a package
CHANGELOG.md, which ischangeset versionemptying the queue. The gate recognizes that shape and reports it as the non-finding it is. The 5 hand deletions are supersessions (4 delete a declaration and add a replacement in the same commit) plus914c3900e"删除 slow-masks-unite.md", a bare deletion of@object-ui/console: minor.Control terms — every zero-hit and every count was controlled
--diff-filter=Aon':(glob).changeset/*.md'--first-parentdiff plumbing sees changesets at all--diff-filter=Monpackage.jsonMfilter is not silently empty--diff-filter=D, stringchore: release packages.changeset/README.mdin theMDsweepNone of the counts above is a bare zero, and the one structural zero (README) is proven by the same sweep that returns 1663 non-zero file hits.
⭐ A narrower signal the history does not contradict
Of those 19 legitimate modifications, 18 keep every package NAME they declared at base — a bump level changes, prose changes, names do not disappear. The single exception dropped
@objectstack/console, a name that resolves to no package in this workspace, because that was the typo being fixed. An overwrite is the opposite shape: the previous declaration's names are simply gone. The gate therefore reports lost declarations separately and loudly. Deliberately not built into a blocking rule today — one explainable hit is not a measurement, and the ruling was report-only first.What ships
scripts/check-changeset-overwrite.mjs— reports every.changeset/*.mdthe change modified or deleted without having added it.baseis the merge base, so "pre-existing" needs no ownership bookkeeping: a changeset added and then edited on the same branch is anA, never anM. It prints the declaration that was there, what the file declares now, and what is gone from it.changeset-guard.ymlgains a second job,Changeset Overwrite Report. A second job rather than a step inno-majorbecause it reads a diff and so needsfetch-depth: 0, which that job does not want. A path filter is normally a false-negative surface; it cannot be here, because a change that modifies or deletes a.changeset/*.mdtouches.changeset/**by definition.changeset-guard.ymlis not in its ownpaths:filter, so a change to that gate is never exercised by the PR that makes it — 1 of 2 exceptions to a 5-of-7 convention #6321's convention):scripts/check-changeset-overwrite.mjsis listed in bothpaths:filters alongside the workflow's own YAML, so the PR that changes the gate is the PR that runs it. Pinned by a test.scripts/dependabot-merge-gate.mjsclassifies the new check name intoNOT_A_GATE— caught bydependabot-merge-gate.test.ts, which refuses to let a new check default into silence.content/docs/guide/ci-cd-pipeline.md— inventory row, the verbatimpaths:quote, and a new section for the second job.git diffwrapper and frontmatter reader are imported fromcheck-changeset-presence.mjsrather than becoming a third copy. The second copy of that resolver (incheck-i18n-en-drift.mjs) inherited a real defect from its first draft and had to be fixed to match under check-i18n-en-drift 的显式--base/OS_I18N_DRIFT_BASE解析不出时会静默跌落到别的提交,拿错基准比对并报绿 #3766.Two-direction proof
RED direction — the exact #6165 shape planted on the real repository: a heredoc onto
.changeset/olive-donkeys-smile.md, which still carries@object-ui/plugin-charts: minoronmain. Mutation confirmed on disk before measuring (injected marker count 1, overwritten marker count 0, blobe643a315e…becomingb5d3fc008…).Report-only run,
EXIT=0, the message a reader actually gets:Under
OS_CHANGESET_OVERWRITE_ENFORCE=1the same input gives❌andEXIT=1.Restore proved, not assumed —
git checkout HEAD --pinned to the file's absolute path, undertrap … EXIT INT TERM: the restored blob hashes toe643a315e7aea250f5d6ffeef80a96d348061b3d, identical to the HEAD blob, andgit diff HEADfor that path is empty.⭐ NEGATIVE direction — a change that only ADDS a changeset stays green, and green because the gate looked:
EXIT=0both plain and under enforcement. That is this PR itself.git diffnever saw it and the gate reported "0 added". Fixed by counting untracked changesets into the added set (parity withcheck-changeset-presence, which goes out of its way to do the same) and by asserting the "1 changeset(s) added" line, so a green that comes from looking at nothing fails.Ablation — blinding the gate to modifications (the
Mlisting replaced by an empty array) turns 5 of 21 tests red; restoring returns 21/21 green. Mutation and restore both proved bygit hash-object:cbb92c408…to759cc6747…and back tocbb92c408…, plus marker counts on disk before either measurement. No build step is involved — the gate is a plain.mjsexecuted from source by bothnodeand vitest, so there is nodist/for a stale artifact to hide in.Verification
Union run at
ebc678d57(the final commit):npx vitest run --project unit scripts/__tests__— 82 files / 2338 tests passed. Run whole, per the warning thatci-cd-pipeline-doc.test.tspins that page against.github/workflows/in both directions; it is also what caught thedependabot-merge-gatecoupling above.node scripts/check-control-bytes.mjs—✅ OK (scanned 5322 tracked text file(s))(5319 before the commit; the three new files are covered).node scripts/check-entry-guard.mjs—✓ 50 scripts/ file(s) … 45 export bindings, 45 of them inert on import.node scripts/check-changeset-presence.mjs,check-changeset-no-major.mjs,check-changeset-fixed.mjs,check-doc-links.mjs,check-doc-fence-languages.mjs,check-doc-component-types.mjs,check-docs-route-eager-closure.mjs,check-lint-coverage.mjs— all exit 0 on their own verdict lines.npx eslinton the three changed code files — exit 0.npx tsc -p tsconfig.scripts.json --noEmit— exit 0, and--listFilesconfirms it actually includes the new test file rather than excluding it.⛔ Out of scope by ruling: direction 2 from the card — documenting the convention in
AGENTS.md— is not touched here.AGENTS.mdis governed surface and triage ruled the gate. The convention is documented incontent/docs/guide/ci-cd-pipeline.mdand in the gate's own failure message instead.Generated by Claude Code