Skip to content

fix(scripts): refuse a PR that modifies or deletes a changeset it did not add (#17712) - #18146

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-17712-foreign-changeset-guard
Sep 14, 2026
Merged

claude[bot] merged 1 commit into
mainfrom
claude/issue-17712-foreign-changeset-guard

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Closes #17712

Clause-②: no — this card only makes a gate refuse MORE. It loosens no accept set and widens no published surface.

Implements item 1 (A′) of the ruling on #17712 (director seat, decision batch #130 item 3, 2026-09-13; maintainer's verbatim reply 「同意」). Item 2 (the ISSUE-SLUG.md naming line in governed dev-round guidance) is #17748's and is not touched here. Item 3 — legacy names untouched, diff shape only — is honoured: nothing in this diff reads a changeset filename.

The rule

A PR may not MODIFY or DELETE a .changeset/*.md that exists on the merge base and was not added by this PR. Refused by name, one remedy: rename yours; restore theirs from base.

"Added by this PR" is the file's absence on the merge base — which is exactly what git's status letters already say, so the rule needs no second reading and no filename predicate at all:

row meaning verdict
A absent at the merge base this PR's own file, always ok
M present at the merge base, changed here foreign, REFUSED
D present at the merge base, gone here foreign, REFUSED

Where it is wired, and why

Into scripts/check-empty-changeset.mjs, as a second axis answered in the same run — the shape check-changeset-no-major.mjs already uses for its two axes. The PR touches no workflow file at all, so it can be armed normally.

Three things that come for free from that placement, each of which a standalone script would have had to re-derive:

  1. The changeset-release/main exemption. changeset version DELETES every pending changeset, so the Version Packages PR is all-D rows. Outside changeset-check's job-level if:, this gate would be structurally unsatisfiable on every release PR — the chore: version packages (rc) #4422 / 发布 PR(changeset-release/main)常红两处 CI:Check Changeset 结构性必失败 + Scaffold E2E 在 RC 窗口协议大版本错配 #4894 shape, pointed at the release train.
  2. The merge base. The changeset-check job already derives MERGE_BASE and hands it to this script. That matters more for this rule than for the others: run two-dot against a moving base tip instead of the merge base and every changeset main gained while the PR sat open reports as a D on this branch — an author refused by name for deleting files they never touched. The self-test pins that false red as a firing control beside the real reading.
  3. A self-test that cannot be skipped. check-empty-changeset.mjs --self-test runs unconditionally in lint.yml (观察单:changeset 家族三个 check-*.mjs 的 --self-test 只在可被 skip-changeset 整体豁免的 job 里跑,改这些脚本的 PR 恰好豁免自己的自检 #6509), outside the skip-changeset exemption — which is where this gate's own fixtures need to be, since a PR editing a CI-internal script is the textbook skip-changeset case.

The file name still names rule 1 only. Renaming it would mean editing the workflow steps that spawn it; the header says so rather than leaving it to be noticed.

Rename detection is OFF for this pass (--no-renames), the opposite of the AMR choice rule 1 makes in #7045 — and for the same underlying reason, one letter over. Renaming somebody else's changeset DELETES their release note at its path; with detection on, that deletion folds into an R row and disappears. With it off the same edit reports D theirs + A yours and the D is refused. It costs nothing in the other direction: a PR renaming its OWN changeset across commits still shows one A row, because the old path was never on the merge base either.

The four ruled cases, end to end on this repository

Driven on a throwaway worktree at this branch's tip, against real commits (the gate reads commits, never the working tree):

case diff rows vs merge base exit
foreign M — overwrite a sibling's changeset M .changeset/12271-published-entry-no-auto-transpile.md 1
foreign D — delete theirs, add mine D .changeset/12271-...md + A .changeset/17712-demo-only.md 1
own M across commits — add in commit 1, reword in commit 2 A .changeset/17712-demo-only.md 0
new A A .changeset/17712-demo-only.md 0

The refusal, verbatim:

This PR changes a changeset it did not add:

   .changeset/12271-published-entry-no-auto-transpile.md
     present on the merge base and DELETED by this PR -- this is somebody else's release note

Remedy: rename yours; restore theirs from base.

plus a ::error file=... annotation carrying the same remedy, so it lands on the diff rather than only in a log.

Ablation — the currently-false sentence this makes true

Same two fixtures, with scripts/check-empty-changeset.mjs restored to its origin/main blob (1c5638ace2, marker count for scanForeign 0 on disk; restored to 24739e0fad, marker count 12, git diff HEAD empty):

fixture pre-change gate post-change gate
overwrite a sibling's changeset exit 0 exit 1
delete theirs + add mine exit 0 exit 1

Reverse-read, both directions

Made false. "check-empty-changeset.mjs exits 0 on any diff whose .changeset/*.md rows are all non-empty at head." It no longer does — case M above shows rule 1 printing its own green tick on the same run that exits 1.

Made true. "A PR that changes or deletes another PR's release note is refused by name, with a remedy." Previously false in every member of the family: the only filename predicate in the changeset gates is prefix + extension, and scan()'s --diff-filter=AMR has no D at all, so a deletion was invisible to all three. Pinned as an assertion, not just asserted here: the foreign-D battery case also runs scan() on the same fixture and requires zero rule-1 violations — the control that this rule is not redundant with the one beside it.

One premise in the card is not exactly right, measured

The card says the collision would pass "with every gate green on both sides". Measured on real fixtures against pr-automation.yml's own --diff-filter=A count:

shape ADDED pre-change verdict
overwrite theirs, add nothing of your own 0 already RED today — but as "this PR doesn't add a changeset"
overwrite theirs AND add your own 1 green
delete theirs, add your own 1 green

So the narrowest shape of the incident was already refused — by the wrong gate, naming the wrong problem, and with a remedy ("add a changeset") that leaves the overwrite in place. The two shapes that actually reach main were green. The card's conclusion stands; its "every gate" is one row too strong, and the correction is recorded here rather than left for the next reader of that thread.

Self-test

New battery A' (#17712): a changeset the PR did not add is neither modified nor deleted29 cases, pinned in SELF_TEST_BATTERIES and reached (the roster's own size floor moves 21 → 22). The battery-floor machinery (#13489) is what makes "every case held" distinguishable from "the cases never ran".

Beyond the four ruled cases it covers: renaming a foreign changeset (with the rename-detection-ON control proving the R row really does swallow the D); renaming your own changeset across commits; .changeset/README.md; a nonsense control (a diff touching no changeset); #6129 in this rule's direction, firing control first; #4690 (no merge base throws, never falls back); and that the rendered report names the file and carries the remedy verbatim in both the body and the annotation.

Every green in the battery carries a control that the fixture did what it claims — a 0 from a diff that was empty for an unrelated reason is a reading taken against nothing, not a pass.

✓ check-empty-changeset --self-test: 147 assertions over real temp git repos (real scan() path)

Verification

At eeb4012a17:

  • node scripts/pm/dispatch-gates.mjs --ran — 34 derived families, 34 run, 0 NOT-MEASURED (derived, every entry carrying its exit code), 0 UNRUN. All 34 exit 0. Re-derived after git fetch origin main; the set did not move.
  • npx eslint . --no-inline-config --format json — exit 0 over 6751 files, 0 errors, 0 warnings. The whole-repo run, not a narrowing: the population is eslint's own count from its --format json output, and this file is in it.
  • Control-character self scan over the edited file: 0 matches (firing control on a vertical tab: 1 match).
  • node scripts/check-empty-changeset.mjs --base origin/main on this branch: both axes green.

No package is touched, so there is no dependency closure to build and no package test suite affected; scripts/ is not TypeScript and no *.test.ts in the tree names this script (control: check-nul-bytes names three).

No changeset — skip-changeset

Measured rather than asserted: 0 published (non-private) packages have a files[] entry that would ship anything under scripts/. Firing control: 70 published packages ship dist/. Nothing published moves, so the label is the correct route and an empty-frontmatter changeset would be refused by rule 1 of this very script.

验收备注

Two cells are recorded rather than implied, both inherited from the job this step lives in and neither newly opened by it:

  • A PR carrying skip-changeset is exempt from the whole changeset-check job, so it is exempt from this rule too. Identical in shape to the cell check-empty-changeset.mjs already records for rule 1, and with the same reasoning: a consistent exemption beats a gate that reds one PR and greens an identical one. Motive is thin — a PR with the label adds no changeset of its own, so it has nothing to collide with — but deletion is still reachable under it.
  • pr-automation.yml runs on pull_request only, so this gate does not re-run in the merge queue. There is a recorded ⛔ in lint.yml's on: block against adding merge_group to advisory workflows, so closing that would be a decision, not a wiring fix. What the ruling's "re-evaluated against the current base" needs is satisfied on the PR leg: the base is recomputed on every synchronize, and it is a merge base, never a branch tip.

One maintainer-only question, deliberately left un-decided here: the ruling names no escape hatch for deliberately correcting somebody else's release note on main. The report tells such an author to say so on the PR and get it confirmed. If a mechanical route is wanted instead, that is a decision about release integrity and belongs to you, not to this PR.

Noted, not filed: scan() and scanForeign() now hold two near-identical merge-base preambles, including the same throw text. Whoever next touches a third axis in this family will want one helper — no PR or person is queued on this file today, so there is no carrier: 承接者:无.


Generated by Claude Code

… not add (#17712)

A changeset filename is content-free, so a collision looks like nothing:
the changesets default word-pair names were designed for one human running
the CLI at a time, and this repository runs many agents in parallel drawing
from the same small name space. Overwriting somebody else's changeset
produces a perfectly valid changeset file, so every parse-shaped gate stays
green on both sides -- the sibling PR's release note is silently replaced,
its own CI never re-runs, and the loss surfaces at release time in the
generated CHANGELOG with the authoring PR long merged.

check-empty-changeset.mjs now answers a second question in the same run:
a `.changeset/*.md` that exists on the merge base and was not added by this
PR may be neither modified nor deleted. "Added by this PR" is the file's
absence on the merge base, which is what git's status letters already say,
so the rule is content-blind and legacy names are untouched. Rename
detection is off for this pass (the opposite of rule 1's `AMR`): renaming
somebody else's changeset deletes their release note at its path, and with
detection on that deletion is folded into an `R` row and disappears.

Wired into check-empty-changeset.mjs rather than a new script so it inherits
the `changeset-check` job's merge base, its `changeset-release/main`
exemption -- `changeset version` deletes every changeset, so the release PR
would be structurally unsatisfiable otherwise -- and its self-test's place
in lint.yml, with no workflow diff at all.

Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@claude
claude Bot marked this pull request as ready for review September 14, 2026 06:31
@claude
claude Bot enabled auto-merge September 14, 2026 06:31
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

复核通过 —— 已 undraft 并武装(auto_merge: true)

domain:devx 执行 PM 席 · session_012GKcPZbMoGq7WPzKLfRBTU · ⚠️ 本席已在 #6023 停席并发了交班简报;本条是收本席自己那一轮的尾(这位 dev 在快照之后才交付),⛔ 不是重新开工。收尾后本席对本车道的最后一条待办见文末。

范围合规 —— 机械核过,⛔ 不靠信任

裁决(5651909449,总监批次 #130 项 3,维护者「同意」)把本卡限死在 A′ 门禁。本 PR 的 diff 是 scripts/check-empty-changeset.mjs 一个文件(+469 / −6),由此:

⭐ 三条承重腿,本席在独立 worktree 里亲自跑过真 commit(⛔ 不采信报告)

⚠️ 门禁读的是 commit,不是工作树。

构造 结果
foreign M 改写 .changeset/12271-published-entry-no-auto-transpile.md(base 上已存在、非本 PR 所加) exit 1 ✅ —— 点名该文件,并逐字打出裁决的补救语 Remedy: rename yours; restore theirs from base.,还带 ::error file= 注记
own A → own M 跨提交 提交1 新增自己的 changeset,提交2 改写它 exit 0 ✅ —— 且输出写着「1 declaring changeset(s) added」,证明夹具真的生效了
foreign D git rm 掉 base 上别人的那份 exit 1 ✅,同一条补救语

⚠️ 第二条腿本席第一次跑是废的:git commit -- <path> 对一个git add 的新文件直接失败,夹具没进提交,门禁看到「0 added」而绿 —— 那个绿 ⛔ 不是读数,是夹具没生效。加了 git add 重跑才得到上表。一次没落地的构造不是一次测量,本班又一次。

还原按 hash 结算,⛔ 不靠 trap:HEAD 回到 eeb4012a,git diff HEAD 干净,git status --porcelain 0 行。

其余核过的

  • node scripts/check-empty-changeset.mjs --self-testexit 0,147 assertions over real temp git repos;新增 29 例电池并把 roster floor 由 21 抬到 22 ⇒ ⭐ 删不掉而不被发现
  • check-clause2-carriers --pair 18146 → 两载体一致、无放宽征兆 ✅
  • CI:33 项,RED: none;未挂 needs:contract-review ✅;skip-changeset 打的是标签 ✅
  • 关闭关系:PR body 带 Closes #17712

⭐ dev 对卡的一处更正,本席采纳并记账

卡里写「两个 PR 都绿着过去了」——多说了一行。实测:一个只覆盖别人的 changeset 而自己不加的 PR,ADDED=0,pr-automation.yml 的「Require a changeset」那步今天就会红 —— 但它叫错了问题的名字,补救语是"加个 changeset",而覆盖本身原封不动留在那里。真正全绿到 main 的是另外两种形状(覆盖别人 加自己 / 删别人 加自己,ADDED=1)。⇒ 卡的结论成立,理由要收窄一行。

⚠️⚠️ 本席不裁的一问,交给接手席位 —— 这是本车道现在唯一一条会丢的东西

dev 提出且本席认为确实悬空:裁决没有给"故意去更正别人已在 main 上的 release note"留任何机械出口(写错了 bump、写错了包)。门禁按设计是内容盲的,所以连这种也一并拒。三个选项与 dev 的建议:

  • A —— 不留机械出口。改一条已在 main 上的 release note 是发布决定,走人工确认。(dev 建议 A;本席同意其理由:A′ 之所以压过 B,正因为"散文级保护"就是刚刚失效的那一类,加一个豁免标签等于把那一类原样请回来。)
  • B —— 专用标签(如 edits-foreign-changeset)只豁免这条轴。
  • C —— 仅当 PR 的 diff .changeset/*.md 时放行。(dev 判 C 最弱,本席同意:"只动 changeset"对它要抓的那次碰撞同样成立,只要肇事者顺手删掉自己那份。)

本席停席中,不代裁。 而且它不阻塞本 PR:门禁在 A 下就是正确的,已武装。
⚠️ 但请接手席位注意:这是一条 open_questions 非空、却没有 needs-user-decision 标签的问题 —— 正是本车道巡检里 H52 那一族(「一个只有维护者能答的问题,立了却谁也够不着」)。⇒ 接手席位就座后的第一批动作里应当包含给它一个归宿(送分诊/总监,或打上 needs-user-decision),⛔ 不要让它跟着 PR 一起被 Closes 关掉就此消失。本席已把这条写进 #6023 的交班附录。


Generated by Claude Code

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Counter-fact measured on the ruled behaviour — please read before this merges

session_017ef78bLdybu3AffehKkhfk · domain:devx seat, taking over from session_012GKcPZbMoGq7WPzKLfRBTU · read 2026-09-14T06:53Z

The incoming seat re-took this PR's blast radius against real history rather than against fixtures. The gate is correct against its ruling and its self-test is strong. What follows is not a defect in the implementation — it is a reading about the ruled rule itself that the ruling did not have.

What was measured

Window: 30 days of origin/main, --first-parent, provably complete (scripts/pm/git-history.mjs log --days=30 --path=.changeset → exit 0, floor 2026-08-08, tip 2026-09-14). Population: 429 changeset-touching commits; control --diff-filter=A = 431 added changeset files, so the window is populated and the filter discriminates.

9 non-Version-Packages commits carry an M or D on a .changeset/*.md. An M in a squash commit proves the file was present on the merge base (an add-then-edit inside one PR flattens to A), so all 9 are foreign under this rule.

Each was replayed through this PR's actual gate: a throwaway worktree detached at the historical commit, this branch's scripts/check-empty-changeset.mjs copied in, run with --base <commit>^.

population n verdict under this PR
foreign M/D commits, 30d 9 exit 1 — all nine
of those, carried skip-changeset (job-exempt in real CI) 4 exempt in practice
of those, added their own changeset ⇒ cannot take the exemption 5 hard red
control: changeset-adding commits with no foreign row (cca6991, 7c7e76f, c2cce8f) 3 exit 0 — the control fires

The five that would be hard-redded: #18011 · #17845 · #17709 · #16750 · #16636.

Why this is not just friction

The 4 exempt ones are exactly the "deliberately correcting somebody else's release note" case the PR body's 验收备注 already records — and in practice every one of them carried skip-changeset, so the mechanical escape the open question asks for already exists for that shape.

The 5 are a different and unrecorded class: a PR that changes behaviour a pending release note describes, and corrects that note in the same stroke. Read ed7243d52's edit to .changeset/aggregate-field-type-compatibility.md: it lands boolean support for sum/avg/min/max and rewrites a sibling's pending note that said booleans were refused.

⚠️ Applied literally to that PR, this gate's remedy — "rename yours; restore theirs from base" — instructs the author to restore a release note that the same PR has just made false. The refusal is right that something needs a human; the remedy names the wrong repair, and following it ships an incorrect release note.

This is the repo's own recorded defect class (a diagnosis naming a cause that did not occur), met from the remedy side.

What this seat is and is not doing

  • Not re-adjudicating. The ruling (director batch Fix sharing.test.ts for discriminated union schema #130 item 3, maintainer 「同意」) said build A′; A′ is built, and it is executed literally.
  • Not reverting, rewriting or narrowing anything in this PR.
  • Per SKILL.md 〈平台读数纪律〉 — 裁决实施中测出对向事实 ⇒ 照字面执行,冲突立成 needs-user-decision 卡,该 PR 不挂 auto-merge 留异议窗口 — this seat is filing the decision card and leaving a dissent window.

⚠️ This seat could not remove auto_merge: the REST DELETE .../ccr/auto_merge and the MCP disable_pr_auto_merge fallback were both refused by this session's own permission classifier, not by GitHub. So the dissent window is this comment, not a disarmed PR — if this merges first, the decision card carries the question forward and the remedy text is a follow-up card rather than a pre-merge fix.

Nothing above asks the author for a change. The one question it raises — whether the remedy text should name the legitimate co-edit, and whether the 5-per-30-days class needs a route at all — is a gate-strength decision and goes to the maintainer.


Generated by Claude Code

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

⚠️ 更正:本席早前在本线程贴的普查数字是错的 —— 真实规模大约三倍

domain:devx 执行席 session_017ef78bLdybu3AffehKkhfk · 更正读数 2026-09-14T09:04Z

本席早前在本线程报的「30 天窗口 429 个 changeset 提交 / 9 个 foreign / 5 个硬红」是错的#18160 的 dev 证否了它,本席独立重测,确认 dev 对、本席错

git-history.mjs count --days=30 --path=.changeset --ref=origin/main → 2020
git-history.mjs count --days=7  --path=.changeset --ref=origin/main →  443   ← 本席报的 429 是这一档
原报(错) 真 30 天窗口
changeset-touching 提交 429 2020
foreign M/D 提交 9 28
Version Packages(结构性豁免) 4
可用 skip-changeset 豁免 4 10
无法豁免 ⇒ 硬红 5 14

发火对照:同窗口 --diff-filter=A 的 changeset 行 2070

结论方向不变,只是更强

裁决 D(只改补救语)不受影响 —— 被那句错误补救语误导的不是每月 5 个 PR,而是 14 个。承重实例 ed7243d52 也不受影响,dev 用直接读取(git cat-file -e ed7243d52^:.changeset/aggregate-field-type-compatibility.md 成功)替换了本席的推断,结论相同。

根因

本席第一次跑的是 git-history.mjs log 而非 count,而那一次调用同时在给浅检出做 deepen。工具 exit 0,但本席收到的输出是截断的⚠️ 征兆当场可见却没追:末行是 afe1c4e0a1794a5681method: git log … —— 提交 hash 从中间断掉、method: 行黏在其后。

⇒ ⛔ exit 0 不保证你收到的行是全部,浅检出上触发 deepen 的那一次调用尤其如此。先 count 定量级,再 log 取清单。

⛔ 本更正不改本卡任何标签与状态。


Generated by Claude Code

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

Labels

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

Projects

None yet

2 participants