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

@github-actions github-actions Bot added the size/l label Aug 9, 2026
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 0bffdae Aug 9, 2026
21 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-7005-diff-scope-no-major branch August 9, 2026 15:39
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…6984) (objectstack-ai#7104)

`scan()` 原本只枚举 `git ls-files`,也就是索引。一个刚写出来、还没
`git add` 的文件根本不在扫描集里,门禁照样 exit 0 并打印它惯常的成功
行 —— 读起来是「这棵树没有裸控制字节」,而不是「你刚写的那个文件没被
看过」。

这个洞的形状恰好等于本门禁自己的事故源:每一例裸字节都来自编辑工具在
作者「正在写关于这个字节的内容」时把转义落成真字节(objectstack-ai#4763 / objectstack-ai#4890 /
PR objectstack-ai#5140 / objectstack-ai#5460 的 issue 正文),而落点必然是一个**新文件**。于是
agent 指令要求的那次「push 前跑一遍」,正是最可能在看一个索引枚举够不
到的文件的那次运行。objectstack-ai#6984 实测过:一个未跟踪文件里躺着裸 0x1b,
AGENTS.md 的 self-scan 找得到,本门禁绿,唯一的差别是索引。

- 扫描集改为「索引 + `--others --exclude-standard`」,即 git 既不跟踪
  也不忽略的工作区文件。
- 成功行改为同时报出两半:`scanned N text file(s) -- N tracked,
  M untracked-not-ignored`。M 为 0 时**照样打印**,否则 CI 的绿和
  objectstack-ai#6984 之前那种绿会渲染成同一句话,而后者正是本单要消掉的假信心。
- 未跟踪的命中在报错行上标 `[untracked]`——「还没 add」是作者拿到
  一个被拒文件后第一个需要知道的事实。
- `--list` 逐条列出未跟踪的那一半路径。

三项实测,不是断言:

1. `--exclude-standard` 让本地垃圾留在外面。本仓库在完整 `pnpm install`
   之后,未跟踪路径 76122 条,过 `--exclude-standard` 之后 0 条;它是剪
   枝而不是遍历,16ms vs 索引枚举的 5ms。EXCLUDED 仍叠加在上面,所以有
   人把 node_modules 取消忽略也点不红。
2. CI 里是 no-op:workflow 检出的是一个 commit,全树受跟踪,未跟踪那一
   半恒为空。`--self-test` 用「把样本 stage 掉再扫一遍」证明,而不是在
   注释里声称。
3. 反向:回退 `enumerate()` 的未跟踪那一半,6 条新断言立刻转红。

`--self-test` 断言数 48 → 75。临时仓库额外把 `core.excludesFile` 指向
一个空文件,否则开发者的全局 ignore 会让「EXCLUDED 仍然生效」那条样本
因为另一个原因通过。

工具链改动,不涉及任何 package,不发版 —— 走 `skip-changeset`,与近期
scripts/ + workflow-only PR 的做法一致(objectstack-ai#7048 / objectstack-ai#7008 / objectstack-ai#6983 / objectstack-ai#6917)。


Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…tack-ai#7045) (objectstack-ai#7106)

`check-empty-changeset.mjs` and `check-adr-0087-registration.mjs` both enumerated
the diff with `--diff-filter=AM`. Git's rename detection is on by default, so a
changeset renamed and modified in one commit reports as `R` and both filters
dropped the row entirely: an emptied changeset, or a newly declared-breaking one
with no ADR-0087 disposition, walked past its gate unseen.

`--diff-filter=AMR`, reading the branch-point side at the PRE-RENAME path (field 2
of an `R` row, not field 3) — the pattern `check-changeset-no-major.mjs` landed
first in PR objectstack-ai#7048 for the third member of the family. A pure rename compares equal
and stays exempt, so moving a stock changeset still costs nothing.

One thing the reference did not need: the base-side read is guarded by
`isChangesetFile(basePath)`. Git pairs renames by CONTENT, so an `R` row can
arrive as `.changeset/README.md -> .changeset/x.md`, and README declares nothing
by definition — inheriting an exemption from it would have been free.


Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…ough renameRow (objectstack-ai#7342)

Fixes objectstack-ai#7164

The rename-row control PR objectstack-ai#7048 added asserted only /^R\d/.test(raw.trim()) —
that the diff output begins with an R row, not which two paths git paired.
PR objectstack-ai#7157 brought the family's renameRow helper (whole-row match, pinning both
paths) into this file for its own case; this control was the one spelling
left behind. Re-spelled through renameRow(dir, base, '.changeset/old.md',
'.changeset/new.md'), matching the shape check-empty-changeset.mjs and
check-adr-0087-registration.mjs already use, and removed the now-unused raw
binding. No behaviour change.

Co-authored-by: Claude <noreply@anthropic.com>
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