Skip to content

check-changelog-parity: a merge-forward that absorbs the predecessor release section passes all three modes — deleted headings are unpoliced #2264

Description

@kyle-sexton

scripts/check-changelog-parity.sh polices the bump thoroughly — heading present at head, absent at base, monotonic above the base tip, correctly bracketed, SIGPIPE-safe. It does not police preservation: a change set can delete an already-released section's heading and absorb its content into the new release, and every mode passes.

Reproduction — all three modes return EXIT=0

Disposable repo, the real script copied in unmodified. Base carries 0.51.8 and 0.51.7. Head bumps to 0.51.9, writes its own note, and folds 0.51.8's bullet into the new section — deleting the ## [0.51.8] heading:

=== heading list at base ===        === heading list at head ===
## [0.51.8]                         ## [0.51.9]
## [0.51.7]                         ## [0.51.7]

0.51.8 heading present at head? 0 (0 == DELETED)

--- --check-bump base-ref
Every plugin whose version changed vs base-ref has a '## [<version>]' CHANGELOG.md entry.   EXIT=0
--- --check-order
All 1 changelog(s) read newest-first with no duplicate versions.                            EXIT=0
--- --check
Every versioned plugin has a CHANGELOG.md ..., and none documents a version above its manifest.  EXIT=0

A released version's notes are gone from the changelog and nothing objects.

Why each mode misses it

  • --check-bump asks only about the bumped version: present at head (line 448), absent at base (line 455), strictly above the base tip (line 414). Every one of those holds. It never enumerates the base's other headings, so a deletion below the new entry is outside what it looks at.
  • --check-order validates newest-first ordering and duplicate versions. 0.51.9, 0.51.7 is correctly ordered and has no duplicates — a gap in the sequence is not a violation.
  • --check compares the manifest version against the changelog's maximum. 0.51.9 documents 0.51.9. Fine.

This is not hypothetical — it happened tonight

During the #2163 / #2171 drain, a lane resolving a CHANGELOG merge-forward reported hitting exactly this:

## [0.51.8] and ### Fixed sat ABOVE the conflict markers, so both #2163's release and mine were written under ONE heading. A naive resolve fuses two releases into one section with no marker to catch it.

That is the mechanism. When two change sets bump the same plugin concurrently, git's conflict region often opens below the newest heading, so a plausible resolution silently produces one section carrying two releases. There is no conflict marker left to catch it, and per the reproduction above, no gate either. It was caught that time only because a human ran a heading-list diff by hand, and the handoff notes for this repo have carried "never relabel or absorb" as folklore for several sessions precisely because nothing enforces it.

Suggested fix

The check is cheap and exact: every ## [x] heading present at the base must still be present at head. Base heading list must be a suffix of head's, with additions only at the top.

comm -23 <(git show "$base:$changelog" | grep '^## \[') <(grep '^## \[' "$changelog")

Non-empty output means a released heading vanished — fail, naming the missing versions. That is strictly stronger than the byte-identical-tail check operators currently run by hand, and it composes with --check-bump's existing loop, which already resolves $base, $changelog and the per-plugin scope.

Worth deciding as part of it:

  • Where it belongs. --check-bump already has the base ref and the plugin scope, so it is the natural home. But preservation is arguably its own concern, and a separate --check-preserved would also run against plugins whose version did not change — which is where an absorbed heading could otherwise hide.
  • Whether deletion is ever legitimate. Rewriting history in a changelog (removing an entry for a yanked release) is a real use case. If so, the escape hatch should be explicit and visible rather than silent — the script already has a precedent for that in its stale-guarded baseline exemption.
  • Renames too. The same resolution can relabel a heading rather than delete it (## [0.51.8] becoming ## [0.51.9]), which this check catches as a deletion — correctly, and worth stating in the failure message so the author understands what happened.

Related

#2154 / #2159 (the SIGPIPE regression in this same gate) — different failure, same file; that one made a present heading read as missing, this one makes a missing heading read as fine.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions