forked from heavy-duty/rig
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base blob. It sat downstream of all three, so every degradation path returned success on a tree carrying a duplicate. The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a bare `exit 0` on a message that was true about deletion and silent about the duplicate in front of it. STRICT could not reach it — STRICT guards the two skip() calls, and that is not one of them. That inverted the two halves. Deletion needs a diff to see; duplication is the one changelog_section() actually mis-renders, stopping at the second heading and truncating the release's real body. The half with the live extraction bug behind it had the most ways to silently not run. Moved, not rewritten. The skip messages now say containment skipped and that uniqueness already passed. The CI step is no longer pull_request-only, with a `github.ref_name` fallback because base_ref is empty on a push and a bare `origin/` under STRICT would redden every push to main. The script is also now 100755, matching cast's copy of the same file. Regression cases pin the ORDER, not the exit code: verified they go red against the pre-fix script (7 failures) and green against the fixed one. Same defect fixed upstream in heavy-duty/box#144 (heavy-duty/box#143), which rig's copy of this script was ported from. Found by claude-bot-andresmgsl and codex-bot-andresmgsl reviewing #99. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
11 KiB
Bash
Executable file
231 lines
11 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.
|
|
#
|
|
# The failure it exists to catch (#98; ported from heavy-duty/box#122, which
|
|
# was caught in review of box#118) leaves no trace. An author adding an entry
|
|
# under '## Unreleased' REPLACES the line below it instead of inserting above
|
|
# it:
|
|
#
|
|
# -## 0.2.0 — 2026-07-19
|
|
# +## Unreleased
|
|
# +
|
|
# +### Fixed
|
|
# +
|
|
# +- **An entry**
|
|
#
|
|
# git merges that cleanly — it is a one-line edit inside a file nobody has
|
|
# touched concurrently — so there is no conflict and no signal. 0.2.0's whole
|
|
# body is now sitting under '## Unreleased', and 0.2.0 has no section at all.
|
|
#
|
|
# The arming rule is green on exactly that tree, correctly. changelog_armed()
|
|
# in test/release.sh asks only whether the TOP section agrees with VERSION,
|
|
# and deleting '## 0.2.0' 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 damage surfaces at the next release, in changelog_section()
|
|
# (.github/scripts/release-lib.sh), which anchors on the heading:
|
|
#
|
|
# awk -v ver="$2" '
|
|
# /^## / { if (found) exit; found = ($2 == ver); next }
|
|
# ...
|
|
#
|
|
# No heading, no section — and release.yml's "refusing to publish an empty
|
|
# release" assert is the first thing that notices, one whole release too late.
|
|
#
|
|
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
|
|
# ceremony (CONTRIBUTING, "Releases") adds one and never removes one; nothing
|
|
# else in the documented flow 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 VERSION, and the ceremony legitimately consumes it.
|
|
#
|
|
# A file of its own, NOT a clause inside test/release.sh's arming check, 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 rule is driven by test/release.sh against constructed VERSION +
|
|
# CHANGELOG.md trees that are not git repos at all — folding a git-dependent
|
|
# assert into it would make every one of those cases either skip or lie.
|
|
# Same discipline as release-lib.sh: its own file so a test 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 changelog_section() uses, so the two
|
|
# 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.2.0} minus head
|
|
# {0.2.0, 0.2.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 box#118's bad rebase produced: two `## 0.2.0 — 2026-07-19`
|
|
# headings with an incoming entry between them. Every other guard stays green
|
|
# — conflict markers absent, the arming rule happy (the top section is still
|
|
# right), tests and shellcheck clean.
|
|
#
|
|
# rig's symptom differs from box's, and the difference matters. box's
|
|
# release-notes.sh RE-ARMS its grab on every matching '## ' line, so a
|
|
# duplicate makes it ABSORB whatever sits between the copies. rig's
|
|
# changelog_section() has `if (found) exit`, so it stops dead at the second
|
|
# copy instead: a duplicate TRUNCATES. The published body is only what sits
|
|
# BETWEEN the two headings, and everything under the second copy — the real
|
|
# body of that release — is silently dropped. Different symptom, same class:
|
|
# no conflict, no red run, discovered only by a human reading the published
|
|
# notes.
|
|
#
|
|
# 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 changelog_section() stops at the FIRST
|
|
'## ' line after the one it matched — so the published body for that version
|
|
is only what sits BETWEEN the copies, and the real body under the second
|
|
copy is dropped from the release notes entirely.
|
|
|
|
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 (#98; fixed upstream in heavy-duty/box#143, where rig's copy of
|
|
# this script came from). It asks nothing of the history, and gating it behind
|
|
# these conditions let a duplicate exit 0 on a message about deletion.
|
|
|
|
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 (CONTRIBUTING,
|
|
"Releases"); nothing ever legitimately removes one. So this is not a
|
|
judgement call — it is a defect, and almost always the same one (#98): 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 changelog_section() cannot find the section it extracts by heading and
|
|
release.yml refuses to publish an empty release — one whole release late.
|
|
|
|
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)"
|
|
echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog"
|