fix(changelog-monotonic): check uniqueness before anything base-side (#98)

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>
This commit is contained in:
dan-claude-bot 2026-07-20 20:22:43 +00:00
parent b2e7febf08
commit 7ffc30bacc
4 changed files with 174 additions and 36 deletions

53
.github/scripts/changelog-monotonic.sh vendored Normal file → Executable file
View file

@ -69,26 +69,18 @@ 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: SKIPPED — $*"
echo " (Nothing was checked. In CI this same condition is a hard failure.)"
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; }
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 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
@ -100,14 +92,6 @@ headings_raw() {
}
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.
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."
exit 0
}
# --- 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
@ -167,6 +151,35 @@ EOF
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")"

View file

@ -51,21 +51,32 @@ jobs:
run: bash test/labels-reconcile.sh
- name: release-flow tests
run: bash test/release.sh
# No SHIPPED release heading was deleted (#98). Its own step rather than
# a line inside test/release.sh: that suite drives the arming rule
# against constructed VERSION + CHANGELOG.md trees that are not git
# No SHIPPED release heading was deleted or DUPLICATED (#98). Its own step
# rather than a line inside test/release.sh: that suite drives the arming
# rule against constructed VERSION + CHANGELOG.md trees that are not git
# repos, and this assert needs a git history — folding it in would make
# those cases skip or lie. It is also a DIFFERENT invariant: arming is a
# fact about this tree, monotonicity is a fact about this tree versus
# its merge base. Pull requests only: on a push to main the merge base
# IS HEAD, so the assert is vacuous and would only add a green step that
# proves nothing. STRICT=1 so a checkout that cannot reach the base ref
# fails here instead of skipping quietly forever.
- name: no shipped changelog heading was deleted
if: github.event_name == 'pull_request'
# fact about this tree, monotonicity is a fact about this tree versus its
# merge base. STRICT=1 so a checkout that cannot reach the base ref fails
# here instead of skipping quietly forever.
#
# NOT pull-request-only, and that is the #98 fix at the workflow level.
# The two halves have different vacuity: DELETION is vacuous on a push to
# main (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at
# all, so gating the whole script on `pull_request` left a duplicate that
# reached main by any other route unasserted forever.
#
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
# push event `github.base_ref` is EMPTY, so the argument would collapse to
# a bare `origin/`, which does not resolve — and STRICT=1 correctly
# promotes that to a hard failure, turning every push to main red. With
# the fallback it resolves to the pushed branch, whose merge base with
# HEAD is HEAD or its parent: containment passes vacuously, exactly as the
# old `if` intended, while uniqueness now runs on every push.
- name: no shipped changelog heading was deleted or duplicated
env:
CHANGELOG_MONOTONIC_STRICT: '1'
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}"
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
# Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and
# stands up throwaway containers, and a slow image pull must never delay the

View file

