Dropping the pull_request gate made merge_base == HEAD a routine path rather than a degradation, and the success line did not follow. On every push to main the step printed "all N release heading(s) at the merge base are still present" — a containment claim on the one event where deletion is undetectable, since the comparison is the file against itself. That is the dishonesty this PR fixed in the skip messages, surviving in the success message. The line now has two forms: containment vacuous, naming uniqueness as the half that ran, or the existing containment wording when a real base exists. Both pinned, including that they do not collapse. Also scopes the ci.yml negative pin to the monotonic step's own block. As a file-wide assertion it forbade any FUTURE step in ci.yml from being pull_request-gated and would have failed citing #133 when one legitimately was; a companion assert keeps the extractor from silently matching nothing and turning the negative into a tautology. Ported from heavy-duty/box#144, where the defect was found after this PR's approvals had landed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
12 KiB
Bash
Executable file
250 lines
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# changelog-monotonic.sh [<base-ref>] [<changelog>] — assert that no SHIPPED
|
|
# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings
|
|
# on HEAD must be a SUPERSET of the set at the merge base.
|
|
#
|
|
# Ported from box (heavy-duty/box#122, caught in review of box#118) for #133,
|
|
# because cast's release-notes.sh carries the exact awk shape that made box#118
|
|
# dangerous. The failure it exists to catch leaves no trace either. An author
|
|
# adding an entry under '## Unreleased' REPLACES the line below it instead of
|
|
# inserting above it:
|
|
#
|
|
# -## 0.1.1 — 2026-07-19
|
|
# +## Unreleased
|
|
# +
|
|
# +### Fixed
|
|
# +
|
|
# +- **An entry**
|
|
#
|
|
# git merges that cleanly — it is a one-line edit inside a file nobody has
|
|
# touched concurrently — and the shipped section's whole body is silently
|
|
# absorbed into '## Unreleased'. 0.1.1 no longer HAS a section; the notes
|
|
# anchor release-notes.sh extracts by is gone, and the next release cut from
|
|
# that state republishes 0.1.1's prose as if it were new.
|
|
#
|
|
# The ARMING rule (test/release.test.ts, "the changelog is armed for the next
|
|
# entry (rig#66)") is green on exactly that tree, correctly: it asks only
|
|
# whether the TOP section agrees with package.json's version, and deleting
|
|
# '## 0.1.1' leaves '## Unreleased' on top. It is not wrong, it is narrow — it
|
|
# guards ONE heading, the one a PR is about to write under. This guards the
|
|
# REST of the file, the part no single tree can be asked about at all, because
|
|
# "a heading disappeared" is not a property of a tree — it is a property of a
|
|
# DIFF.
|
|
#
|
|
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
|
|
# ceremony (#111) adds one and never removes one; nothing else in the
|
|
# documented flow (CONTRIBUTING.md, "Releasing") touches them. So SUPERSET is
|
|
# exact — it has no legitimate violation to carve an exception for. The stamp
|
|
# is covered for free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS
|
|
# X.Y.Z and removes no X.Y.Z heading, because 'Unreleased' is not one.
|
|
# '## Unreleased' is deliberately NOT in the set this guards — the arming rule
|
|
# owns that heading, keyed on package.json's version, and the ceremony
|
|
# legitimately consumes it.
|
|
#
|
|
# A file of its own, NOT a clause inside the arming assertions, for three
|
|
# reasons. Its input is different (a git history, not two files). Its
|
|
# degradation is different (no base ref is a SKIP, not a failure). And the
|
|
# arming assertions run against constructed in-memory changelog strings that
|
|
# are not git repos at all — folding a git-dependent assert into them would
|
|
# make every one of those cases either skip or lie. Same discipline as
|
|
# release-notes.sh: its own file so test/release.test.ts can drive it.
|
|
|
|
base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}"
|
|
changelog="${2:-CHANGELOG.md}"
|
|
|
|
# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local
|
|
# degradation becomes a red run there instead. A guard that can silently
|
|
# stop guarding is the failure shape this whole family of checks exists to
|
|
# refuse, so the skip path is loud and CI refuses to take it at all.
|
|
strict="${CHANGELOG_MONOTONIC_STRICT:-0}"
|
|
|
|
skip() {
|
|
if [ "$strict" = "1" ]; then
|
|
echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2
|
|
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
|
|
echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2
|
|
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
|
|
exit 1
|
|
fi
|
|
echo "changelog-monotonic: containment SKIPPED — $*"
|
|
echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading"
|
|
echo " half needs the history. In CI this same condition is a hard failure.)"
|
|
exit 0
|
|
}
|
|
|
|
[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; }
|
|
|
|
# The set of RELEASE headings: '## <token> ...' where <token> looks like a
|
|
# version. Field $2, the same split the arming rule 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_raw() {
|
|
awk '
|
|
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
|
|
'
|
|
}
|
|
headings() { headings_raw | sort -u; }
|
|
|
|
# --- uniqueness on HEAD (the box#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.1.1} minus head
|
|
# {0.1.1, 0.1.1} 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.
|
|
#
|
|
# cast is the MORE exposed of the two repos here (#133). release-notes.sh
|
|
# extracts with:
|
|
#
|
|
# /^## / { grab = ($2 == ver); next }
|
|
# grab { print }
|
|
#
|
|
# There is no `exit`. `grab` re-arms on every matching '## ' line, so two
|
|
# '## 0.1.1' headings make the published body ABSORB whatever sits between the
|
|
# copies — and an entry stranded there is dropped from the NEXT release's notes
|
|
# as well. (rig's extractor has `if (found) exit`, so it truncates instead of
|
|
# absorbing — same class, milder symptom. cast has the absorbing one.)
|
|
#
|
|
# This is the shape box#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 — the arming rule happy (the top section was still
|
|
# right), tests and `bash -n` clean — while release-notes.sh re-armed its grab
|
|
# on the second heading and folded post-cut prose into the shipped release
|
|
# body. Note the arming rule's "double re-arm" case counts duplicate
|
|
# '## Unreleased' headings only; duplicate VERSION headings, the ones that
|
|
# reach release-notes.sh, are this script's.
|
|
#
|
|
# 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 box#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
|
|
|
|
# --- everything below needs the HISTORY --------------------------------------
|
|
# Uniqueness is settled. What follows is containment, which compares HEAD
|
|
# against the merge base and therefore genuinely depends on the base ref, the
|
|
# merge base, and the base blob. Each of those can be unavailable for reasons
|
|
# that are not the author's fault (a shallow clone, a fork checkout without the
|
|
# upstream remote, the commit that first adds the changelog), so each degrades
|
|
# rather than failing — which is exactly why the uniqueness half must NOT live
|
|
# down here (#133, box#143). It asks nothing of the history, and gating it
|
|
# behind these conditions let a duplicate exit 0 on a message about deletion.
|
|
#
|
|
# That ordering mattered MORE here than anywhere. cast's release-notes.sh has
|
|
# no `exit`, so `grab` re-arms on every matching '## ' line and a duplicate
|
|
# makes the published body ABSORB whatever sits between the copies — the live
|
|
# extraction bug this guard exists for. The half with that bug behind it was
|
|
# the half with the most ways to silently not run.
|
|
|
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
|
|| skip "not inside a git work tree, so there is no history to compare against"
|
|
|
|
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|
|
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
|
|
|
|
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
|
|
[ -n "$merge_base" ] \
|
|
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
|
|
|
|
# The changelog may not exist at the merge base at all (the commit that adds
|
|
# it). Nothing to have deleted, so nothing to assert.
|
|
base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
|
|
[ -n "$base_file" ] || {
|
|
echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)."
|
|
exit 0
|
|
}
|
|
|
|
base_headings="$(printf '%s\n' "$base_file" | headings)"
|
|
head_headings="$(headings < "$changelog")"
|
|
|
|
# comm -23: lines in the base set that are NOT in the head set — exactly the
|
|
# headings this branch removed.
|
|
missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))"
|
|
|
|
if [ -n "$missing" ]; then
|
|
{
|
|
echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:"
|
|
echo
|
|
printf '%s\n' "$missing" | sed 's/^/ ## /'
|
|
echo
|
|
cat <<EOF
|
|
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
|
|
|
|
Release headings are APPEND-ONLY. The ceremony adds one (#111); nothing ever
|
|
legitimately removes one. So this is not a judgement call — it is a defect,
|
|
and almost always the same one (#133, box#122): an entry written under
|
|
'## Unreleased' REPLACED the heading below it instead of being inserted
|
|
ABOVE it. The shipped section's body is now sitting under '## Unreleased',
|
|
and the version it belonged to has no section at all.
|
|
|
|
Nothing else will say so. git merges that edit cleanly — no conflict, no
|
|
signal — and the arming rule stays green, because the TOP section is still
|
|
the right one for this VERSION. The damage surfaces at the NEXT release,
|
|
when release-notes.sh cannot find the section it extracts by heading, or
|
|
worse, republishes the absorbed prose as if it were new.
|
|
|
|
The fix is to put the heading back and INSERT above it, never over it:
|
|
|
|
## Unreleased
|
|
|
|
### Fixed
|
|
|
|
- **Your entry**
|
|
|
|
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
|
|
|
|
If you are genuinely renaming a released version, that is a rewrite of
|
|
history this guard is meant to stop; say so in the PR and change the guard
|
|
deliberately, in its own commit.
|
|
EOF
|
|
} >&2
|
|
exit 1
|
|
fi
|
|
|
|
count="$(printf '%s\n' "$base_headings" | grep -c . || true)"
|
|
head_count="$(printf '%s\n' "$head_headings" | grep -c . || true)"
|
|
|
|
# The success line has two honest forms, because this step now runs on two
|
|
# shapes of event. On a push to main the merge base IS HEAD: containment
|
|
# compared the file against itself and asserted nothing, and deletion is
|
|
# undetectable on that event by construction. Reporting "all N still present"
|
|
# there would be the same dishonesty the skip messages were fixed for (#133) —
|
|
# a log claiming a check that did no work. Uniqueness is the half that actually
|
|
# ran, so that is the half the line names.
|
|
if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then
|
|
echo "changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked $head_count release heading(s)."
|
|
else
|
|
echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog"
|
|
fi
|