fix(scripts): gate changelog sections a change set deletes (#2264) - #2298
fix(scripts): gate changelog sections a change set deletes (#2264)#2298kyle-sexton wants to merge 1 commit into
Conversation
`check-changelog-parity.sh` policed the bump and never the preservation. A merge-forward that writes the new release under the PREVIOUS release's heading — absorbing it, or relabelling that heading to the new version — deletes a released section, and all three modes passed: `--check-bump` asks only about the bumped version, `--check-order` reads a gap in the sequence as correctly ordered, and `--check` compares the manifest against the changelog maximum. Git leaves no conflict marker behind for that resolution either, so nothing caught it. Adds a fourth mode, `--check-preserved <base-ref>`: every `## [<version>]` heading a touched changelog carried at the FORK POINT must still be there at head. Three decisions: - **Its own mode, not a `--check-bump` addition.** Preservation is a different concern and a wider one: the bump gate is scoped to plugins whose manifest version changed, which is exactly where an absorbed heading would NOT be looked for. The new mode sweeps every changed changelog under both roots `--check-order` reads, including the convention changelogs no manifest versions. - **Fork point, never the base tip.** A branch that has not integrated main would read every heading main added after the fork as deleted — a false positive on a required gate. The two coincide in the scenario this catches, because absorbing a section requires having merged main forward in the first place. - **No exemption list.** Keep a Changelog keeps a yanked release's heading and marks it `[YANKED]` rather than deleting it, and a release note written ahead of its bump is fixed by bumping the manifest up to it — so both removals an author might reach for have a legal non-deleting form, and a hatch could only ever silence the defect. The failure message names relabelling explicitly, since that resolution reads as a deletion and the author needs to recognise what happened. Reading discipline is unchanged from the #2154/#2159 fix: no reader in the new path exits before EOF, so no writer can take SIGPIPE under pipefail on any awk. Existence at the fork point is probed with `git ls-tree` rather than `git cat-file -e`, which cannot distinguish a missing path from an unusable rev (both exit 128) and would fail every change set that ADDS a changelog. Twenty new cases, including the three-mode gap assertion on the absorption tree, a stale-branch false-positive guard, the `[YANKED]` form, a plugin removal, and the large-changelog SIGPIPE fixture under forced gawk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 2m 17s —— View job Security review complete
No security issues found. This PR adds a new Notes from the review (informational, not findings):
|
|
Claude finished @kyle-sexton's task in 6m 0s —— View job Review complete
Scope note: this repo has a Execution note: the sandbox for this review blocked running shell commands beyond What I checkedTraced
One documented, intentional gap I confirmed rather than flagged: a plugin directory rename (not just a heading rename) can slip past Finding posted inline🟡 Suggestion — scripts/check-changelog-parity.sh:482: the "DELETED CHANGELOG ENTRY" remediation text is written for the absorb/relabel case ("restore the heading above the entry that replaced it") but the same message also fires when the whole changelog file was deleted while its plugin survived — where that specific advice doesn't apply. Suggested a two-branch message; not a correctness bug in the check itself. See the inline comment for a concrete patch. No Important/Critical findings. The design decisions in the PR description (fork point over base tip, no exemption list, shared prologue with |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
|
Superseded by #2290, which landed the #2264 fix first — no objection to the close. Recording the delta here so the branch is not lost, and so the parts of it that are still live are traceable. Filed as #2327: the shipped check diffs whole heading lines (
Plus two scope gaps the #2264 body had named as reasons to consider a separate mode: an absorption in a change set that does not bump the manifest, and one in a The branch This PR is not reopenable (the head ref moved while it was closed), so any re-land is a fresh PR from the same branch. |
Summary
scripts/check-changelog-parity.shpoliced the bump and never the preservation. A change set could delete an already-released section's heading — absorbing its notes into the new release — and all three modes passed. Reproduced verbatim from #2264, now a test fixture:--check-bump--check-order--check--check-preserved## [0.51.8],## [0.51.7]→ head## [0.51.9],## [0.51.7]Why each of the three missed it:
--check-bumpasks only about the bumped version and never enumerates the base's other headings;--check-orderreads0.51.9, 0.51.7as correctly ordered with no duplicates, and a gap is not an ordering violation;--checkcompares the manifest against the changelog maximum. Git leaves no conflict marker behind for that resolution either — when two change sets bump one plugin, the conflict region tends to open below the newest heading, so a plausible merge-forward writes two releases under one heading silently. It happened on this repo during the #2163/#2171 drain and was caught only because a human ran a heading-list diff by hand.This adds a fourth mode,
--check-preserved <base-ref>: every## [<version>]heading a touched changelog carried at the fork point must still be there at head. Wired as a PR-only step in the existingchangelog-parity-gatejob, so no required-context name changes.Three design decisions
1. Its own mode, not an addition to
--check-bump.--check-bumpalready resolves the base and the plugin scope, so folding it in is cheaper — but its scope is plugins whose manifest version changed, which is exactly where an absorbed heading would not be looked for. A section can be absorbed by a change set that never touches a manifest, and the convention changelogs underdocs/conventions/*/are unversioned by any manifest so--checkand--check-bumpnever read them at all. The new mode sweeps every changed changelog under both roots--check-orderreads. The two modes share one prologue for the base ref, the fork point, and the touched-path scan, so they cannot drift into reading the diff differently.2. Fork point, never the base tip. The issue suggested
git show "$base:$changelog". Compared against the base tip, a branch that has not integratedmainreads every headingmainadded after the fork as "deleted" — a false positive on a required gate that would block every in-flight PR. The two semantics coincide in the scenario this catches, because absorbing a section requires having mergedmainforward (that is what opens the conflict region), and once the base is an ancestor of head the fork point is the base tip. Merge-base loses nothing and cannot produce that false-positive class at all. Guarded by its own test (a stale branch that never integrated main is not flagged), which fails under base-tip semantics.3. No exemption list — decided against real evidence, not in the abstract. Two removals an author might reach for, and neither needs a deletion:
## [0.0.5] - 2014-12-13 [YANKED]— "The[YANKED]tag is loud for a reason" — so the canonical "legitimate deletion" is not a deletion at all under the convention these changelogs follow. A[YANKED]-marked heading keeps its version in the extractor's list and passes; there is a test for it.--checksuggests first — bump the manifest onto that number — is often unavailable, because by the time the mismatch is noticed the manifest has already moved past it and bumping back down is aVERSION REGRESSIONunder--check-bump.The sweep below found exactly one deletion in this repo's recent history — 04822fc, which closed #2131 by folding
docs-hygiene's never-released## [0.9.7]into## [0.10.0]— and it is the second shape. That is the strongest available argument for a hatch, and it is also the argument against one: in the diff, that fold is indistinguishable from the absorption this gate exists to catch. A structural exemption would therefore have to exempt both. An allowlist file (the repo'schangelog-parity-baseline.txt/contract-slice-baseline.txtidiom) was the alternative considered and declined: it cannot be stale-guarded meaningfully here (post-merge the removal is in the base, so an entry never expires without red-liningmainfor someone else), and a required gate whose whole purpose is catching a hurried merge resolution should not ship the off switch a hurried resolver will reach for. The failure message states the annotate-in-place remedy instead.Renames. The same bad resolution can relabel
## [0.51.8]to## [0.51.9]rather than delete it. That is a deletion of0.51.8and the check reports it as one — correctly — and the failure message names relabelling explicitly so the author recognises what their resolve did. Separately, a plugin directory rename can carry an absorption past the gate (default rename detection makesgit diff --name-onlyreport only the new path); that is recorded in the code as a known boundary, not an oversight —--check-bump's manifest scoping has the same property, and--no-renameswould not close it.Constraints of the file that were preserved
rendered_lineswriter intogrep -oEandmapfile, none of which stop early — so no writer can take SIGPIPE underpipefailon any awk. Covered by a large-changelog fixture under a forced-gawkPATH shim, the same idiom the--check-bumpSIGPIPE fixture uses. CI confirms it ran rather than skipped (noSKIP:line in the job log).changelog_versionsextractor rather than adding a second reader, so a fenced or HTML-commented example heading is invisible to it too.git ls-tree(missing path → exit 0, empty output; unusable rev → non-zero), notgit cat-file -e, which exits 128 for both and would have failed every change set that adds a changelog. Verified empirically before relying on it. The heading extraction's status is deliberately ignored —grep -oEexits 1 on a changelog with no version headings, which is not an error, and treating it as one would false-positive on every prose-only changelog (covered by a test).plugins/**is touched.Test plan
Suite:
PASS=76 FAIL=0(55 pre-existing + 21 new),bash scripts/check-changelog-parity.test.sh, and green on CI (ubuntu-24.04).New cases prove the check discriminates, not merely that it exists:
[YANKED]-marked heading, a heading reformatted between the two accepted forms, a headingless changelog, a removed fenced example.docs/conventions/*/CHANGELOG.md) are in scope, proven by an absorption there.Zero false positives on the live tree
Corpus: 65 plugin changelogs + 10 convention changelogs = 75 in scope, carrying 1530 version headings today. A separate scan confirms 0 of the 1487 headings in the plugin changelogs currently name a version above their manifest (the remaining 43 headings are in the 10 convention changelogs, which no manifest versions, so there is nothing to compare them against).
Sweep B — the load-bearing one.
mainis squash-merged (git rev-list --merges origin/main→ 0 results), so each commit onmainis a merged PR. Every one of the 60 most recent commits touching a changelog was replayed as its own PR (tree atC, baseC^1) in a throwaway local clone:The single fire is 04822fc —
plugins/docs-hygiene/CHANGELOG.md documented 0.9.7 at the fork point but no longer does— and it is a true positive: that commit really did delete## [0.9.7]and move its bullet up under## [0.10.0](diff). It was a legitimate fold of a never-released entry, which is why design decision 3 above is written the way it is. A sweep that never fires across 60 real commits would be weaker evidence than one that fires exactly on the commit that deleted a heading.Sweep A — whole-corpus, older bases. The real mode run from a tree at
mainwith progressively older bases, so every heading present at that base must survive at head:origin/main~10origin/main~50origin/main~200origin/main~600(A root-commit base was also run; it compares 0 headings because no changelog existed then, so it is not evidence and is excluded from the table.)
Conflict-marker sweep.
<<<<<<<: 0.|||||||: 0.>>>>>>>: 0.=======: 8 matches, every one inside a# ===== <section> =====banner comment matching the file's existing section-header style — none a conflict marker.Other gates, locally:
shellcheck --rcfile=.shellcheckrc -xon both files → clean;scripts/check-shell-portability.sh --pathson both files →No unexcused GNU-only constructs in 2 shell file(s);scripts/check-silent-skips.sh --check→ clean;actionlint .github/workflows/ci.yml→ clean.Independent verification. A fresh-context verifier reads the diff at the pinned head SHA, builds its own sweep (not the author's scripts), reverts the fix to prove the new tests fail without it, and confirms the SIGPIPE-safe reading discipline. Its verdict is posted to this PR as a comment.
Related
Closes #2264.
Same file, different failure: #2154 / #2159 (the SIGPIPE regression that made a present heading read as missing; this one made a missing heading read as fine). #2131 is the reverse-parity gap whose remediation commit is the single true-positive fire in Sweep B. The incident that produced the live absorption: #2163 / #2171.