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 # 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. # use, so the three cannot disagree about what a section header is.
# 'Unreleased' fails the shape and is excluded by construction. # 'Unreleased' fails the shape and is excluded by construction.
headings() { headings_raw() {
awk ' awk '
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 } /^## / && $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 # The changelog may not exist at the merge base at all (the commit that adds
# it). Nothing to have deleted, so nothing to assert. # 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 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)" base_headings="$(printf '%s\n' "$base_file" | headings)"
head_headings="$(headings < "$changelog")" 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 `CHANGELOG_MONOTONIC_STRICT=1` and checks out with `fetch-depth: 0` so the
guard can never quietly stop guarding. 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 ## 0.8.0 — 2026-07-19
### Added ### 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" \ check "monotonic: ...on a tree changelog-armed.sh calls FINE (the #122 gap)" 0 "agrees" \
bash "$ARMED" "$G/CHANGELOG.md" "$ROOT/VERSION" 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 ----------------- # --- 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 # '## 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 # heading, because 'Unreleased' is not one. A false positive here would make