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
|
# 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