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