Skip to content

fix(ci): let the two changeset gates see a RENAMED changeset (#7045) - #7106

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-7045-diff-filter-amr-siblings
Aug 9, 2026
Merged

fix(ci): let the two changeset gates see a RENAMED changeset (#7045)#7106
os-project-manager merged 1 commit into
mainfrom
claude/issue-7045-diff-filter-amr-siblings

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #7045

scripts/check-empty-changeset.mjs and scripts/check-adr-0087-registration.mjs
both enumerated the diff with --diff-filter=AM. Git's rename detection is on by
default (diff.renames, since git 2.9), so a changeset renamed and modified in
one commit reports as R, and AM dropped the row entirely — neither gate saw the
file at all.

check-changeset-no-major.mjs closed the same hole in the third member of this
family in PR #7048 (#7005). This is that pattern applied to the two siblings, with
each gate keeping its own fixtures and its own message text.

The bypass, end to end

One temp repo, one commit that renames a declaring changeset while emptying its
frontmatter. Same repo, same commit, two versions of the same script:

$ git diff --name-status $BASE HEAD -- '.changeset/*.md'
R085    .changeset/was-declaring.md     .changeset/now-renamed.md

# origin/main (--diff-filter=AM)
✓ No empty-frontmatter changeset introduced by this diff (0 declaring changeset(s) added).
exit=0

# this branch (--diff-filter=AMR)
This PR adds an empty-frontmatter changeset:

   .changeset/now-renamed.md
     renamed into this path, and empty here -- declares no package
     (the branch-point path is .changeset/was-declaring.md -- read the diff there)
exit=1

What changed

  1. --diff-filter=AMR in both scan()s.
  2. An R row is R< score > TAB old-path TAB new-path, so the head path is field
    3 and the branch-point path is field 2. The base side is read at the
    pre-rename name; the status is compared one character wide, because the R
    letter carries a similarity score.
  3. A pure rename therefore compares equal and stays exempt / skipped at its new
    path. Moving a stock changeset is not a violation and never becomes one.

One thing the reference implementation did not need

The base-side read is guarded by isChangesetFile(basePath). Git pairs renames by
content, not by name, so an R row can legitimately arrive as
.changeset/README.md -> .changeset/anything.md — measured on git 2.43.0, where two
identically-bodied files paired across unrelated names and the pathspec kept the
pairing inside .changeset/. README is documentation that declares nothing by
definition, so reading "already empty at base" / "already breaking at base" off it
would have handed the exemption out for free on a head file that really is a
brand-new changeset. Both gates now refuse to inherit anything from a non-changeset
base path; each carries a fixture for it (RED 5 / R16).

What deliberately did NOT change

.github/workflows/pr-automation.yml keeps --diff-filter=A for its changeset
count, and check-empty-changeset.mjs's consumer block keeps asserting exactly
that spelling. That A is route detection — "did this PR write a changeset, or does
it owe a skip-changeset label" — not a violation scan. A rename adds no changeset,
so counting R there would hand the label exemption to a PR that wrote nothing,
which is the loosening direction. A fails closed for that count; AMR fails closed
for the scans. Same family, opposite obligations — now written down next to the
assertion so the next reader does not "fix" it to match.

Fixtures, and the control that keeps them honest

Rename detection is a similarity score: too short a body and git degrades the
move to add-plus-delete, the R path is never exercised, and the case passes green
while testing nothing. Every new case therefore asserts the real R row first, by
regex against raw git diff --name-status output, before it asserts any verdict.

check-empty-changeset.mjs — 91 → 105 assertions:

  • RED 4 stock changeset renamed AND emptied in one commit → one violation, named
    at the head path, kind: 'renamed-empty', carrying the branch-point path.
  • GREEN 6 pure rename of a stock empty changeset → exempt at its new path.
    This one can only be green through the R path: had the rename degraded, the new
    path would arrive as A + empty, which is RED 1.
  • GREEN 7 pure rename of a stock declaring changeset → simply ok.
  • RED 5 an R row whose base side is README.md inherits nothing.

check-adr-0087-registration.mjs — 130 → 142 assertions:

  • R15 changeset renamed AND turned breaking, no disposition marker → RED.
  • G10 pure move of an already-breaking stock changeset → GREEN, and reported as
    skipped at its new path rather than silently absent.
  • R16 an R row whose base side is README.md is still judged.

The self-test's build() helper gained baseFiles and null-means-delete in
files, because expressing a rename needs both halves of the pair.

Reverse verification

Direction predicted before running, three ablations per gate, all red as predicted:

ablation check-empty-changeset check-adr-0087-registration
AMRAM 7 assertions fail (RED 4 ×4, GREEN 6, GREEN 7, RED 5) 8 fail (R15 ×4, G10, R16 ×3)
base read at head path instead of field 2 2 fail (GREEN 6) 2 fail (G10)
drop isChangesetFile(basePath) 2 fail (RED 5) 3 fail (R16)

Worth noting which case pins which claim. Reverting AMR does not break the
"read field 2" claim — under AM there is no R row to misread. What pins field 2
is the pure-rename case in each gate (GREEN 6 / G10): read the base at the head
path and it resolves to nothing, so a legitimate move is reported as a brand-new
violation. The RED cases alone would have stayed green through that mistake.

Sample output, AMRAM on the ADR gate:

✗ check-adr-0087-registration --self-test -- 8 failure(s)
  • R15 the #7045 shape (renamed AND turned breaking, no disposition): expected RED, got green
  • G10: ...and it is reported as SKIPPED at its new path, not silently absent -- got []
  • R16 a breaking changeset paired with README.md by rename detection is still judged: expected RED, got green

Verification

node scripts/check-empty-changeset.mjs --self-test
  ✓ 105 assertions over real temp git repos (real scan() path)
node scripts/check-adr-0087-registration.mjs --self-test
  ✓ 142 assertions over real temp git repos (real scan()/assertInputs() path)
node scripts/check-changeset-no-major.mjs --self-test
  ✓ 113 assertions   (the untouched third sibling, still green)

node scripts/check-empty-changeset.mjs --base origin/main        exit 0
node scripts/check-adr-0087-registration.mjs --base origin/main  exit 0

pnpm lint                exit 0   (full repo, eslint --no-inline-config)
pnpm check:nul-bytes     exit 0   (6539 tracked files, no raw control bytes)
pnpm check:doc-authoring / check:role-word / check:adr-anchors / check:merge-driver   all pass

pnpm check:type-check-debt was not run locally: it refuses to measure without the
whole package closure built, and this diff contains no TypeScript at all — two
.mjs files under scripts/. The CI job is the check for it.

No changeset

scripts/ belongs to the private root workspace (@objectstack/spec-monorepo,
"private": true) and ships to no registry, so this PR releases nothing and takes
skip-changeset — the same route PR #7048 took for the identical file class.


Generated by Claude Code

`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 #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.

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 4:01pm

Request Review

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

Development

Successfully merging this pull request may close these issues.

--diff-filter=AM lets a RENAMED changeset carry a violation past check-empty-changeset and check-adr-0087-registration

2 participants