fix: refuse a PR that deletes a shipped changelog heading #126
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
|
||||
# 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 <<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)"
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue