Split out of #2327 so that issue can close with #2341, which fixes only its false positives (items 1 and 2). These are #2327 items 3, 4 and 5 — all pre-existing from #2290, none addressed by #2341.
The absorbed-heading check lives inside --check-bump's loop, so it is gated on bumped_candidate[$name] and on [[ "$head_version" != "$fork_version" ]] || continue. Three consequences, each verified against the shipped script on main, copied unmodified into throwaway repos.
1. A changelog-only change set is unpoliced — and this is where the real one hid
base: ## [0.51.8], ## [0.51.7] manifest 0.51.8
head: ## [0.51.8] manifest 0.51.8 (untouched)
(0.51.7's note folded into 0.51.8, its heading deleted)
--check-bump -> EXIT=0
Not hypothetical. Replaying the 60 most recent main commits touching plugins/*/CHANGELOG.md as their own PRs produced zero ABSORBED CHANGELOG HEADING fires — including on 04822fc4, which really did delete ## [0.9.7] from plugins/docs-hygiene/CHANGELOG.md and fold its bullet into ## [0.10.0]. It is invisible because it changed no manifest at all:
$ git diff --name-only 04822fc4^1 04822fc4
.github/workflows/ci.yml
plugins/docs-hygiene/CHANGELOG.md
scripts/check-changelog-parity.sh
scripts/check-changelog-parity.test.sh
"Fix a release note" is the ordinary shape of a changelog-only PR, and it is exactly the shape a bad merge-forward resolution takes.
2. Convention changelogs are unpoliced
docs/conventions/*/CHANGELOG.md are unversioned by any manifest, so --check-bump never reads them — which is precisely why --check-order was given its own whole-file scope (the misordering that produced it shipped in docs/conventions/loop-lane/CHANGELOG.md).
docs/conventions/demo/CHANGELOG.md
base: ## 2.0.0 — 2026-01-02, ## 1.0.0 — 2026-01-01
head: ## 3.0.0 — 2026-01-03, ## 1.0.0 — 2026-01-01 <- 2.0.0 absorbed
--check-bump -> EXIT=0
3. The fork-point read is fail-open
git show "$merge_base:$changelog" 2>/dev/null | changelog_versions - inside a process substitution: a git read that genuinely fails yields an empty fork-point set, which reads as "nothing to preserve" and passes. git ls-tree distinguishes the two cases exactly — a path missing from the tree is exit 0 with empty output, an unusable rev is a non-zero exit — where git cat-file -e cannot (it exits 128 for both, so probing with it would fail every change set that adds a changelog).
Shape of a fix
Preservation as its own diff mode over every changed changelog under both roots --check-order sweeps, wired as an additional PR-only step in the existing changelog-parity-gate job so no required-context name changes. It must replace the in-loop check rather than sit beside it — two overlapping absorption checks is the drift the file's own header warns against.
A worked implementation exists on branch fix/2264-changelog-preservation (PR #2298, closed as a duplicate of #2290 before it could land, and not reopenable): 21 tests including [YANKED], heading reformat, whole-file deletion, convention-changelog absorption, a stale-branch false-positive guard, and a forced-gawk SIGPIPE fixture. Swept over 60 replayed main commits, 7789 headings compared, firing on 04822fc4 and nowhere else. It needs rebasing onto #2290 and #2341.
Related
#2264 (the defect), #2290 (the fix that shipped), #2327 (the false positives), #2341 (their repair).
Split out of #2327 so that issue can close with #2341, which fixes only its false positives (items 1 and 2). These are #2327 items 3, 4 and 5 — all pre-existing from #2290, none addressed by #2341.
The absorbed-heading check lives inside
--check-bump's loop, so it is gated onbumped_candidate[$name]and on[[ "$head_version" != "$fork_version" ]] || continue. Three consequences, each verified against the shipped script onmain, copied unmodified into throwaway repos.1. A changelog-only change set is unpoliced — and this is where the real one hid
Not hypothetical. Replaying the 60 most recent
maincommits touchingplugins/*/CHANGELOG.mdas their own PRs produced zeroABSORBED CHANGELOG HEADINGfires — including on04822fc4, which really did delete## [0.9.7]fromplugins/docs-hygiene/CHANGELOG.mdand fold its bullet into## [0.10.0]. It is invisible because it changed no manifest at all:"Fix a release note" is the ordinary shape of a changelog-only PR, and it is exactly the shape a bad merge-forward resolution takes.
2. Convention changelogs are unpoliced
docs/conventions/*/CHANGELOG.mdare unversioned by any manifest, so--check-bumpnever reads them — which is precisely why--check-orderwas given its own whole-file scope (the misordering that produced it shipped indocs/conventions/loop-lane/CHANGELOG.md).3. The fork-point read is fail-open
git show "$merge_base:$changelog" 2>/dev/null | changelog_versions -inside a process substitution: a git read that genuinely fails yields an empty fork-point set, which reads as "nothing to preserve" and passes.git ls-treedistinguishes the two cases exactly — a path missing from the tree is exit 0 with empty output, an unusable rev is a non-zero exit — wheregit cat-file -ecannot (it exits 128 for both, so probing with it would fail every change set that adds a changelog).Shape of a fix
Preservation as its own diff mode over every changed changelog under both roots
--check-ordersweeps, wired as an additional PR-only step in the existingchangelog-parity-gatejob so no required-context name changes. It must replace the in-loop check rather than sit beside it — two overlapping absorption checks is the drift the file's own header warns against.A worked implementation exists on branch
fix/2264-changelog-preservation(PR #2298, closed as a duplicate of #2290 before it could land, and not reopenable): 21 tests including[YANKED], heading reformat, whole-file deletion, convention-changelog absorption, a stale-branch false-positive guard, and a forced-gawkSIGPIPE fixture. Swept over 60 replayedmaincommits, 7789 headings compared, firing on04822fc4and nowhere else. It needs rebasing onto #2290 and #2341.Related
#2264 (the defect), #2290 (the fix that shipped), #2327 (the false positives), #2341 (their repair).