From cb72c71c33106005b76aac7860fa763c50b56a97 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Mon, 20 Jul 2026 14:08:56 +0000 Subject: [PATCH] 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 --- .github/scripts/changelog-monotonic.sh | 56 +++++++++++++++++++++++++- CHANGELOG.md | 12 ++++++ test/release.sh | 21 ++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/.github/scripts/changelog-monotonic.sh b/.github/scripts/changelog-monotonic.sh index 16f681b..904efdf 100755 --- a/.github/scripts/changelog-monotonic.sh +++ b/.github/scripts/changelog-monotonic.sh @@ -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 <&2 + exit 1 +fi + base_headings="$(printf '%s\n' "$base_file" | headings)" head_headings="$(headings < "$changelog")" diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f1db0..e33cef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/test/release.sh b/test/release.sh index c68b734..acb0f46 100644 --- a/test/release.sh +++ b/test/release.sh @@ -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