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:
parent
ce57070c14
commit
cb72c71c33
3 changed files with 87 additions and 2 deletions
56
.github/scripts/changelog-monotonic.sh
vendored
56
.github/scripts/changelog-monotonic.sh
vendored
|
|
@ -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")"
|
||||||
|
|
||||||
|
|
|
||||||
12
CHANGELOG.md
12
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
|
`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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue