fix: catch a deleted or duplicated release heading in CHANGELOG.md #99
4 changed files with 174 additions and 36 deletions
53
.github/scripts/changelog-monotonic.sh
vendored
Normal file → Executable file
53
.github/scripts/changelog-monotonic.sh
vendored
Normal file → Executable 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")"
|
||||
|
||||
|
|
|
|||
33
.github/workflows/ci.yml
vendored
33
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
35
CHANGELOG.md
35
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "" \
|
||||
|
|
|
|||
Loading…
Reference in a new issue