fix: refuse a PR that deletes a shipped changelog heading #126

Merged
dan-claude-bot merged 2 commits from fix/changelog-heading-monotonicity into main 2026-07-20 14:15:57 +00:00
3 changed files with 87 additions and 2 deletions
Showing only changes of commit cb72c71c33 - Show all commits

View file

@ -83,11 +83,12 @@ merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
# version. Field $2, the same split changelog-armed.sh and release-notes.sh
# use, so the three cannot disagree about what a section header is.
# 'Unreleased' fails the shape and is excluded by construction.
headings() {
headings_raw() {
awk '
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
' | sort -u
'
}
headings() { headings_raw | sort -u; }
# The changelog may not exist at the merge base at all (the commit that adds
# it). Nothing to have deleted, so nothing to assert.
@ -97,6 +98,57 @@ base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
exit 0
}
# --- uniqueness on HEAD (the #118 class) -------------------------------------
# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the
# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to
# extras on the head side — with or without `sort -u`, base {0.8.0} minus head
# {0.8.0, 0.8.0} is empty. Multiset comparison does not close it either, for
# the same reason. The assert that does is uniqueness of version headings ON
# HEAD, kept alongside containment rather than replacing it.
#
# This is the shape #118's bad rebase actually produced: two
# `## 0.8.0 — 2026-07-19` headings with the incoming entry between them. Every
# other guard stayed green — markers absent, changelog-armed.sh happy (the top
# section was still right), tests and shellcheck clean — while
# release-notes.sh re-armed its grab on the second heading and folded post-cut
# prose into the shipped release body.
#
# Nothing legitimate repeats a version heading: the ceremony stamps a NEW
# version, and 'Unreleased' fails the version shape and never reaches here.
dupes="$(headings_raw < "$changelog" | sort | uniq -d)"
if [ -n "$dupes" ]; then
{
echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):"
echo
printf '%s\n' "$dupes" | sed 's/^/ ## /'
echo
cat <<EOF
Each version heading must appear exactly once. A repeat splits one release
into two same-named sections, and release-notes.sh re-arms its extraction on
every matching '## ' line — so the published body for that version absorbs
whatever sits between the copies, and an entry stranded there is dropped from
the NEXT release's notes as well.
This is the #118 shape: an entry meant for '## Unreleased' was inserted after
a shipped heading, and the heading re-added below it. The fix is one heading,
with the entry above it under '## Unreleased':
## Unreleased
### Fixed
- **Your entry**
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
Quick check on any changelog-touching rebase:
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
EOF
} >&2
exit 1
fi
base_headings="$(printf '%s\n' "$base_file" | headings)"
head_headings="$(headings < "$changelog")"

View file

@ -103,6 +103,18 @@ which records not just what changed but what each drill run proved.
`CHANGELOG_MONOTONIC_STRICT=1` and checks out with `fetch-depth: 0` so the
guard can never quietly stop guarding.
Review of this PR found the guard's first cut incomplete, and the gap is the
shape the incident *actually* had. Containment catches a **deleted** heading;
it cannot catch a **duplicated** one, because the duplicate is head-side
surplus and `comm -23` (base minus head) is blind to extras on the head side
— with or without `sort -u`, and multiset comparison does not close it for
the same reason. So the guard now also asserts that version headings are
**unique on HEAD**, alongside containment rather than instead of it. Nothing
legitimate repeats one: the ceremony stamps a new version, and `Unreleased`
fails the version shape. Both trees are pinned in `test/release.sh` — the
deletion near-miss and the real duplicate — each with `changelog-armed.sh`
asserted green on it, which is the whole reason this script exists.
## 0.8.0 — 2026-07-19
### Added

View file

@ -335,6 +335,27 @@ check "monotonic: ...and why nothing else says so (git merges it cleanly)" 1 "gi
check "monotonic: ...on a tree changelog-armed.sh calls FINE (the #122 gap)" 0 "agrees" \
bash "$ARMED" "$G/CHANGELOG.md" "$ROOT/VERSION"
# --- the #118 incident as it ACTUALLY happened: a DUPLICATED heading -------
# The deletion case above is the near-miss. What the bad rebase really produced
# was two '## 0.8.0 — 2026-07-19' headings with the incoming entry stranded
# between them. Containment cannot see this: the duplicate is head-side
# SURPLUS, and `comm -23` (base minus head) is blind to extras on the head side
# — with or without `sort -u`, and multiset comparison does not close it for
# the same reason. Uniqueness on HEAD is the assert that does.
G="$(grepo mono-dup '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**')"
head_changelog "$G" '## Unreleased' '' '## 0.8.0 — 2026-07-19' '' '### Fixed' '' '- **An entry**' '' '## 0.8.0 — 2026-07-19' '' '### Added' '' '- **Shipped prose**'
check "monotonic: a DUPLICATED release heading fails (the #118 shape)" 1 "DUPLICATE release heading" mono "$G" main
check "monotonic: ...and names the repeated heading" 1 "## 0.8.0" mono "$G" main
check "monotonic: ...and says what a repeat does to release-notes extraction" 1 "re-arms its extraction" mono "$G" main
# Containment alone is green on this exact tree — nothing was deleted. Pinned
# so a future simplification cannot collapse the two asserts into one.
# shellcheck disable=SC2016 # $1/$2 are the inner shell's positionals, not ours
check "monotonic: ...on a tree where NOTHING was deleted (containment is blind)" 1 "" \
bash -c 'cd "$1" && bash "$2" main 2>&1 | grep -q "DELETES release heading"' _ "$G" "$MONO"
# And, as with the deletion case, the other guard calls this tree fine.
check "monotonic: ...on a tree changelog-armed.sh calls FINE" 0 "agrees" \
bash "$ARMED" "$G/CHANGELOG.md" "$ROOT/VERSION"
# --- the release ceremony's stamp: an ADD, never a removal -----------------
# '## Unreleased' -> '## 0.8.1 — DATE' adds 0.8.1 and removes no X.Y.Z
# heading, because 'Unreleased' is not one. A false positive here would make