@ -50,6 +50,41 @@ on the way to cutting its first release, and this file starts there.
same class: no conflict, no red run, found only by a human reading the
published notes.
- **...and the uniqueness half no longer sits behind conditions it does not
depend on** (#98, heavy-duty/box#143) — containment is a property of a DIFF
and genuinely needs the base ref, the merge base and the base blob.
Uniqueness is a property of **HEAD alone** and needs none of the three. It
sat downstream of all of them anyway, so every degradation path returned
success on a tree carrying a duplicate in plain sight.
The base-blob case was the worst of the three, because it was not a skip at
all: 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=1` could not reach it — STRICT guards the two `skip()` calls,
and that path is not one of them. Off CI the two skips had the same shape, so
a shallow clone or an unpacked tarball would not look at a duplicate the
author was about to push.
That inverted the value of the two halves. Deletion is the failure that needs
a diff to see; duplication is the one `changelog_section()` actually
mis-renders, stopping dead at the second copy and truncating the release's
real body. The half with the live extraction bug behind it was the half with
the most ways to silently not run.
Fixed by moving, not rewriting: uniqueness now runs directly after the file
exists, before any git access, under a boundary comment saying so. The skip
messages say *containment* skipped and that uniqueness already passed, so a
skip no longer claims nothing was checked. The CI step is also no longer
gated to `pull_request` — deletion is vacuous on a push to main, but
duplication is vacuous on no tree, so a duplicate reaching main by any other
route went unasserted. That gate could not simply be dropped:
`github.base_ref` is empty on a push, and a bare `origin/` under `STRICT=1`
is a hard failure on *every* push to main, so the base ref falls back to
`github.ref_name`. The regression cases pin the ORDER rather than the exit
code, since the clean base-absent tree is green either way — asserting only
the code is what let the original ship. `changelog-monotonic.sh` is also now
`100755`, matching cast's copy of the same file.
- **An unreadable check rollup no longer reads as "nothing is failing"** (#90)
— when `gh pr view` failed, the fallback left the `statusCheckRollup` key
absent entirely, and `(.statusCheckRollup // [])` collapsed that into the

View file

@ -306,8 +306,9 @@ check "monotonic: the re-armed ceremony tree passes too" 0 "" mono "$T"
# of checks exists to refuse. So STRICT flips exactly that case red.
mono_noref() { local d="$1"; shift; ( cd "$d" && env "$@" bash "$MONO" no/such/ref 2>&1 ); }
T="$(monorepo noref)"
check "monotonic: an unresolvable base ref SKIPS locally" 0 "SKIPPED" mono_noref "$T"
check "monotonic: ...and the skip says nothing was checked" 0 "Nothing was checked" mono_noref "$T"
check "monotonic: an unresolvable base ref SKIPS containment locally" 0 "containment SKIPPED" mono_noref "$T"
check "monotonic: ...and the skip says uniqueness already ran, not that nothing did" 0 \
"already ran and passed" mono_noref "$T"
check "monotonic: ...but is a FAILURE under STRICT=1 (what CI sets)" 1 "STRICT=1" \
mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1
check "monotonic: ...and the STRICT failure blames the checkout, not the script" 1 \
@ -320,19 +321,97 @@ T="$(monorepo nofile)"
check "monotonic: a missing changelog file is an error, never a skip" 1 "no such file" \
bash -c 'cd "$1" && bash "$2" base nope.md 2>&1' _ "$T" "$MONO"
# --- #98: uniqueness is a property of HEAD, so nothing base-side may gate it --
# Containment needs the merge base. Uniqueness needs only the file in front of
# it. As first written (and as inherited from heavy-duty/box, fixed there in
# box#144 for box#143) the duplicate check sat DOWNSTREAM of the base-ref,
# merge-base and base-blob conditions, so each of the degradation paths below
# exited 0 on a tree carrying a duplicate in plain sight — the base-blob one
# not even through skip(), but a bare `exit 0` that STRICT could not reach.
#
# These cases pin the ORDER, which is the actual invariant. Every monorepo
# fixture above commits MONO_BASE on 'base', so no case up there ever reaches
# the base-absent branch at all; and asserting the exit code alone is what let
# the original ship, since the clean base-absent case is green either way.
mononocl() { # mononocl <name> -> a repo whose 'base' has NO changelog, on 'work'
local d="$WORK/mono-$1"; mkdir -p "$d"
git -C "$d" init -q -b base
git -C "$d" config user.email harness@example.invalid
git -C "$d" config user.name harness
printf '%s\n' '# rig' > "$d/README.md"
git -C "$d" add README.md
git -C "$d" commit -qm 'base: no changelog yet'
git -C "$d" checkout -q -b work
printf '%s' "$d"
}
monoadd() { # monoadd <dir> <line...> — the branch INTRODUCES CHANGELOG.md
local d="$1"; shift
printf '%s\n' "$@" > "$d/CHANGELOG.md"
git -C "$d" add CHANGELOG.md
git -C "$d" commit -qm 'work: introduce the changelog'
}
# The changelog is absent at the merge base AND the branch introduces a
# duplicate. Before the fix this exited 0 on "nothing could have been deleted".
T="$(mononocl 98-newdup)"
monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
'- **A shipped thing** (#1) — prose.'
check "monotonic: a duplicate introduced where the base had NO changelog is CAUGHT (#98)" 1 \
"DUPLICATE release heading" mono "$T"
check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 \
"DUPLICATE release heading" mono "$T" CHANGELOG_MONOTONIC_STRICT=1
# ...and the clean counterpart still passes, now SAYING uniqueness ran. Without
# this the case above could be satisfied by failing the base-absent path
# outright, which would redden every changelog-introducing branch.
T="$(mononocl 98-newok)"
monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
'- **A shipped thing** (#1) — prose.'
check "monotonic: ...while a CLEAN introduced changelog still passes" 0 \
"nothing could have been deleted" mono "$T"
check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 \
"uniqueness on HEAD already passed" mono "$T"
# No git at all (a tarball, an unpacked release): uniqueness still has
# everything it needs, so a duplicate is caught rather than skipped past.
mkdir -p "$WORK/mono-98-nogit"
printf '%s\n' '# Changelog' '' '## 0.2.0 — 2026-07-19' '' \
'## 0.2.0 — 2026-07-19' > "$WORK/mono-98-nogit/CHANGELOG.md"
check "monotonic: a duplicate OUTSIDE a git work tree is caught (#98)" 1 \
"DUPLICATE release heading" mono "$WORK/mono-98-nogit"
# An unresolvable base ref: same — the skip belongs to containment, not to the
# script, so uniqueness has already run by the time skip() is reachable.
T="$(monorepo 98-nobase)"
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
'- **The first thing** (#0) — prose.'
check "monotonic: a duplicate is caught even when the base ref will not resolve (#98)" 1 \
"DUPLICATE release heading" mono_noref "$T"
# --- ci.yml: the monotonic step is actually wired (#98) ----------------------
# The guard runs from ci.yml, not from this suite, so pin the wiring the same
# way release.yml's is pinned — a script nothing invokes is not a check.
CIY="$ROOT/.github/workflows/ci.yml"
check "ci.yml: runs the monotonic guard" 0 "" \
grep -q "changelog-monotonic.sh" "$CIY"
check "ci.yml: ...on pull requests only (on a push to main it is vacuous)" 0 "" \
grep -qF "github.event_name == 'pull_request'" "$CIY"
check "ci.yml: ...with STRICT=1, so a skip is red rather than quietly green" 0 "" \
grep -qF "CHANGELOG_MONOTONIC_STRICT: '1'" "$CIY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "ci.yml: ...against the PR's base branch" 0 "" \
grep -qF 'origin/${{ github.base_ref }}' "$CIY"
grep -qF 'origin/${{ github.base_ref' "$CIY"
# The step must NOT be pull-request-only. Deletion is vacuous on a push to main
# (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at all, so
# gating the whole script left a duplicate reaching main by any other route
# unasserted. Dropping the gate is only safe with the ref_name fallback:
# `github.base_ref` is EMPTY on a push, a bare `origin/` does not resolve, and
# STRICT=1 promotes that to a hard failure on every push to main.
check "ci.yml: the monotonic step is NOT gated to pull_request (#98)" 1 "" \
grep -qF "if: github.event_name == 'pull_request'" "$CIY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \
grep -qF 'github.base_ref || github.ref_name' "$CIY"
# Without full history the base ref does not resolve, and STRICT turns that
# into a red run — so the fetch depth is load-bearing, not incidental.
check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \