Skip to content

fix(ci): diff-scope the no-major changeset guard against the merge base (#7005) - #7048

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-7005-diff-scope-no-major
Aug 9, 2026
Merged

fix(ci): diff-scope the no-major changeset guard against the merge base (#7005)#7048
os-project-manager merged 2 commits into
mainfrom
claude/issue-7005-diff-scope-no-major

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #7005

What was wrong

scripts/check-changeset-no-major.mjs read the entire .changeset directory (readdirSync, no branch point) and failed if any pending changeset declared a major. Its verdict was therefore a function of what main carries, not of what the author wrote — #6129's direction reached by a different route: not a frozen base ref, but no base ref at all.

Its sibling check-empty-changeset.mjs has been diff-scoped since #6129 and carries the whole argument in a header note. This one never was, and nobody noticed because the enforcing half has never executed: the RC exemption stands the guard down for the entire pre-release window.

Re-measured on main before building (the card quoted d3e53f2d8):

card @ d3e53f2d8 this PR @ 6dd3c251b
pending .changeset/*.md 1552 1567
files declaring major 171 172
total major entries 222 223
.changeset/pre.json mode pre pre

The premise holds and has grown by one file. Pre-mode has not exited, so the situation is still pending rather than live — there is time, but the trigger is a scheduled event, not a hypothetical.

The sequence it was queued for: changeset pre exit rewrites the mode to "exit" (@changesets/pre@2.0.2, changesets-pre.cjs.js:117) and that commit lands on main. Pre-mode changeset version does not delete the changesets it consumes — it records them in pre.json.changesets (1279 of them) so the final release can re-apply them; only the post-exit changeset version deletes them. So from that commit until the Version PR merges, every unlabelled PR in the repo would have gone red listing 172 files it never touched. The only route out is the allow-major label, whose own error message says "a whole-stack major release is genuinely intended" — false for a PR fixing a typo.

What changed

Judge only what the diff introduces, starting at merge-base(base, head) — reusing the machinery check-empty-changeset.mjs already carries rather than inventing a second one. judge() stays pure and now takes scan()'s output instead of the stock; render() is unchanged in shape.

The rows, and the reason M is judged rather than skipped:

status at head at branch point verdict
A declares major absent offence
M declares major did not offence (majored in place)
M declares major same major exempt (stock, prose edited)
R declares major did not offence
R declares major same major exempt (stock, moved)
any no major ok

Row 2 removes the obvious bypass: editing a stock minor changeset's bump word to major introduces a brand-new whole-stack major, and --diff-filter=A alone sees nothing. Row 3 keeps the stock exempt when a PR legitimately edits an existing major changeset's prose.

The comparison is per package, not per file, so adding "@objectstack/cli": major to a changeset that already declared "@objectstack/spec": major is reported for @objectstack/cli alone. Naming only what the PR introduced is the whole point of the card.

The stock is exempt with no roster — "absent-or-non-major at the branch point" says it once, where a list of 172 names would be a high-water mark that rots on the first merge (the #5471 shape).

What this does not change: at changeset pre exit a whole-stack major really is intended, and the release's own Version PR carries it. That PR is exempt at the job level (changeset-check skips changeset-release/main), so the intended major still lands. What moves is who pays — the release, not the author of an unrelated PR.

The R divergence, measured

This file now uses --diff-filter=AMR; the two siblings use AM. Measured on git 2.43.0, renaming .changeset/old.md to .changeset/new.md while flipping its bump to major reports as R075 .changeset/old.md .changeset/new.md and is dropped entirely by AM. Reading the base side at the pre-rename path closes it and costs nothing, because a pure rename compares equal and stays exempt. Both directions are fixtured with a control asserting git really did report a rename.

The two siblings have the same hole in their own directions; filed as #7045 rather than fixed here, because their fixtures and messages are theirs.

The tradeoff, named rather than slipped in

This file used to have no dependency on git at all. It could run in a checkout with no history, before pnpm install. It now shells out to git merge-base, git diff and git show.

That is a real reduction in where it can run, and it is the price of the fix: "what this PR introduces" is a claim about one side of a fork, and there is no way to evaluate it without the fork. The sibling already pays exactly this cost for exactly this reason, and both run in the same job, on the same checkout, one step apart. Zero third-party dependencies still holdsnode:child_process and the git binary are not new requirements for that job.

A base that cannot be resolved, or that has no merge base with head, exits 1 rather than 0 (#4690).

Still unexecuted on CI, and that is stated rather than implied

.changeset/pre.json says "mode": "pre", so the real scan still takes the exemption branch and exits 0 on every run. The enforce verdict has never been produced by a CI invocation of this script and still is not after this PR. What it is, is fixtured — on every PR, in a job with no label exemption (check:changeset-gate-self-tests, lint.yml's ESLint job). Fixtured is not executed. #7008 set that precedent in this exact file and this PR keeps it.

Verification

--self-test: 101 assertions, up from #7008's 70 (both counts measured, not estimated), all green, run through pnpm check:changeset-gate-self-tests alongside both siblings.

The card's acceptance criterion, on the real 172-file stock — driven through the shipped scan()/judge()/render() with pre overridden in memory only (.changeset/pre.json is never touched, #6170):

REAL STOCK: 1567 changesets, 172 declaring major, 223 major entries.
REAL pre.json mode: "pre"

scan(base=origin/main, head=HEAD) -> merge base 6dd3c251b
  introduced: []
  exempt:     []
--- pre.mode=exit -> verdict=clean exit=0
    ✓ This diff introduces no `major` bump.

A PR introducing no major is green with all 172 on disk and pre-mode exited. The other direction, same stock, same run:

scan(base=origin/main, head=scratch-7005-red) -> merge base 6dd3c251b
  introduced: [{"file":".changeset/deliberate-major-7005.md","majors":["@objectstack/spec"]}]
--- pre.mode=exit -> verdict=enforce exit=1
    ⛔ This PR introduces changeset(s) that declare a `major` bump.
       .changeset/deliberate-major-7005.md
         - @objectstack/spec: major

One file named, out of 172 on disk. The fix narrows the gate; it does not disarm it.

Reverse verification — direction predicted before the run: RED, listing all 172, for a PR that introduces nothing. main's stock-scoped judge() was extracted and driven against the same tree:

OLD stock-scoped judge, pre.mode=exit, on a PR introducing nothing:
  verdict  : enforce
  offenders: 172 files
  exitCode : 1
    .changeset/action-descriptor-is-async-retired.md -> @objectstack/spec
    .changeset/action-location-dual-source-c17.md -> @objectstack/spec
    .changeset/action-param-option-visible-when.md -> @objectstack/spec

Confirmed in the predicted direction, and the mutation demonstrably applied: the same input yields 172 offenders on the old code and 0 on the new, so neither reading is a vacuous green. Every fixture follows the same discipline (check-required-contexts.mjs #6983, and #7008's own self-test): each negative states which positive it differs from, and the #7005 block asserts the real stock is non-empty before asserting a PR that introduces none of it is clean.

#6129 proper is fixtured on a real refs/pull/N/merge shape — a base branch that keeps moving after the fork, with two parents, because the defect lives in the difference between them. Three readings are asserted so the correction cannot be mistaken for a scan that finds nothing: from the merge base main's drift is invisible (0), from a frozen fork point it is blamed on this PR (1, the defect pinned), and the stock at HEAD contains it (1, which is exactly what the old gate read). The deletion half is fixtured too — the post-exit changeset version removing every consumed changeset at once is precisely the "base branch that DELETES" case.

An unrelated live instance turned up mid-task: origin/main moved from 08863dd18 to 6dd3c251b while this PR was being written, and git diff origin/main promptly attributed four files from #7039 to this branch. Rebased.

Pins flipped deliberately, not deleted

#7008 pinned current behaviour in several places. Two are now wrong on purpose:

  1. The workflow wiring. It required the bare node scripts/check-changeset-no-major.mjs. That spelling is the stock-scoped gate, so it is now the thing forbidden, and --base "$MERGE_BASE" is required. Three further assertions were added: exactly one --base call site, every one of them handed $MERGE_BASE and never a pinned sha, and the step must set MERGE_BASE in its own env:$MERGE_BASE is a shell variable, and a step that never receives it would scan against an empty base.

    That this is the only pin that moved was measured rather than assumed. main's 70-assertion self-test, dropped into this worktree so it resolves the real repo root, fails on exactly one assertion and it is that one:

    OLD SELF-TEST TOTAL: 70 assertions, 1 failing here
      • wiring: pr-automation.yml must still invoke the real scan
        (`node scripts/check-changeset-no-major.mjs`) — the self-test fixtures
        replace none of the enforcement
    

    69 of test(ci): fixture the no-major changeset guard and wire it into the exemption-free self-test step (#6923) #7008's 70 still hold unchanged.

  2. [finding] An unreadable .changeset/ makes check-changeset-no-major.mjs print a tick and exit 0 (#4690 shape) #7006, dissolved rather than fixed. The no-changeset-dir verdict rendered as exit 0 — a gate that could not read its input reporting "no violations", the check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690 shape, pinned by test(ci): fixture the no-major changeset guard and wire it into the exemption-free self-test step (#6923) #7008 and filed as [finding] An unreadable .changeset/ makes check-changeset-no-major.mjs print a tick and exit 0 (#4690 shape) #7006. Diff scoping removes the directory read from the enforcing path entirely, so there is no such verdict left to exit 0 from. Its replacement, unreadable-diff, exits 1. The assertion is flipped in place with a comment saying so, and readChangesets survives only to serve --list. Commented on [finding] An unreadable .changeset/ makes check-changeset-no-major.mjs print a tick and exit 0 (#4690 shape) #7006 rather than closing it — that call is triage's.

--list is new, and it is where the stock view went. During an RC window it is how a curator sees what has accumulated; that used to be a side effect of every PR run, emitting 172 ::notice lines on PRs that introduced none of them, well past GitHub's 10-annotation cap.

Gates run locally

  • pnpm check:changeset-gate-self-tests — green (65 + 107 + 101 assertions, all three family members)
  • pnpm lint (eslint . --no-inline-config, repo-wide) — clean
  • node scripts/check-nul-bytes.mjs — OK, 6498 files; plus a targeted control-byte self-scan of both changed files, clean
  • node scripts/check-workflow-status-functions.mjs — the third script that reads pr-automation.yml — green
  • All three CLI modes exercised on the real tree: the scan, --self-test, --list

No changeset

scripts/** and .github/workflows/** only — this releases nothing, so skip-changeset applies and no changeset is written. That is the route this gate's own sibling prescribes for exactly this shape of PR.

Coordination

scripts/check-changeset-no-major.mjs is also #7004's file (the family-wide trailing-comment regex). This PR deliberately does not touch majorPackagesIn or the assertion pinning that gap, so the two changes should merge mechanically. #7004 had no in-flight edits to the file when this was written. #6923 landed as #7008 first, so the serialization triage asked for is already resolved.


Generated by Claude Code

…se (#7005)

`check-changeset-no-major.mjs` read the whole `.changeset` directory with no
branch point, so its verdict was a function of what main carried rather than of
what the author wrote. Dormant while pre-mode holds; at `changeset pre exit` it
would have failed every unlabelled PR in the repo, each listing 172 pending
major changesets it never touched, with `allow-major` as the only route out.

Judge only what the diff introduces, starting at `merge-base(base, head)` —
the machinery `check-empty-changeset.mjs` already carries for #6129, reused
rather than reinvented. The pending stock is exempt with no roster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 3:13pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant