fix: the guard also refuses a DUPLICATED release heading, not just a deleted one

Review found the first cut incomplete, and the gap is the shape the #118
incident actually had. Containment catches a heading that VANISHED. It cannot
catch one that was DUPLICATED: 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`. Multiset comparison does not close it either, for the same
reason: base {0.8.0} minus head {0.8.0, 0.8.0} is still empty. The assert that
does close it is uniqueness of version headings ON HEAD, kept ALONGSIDE
containment rather than replacing it, since containment remains the right
check for deletions.

That matters because the duplicate is not hypothetical. It is what a bad
rebase of #116 produced an hour before this commit: two
`## 0.8.0 - 2026-07-19` headings with the incoming entry stranded between
them. Every other guard was green on that tree -- no conflict markers,
changelog-armed.sh happy because the top section was still right for the
VERSION, tests and shellcheck clean -- while release-notes.sh re-armed its
grab on the second heading and folded post-cut prose into the shipped 0.8.0
body, and the stranded entry would have been dropped from the next release's
notes as well.

Uniqueness fires on nothing legitimate: the ceremony stamps a NEW version, and
'Unreleased' fails the version shape before it reaches here.

Both trees are now pinned in test/release.sh side by side -- 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 separately. The
duplicate case also pins that the containment arm stays silent on it, so a
future simplification cannot collapse the two asserts into one.

Proven non-vacuous: stubbing the uniqueness check to empty turns the suite red
on exactly the three new assertions (117/3); restoring it returns 120/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-20 14:08:56 +00:00
parent ce57070c14
commit cb72c71c33
3 changed files with 87 additions and 2 deletions

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