Merge pull request #99 from dan-claude-bot/fix/changelog-monotonic
fix: catch a deleted or duplicated release heading in CHANGELOG.md
This commit is contained in:
commit
9adf45a229
4 changed files with 646 additions and 0 deletions
244
.github/scripts/changelog-monotonic.sh
vendored
Executable file
244
.github/scripts/changelog-monotonic.sh
vendored
Executable file
|
|
@ -0,0 +1,244 @@
|
|||
#!/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)"
|
||||
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 in #98 —
|
||||
# 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
|
||||
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
|
|
@ -8,6 +8,18 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch-depth: 0, for the changelog-monotonic step below and only
|
||||
# for it. That check is about a DIFF — which release headings the
|
||||
# merge base had — so it needs the base branch's history present,
|
||||
# and the default depth-1 checkout has none of it. An explicit
|
||||
# `git fetch origin <base>` would be narrower, but it has to be
|
||||
# right on both event types and on fork PRs, and getting it subtly
|
||||
# wrong degrades to a SKIP (a guard that silently stops guarding —
|
||||
# the exact failure this repo keeps refusing). Full history on a
|
||||
# pure-bash tree costs a second; the STRICT flag below turns any
|
||||
# remaining skip red rather than green.
|
||||
fetch-depth: 0
|
||||
- name: shellcheck
|
||||
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
|
||||
# globstar so a script in a new subdirectory is linted without anyone
|
||||
|
|
@ -39,6 +51,32 @@ jobs:
|
|||
run: bash test/labels-reconcile.sh
|
||||
- name: release-flow tests
|
||||
run: bash test/release.sh
|
||||
# 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. 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 || 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
|
||||
|
|
|
|||
89
CHANGELOG.md
89
CHANGELOG.md
|
|
@ -8,6 +8,95 @@ on the way to cutting its first release, and this file starts there.
|
|||
|
||||
### Fixed
|
||||
|
||||
- **Deleting a shipped release heading from `CHANGELOG.md` is now caught, on
|
||||
every PR** (#98, heavy-duty/box#122) — the arming rule (#66) asks one
|
||||
question about **one** heading: does the top section agree with `VERSION`?
|
||||
It says nothing about the rest of the file, and the failure that lives there
|
||||
is an author adding an entry under `## Unreleased` who *replaces* the line
|
||||
`## 0.2.0 — 2026-07-19` below it instead of inserting above it. git merges
|
||||
that cleanly — a one-line edit in a file nobody touched concurrently, so no
|
||||
conflict and no signal — and `changelog_armed()` stays green on that exact
|
||||
tree, correctly: `## Unreleased` is still on top and still right for
|
||||
`VERSION`. Verified here rather than assumed: the arming rule was run
|
||||
against a deliberately mangled copy of this repo's real tree and passed,
|
||||
while `changelog_section()` extracted `0.2.0` to zero lines. The damage
|
||||
would have surfaced a whole release later, when `release.yml` refused to
|
||||
publish an empty section it could no longer find by heading.
|
||||
|
||||
`.github/scripts/changelog-monotonic.sh` asserts the complementary
|
||||
invariant: release headings are **append-only**, so the set of `## X.Y.Z`
|
||||
headings on a branch must be a **superset** of the set at its merge base.
|
||||
Its own script, not a clause in the arming check, because "a heading
|
||||
disappeared" is a property of a DIFF, not of a tree — and because the arming
|
||||
rule is driven from `test/release.sh` against constructed `VERSION` +
|
||||
`CHANGELOG.md` pairs that are not git repos and could not express it. The
|
||||
ceremony's stamp passes by construction (it *adds* `X.Y.Z` and removes none;
|
||||
`Unreleased` fails the version shape and is deliberately outside the guarded
|
||||
set), and no reachable base ref is a loud SKIP locally but a hard failure in
|
||||
CI, which sets `CHANGELOG_MONOTONIC_STRICT=1` and now checks out with
|
||||
`fetch-depth: 0` so the guard can never quietly stop guarding.
|
||||
|
||||
It carries box's second half too: containment catches a **deleted** heading
|
||||
but cannot catch a **duplicated** one, since 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 version headings are also asserted **unique on HEAD**.
|
||||
rig's symptom there is not box's, and the guard says so in its own words:
|
||||
box's extractor re-arms on every matching `## ` line and *absorbs* whatever
|
||||
sits between the copies, while rig's `changelog_section()` has
|
||||
`if (found) exit` and stops dead at the second copy — a duplicate
|
||||
**truncates**, publishing only what sits between the two headings and
|
||||
silently dropping the release's real body underneath. Different symptom,
|
||||
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 — and the **success** line got the
|
||||
same treatment, because dropping the gate below made `merge_base == HEAD` a
|
||||
routine path rather than a degradation. On a push to main containment
|
||||
compares the file against itself and asserts nothing, so the line now reports
|
||||
containment *vacuous* and names uniqueness as the half that ran, instead of
|
||||
claiming N headings were verified present by a comparison that could not have
|
||||
detected their absence. Both forms are pinned, including a negative that the
|
||||
two do not collapse.
|
||||
|
||||
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. The pin that holds that
|
||||
open is scoped to the step's own block: as a file-wide grep it forbade any
|
||||
FUTURE step in `ci.yml` from being `pull_request`-gated, and a companion
|
||||
check keeps the block extractor from silently matching nothing and turning
|
||||
the negative into a tautology. 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
|
||||
|
|
|
|||
275
test/release.sh
275
test/release.sh
|
|
@ -190,6 +190,281 @@ check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed
|
|||
T="$(armtree headless 0.2.1-dev '# Changelog' '' 'no sections here')"
|
||||
check "arming: a changelog with no sections FAILS" 1 "" armed "$T"
|
||||
|
||||
# --- the monotonicity rule: was a SHIPPED heading deleted? -------------------
|
||||
# #98. Arming asks about ONE heading — does the top section agree with
|
||||
# VERSION? — so it is silent about the rest of the file. The failure it cannot
|
||||
# see is an entry written under '## Unreleased' that REPLACES the heading
|
||||
# below it instead of inserting above it: git merges the one-line edit
|
||||
# cleanly, arming stays green (the top section is still right), and the
|
||||
# shipped release loses its section entirely. "A heading disappeared" is not a
|
||||
# property of a tree, it is a property of a DIFF — so unlike every check
|
||||
# above, these cases need real git repos, which is why the guard is its own
|
||||
# script rather than a function sourced here.
|
||||
MONO="$ROOT/.github/scripts/changelog-monotonic.sh"
|
||||
check "changelog-monotonic.sh: exists and is the guard under test" 0 "" test -f "$MONO"
|
||||
|
||||
# The stock changelog every case below starts from: an Unreleased section and
|
||||
# two shipped releases, committed on branch 'base' — which plays origin/main.
|
||||
# The caller then rewrites CHANGELOG.md on 'work' and commits.
|
||||
MONO_BASE=('# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.')
|
||||
monorepo() { # monorepo <name> -> prints the dir, left checked out 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' "${MONO_BASE[@]}" > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md
|
||||
git -C "$d" commit -qm 'base: two shipped releases'
|
||||
git -C "$d" checkout -q -b work
|
||||
printf '%s' "$d"
|
||||
}
|
||||
monowrite() { # monowrite <dir> <line...> — rewrite CHANGELOG.md and commit
|
||||
local d="$1"; shift
|
||||
printf '%s\n' "$@" > "$d/CHANGELOG.md"
|
||||
git -C "$d" commit -qam 'work: edit the changelog'
|
||||
}
|
||||
mono() { # mono <dir> [VAR=val ...] — run the guard there, base ref 'base'
|
||||
local d="$1"; shift
|
||||
( cd "$d" && env "$@" bash "$MONO" base 2>&1 )
|
||||
}
|
||||
|
||||
# An untouched branch with NO commit of its own: 'work' still points at the
|
||||
# base commit, so the merge base IS HEAD and containment compared the file
|
||||
# against itself. That is the vacuous path (#98), not a containment result —
|
||||
# the green message therefore names uniqueness, the half that actually ran.
|
||||
# A guard that prints nothing is indistinguishable from one that did nothing,
|
||||
# but a guard that prints the WRONG half is worse: it is a false receipt.
|
||||
T="$(monorepo clean)"
|
||||
check "monotonic: an untouched branch passes" 0 "uniqueness on HEAD checked 2" mono "$T"
|
||||
check "monotonic: ...saying containment was VACUOUS, not that it verified 2" 0 \
|
||||
"containment vacuous" mono "$T"
|
||||
# A negative, because the point is that the two wordings do NOT collapse: with
|
||||
# the pull_request gate gone (#98) this is the shape of EVERY push to main, and
|
||||
# "are still present" there would be a containment claim on the one event where
|
||||
# deletion is undetectable by construction.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "monotonic: ...and never claims the headings are still present" 1 "" \
|
||||
bash -c 'cd "$1" && bash "$2" base | grep -q "are still present"' _ "$T" "$MONO"
|
||||
|
||||
# The same shape against a REAL base — an unrelated commit on 'work', the
|
||||
# changelog untouched — which is what an untouched-changelog PR branch
|
||||
# actually looks like. Here containment genuinely ran and held, so this is
|
||||
# the case that pins the containment wording and its count. The two forms
|
||||
# must not collapse into one another.
|
||||
T="$(monorepo clean-realbase)"
|
||||
printf '%s\n' '# rig' > "$T/README.md"
|
||||
git -C "$T" add README.md
|
||||
git -C "$T" commit -qm 'work: an unrelated commit, changelog untouched'
|
||||
check "monotonic: an untouched changelog on a REAL base reports containment" 0 \
|
||||
"all 2 release heading(s)" mono "$T"
|
||||
check "monotonic: ...and says they are still present, the containment claim" 0 \
|
||||
"are still present" mono "$T"
|
||||
|
||||
# The legitimate edit this guard must never object to: a new entry INSERTED
|
||||
# above the shipped heading, which is left alone.
|
||||
T="$(monorepo insert)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \
|
||||
'- **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: an entry inserted ABOVE the shipped heading passes" 0 "" mono "$T"
|
||||
|
||||
# ...and the bug itself: the same entry typed OVER '## 0.2.0'. 0.2.0's body is
|
||||
# now under '## Unreleased' and 0.2.0 has no section. RED, naming the version.
|
||||
T="$(monorepo deleted)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: a DELETED shipped heading FAILS (#98)" 1 "DELETES release heading" mono "$T"
|
||||
check "monotonic: ...and the failure names the version that vanished" 1 "## 0.2.0" mono "$T"
|
||||
|
||||
# Deleting the OLDEST release is the same defect, not a lesser one — the set
|
||||
# is a set, position in the file buys no leniency.
|
||||
T="$(monorepo deleted-old)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.'
|
||||
check "monotonic: deleting an OLDER release heading fails too" 1 "## 0.1.0" mono "$T"
|
||||
|
||||
# The duplicate half. Containment cannot catch this: the second copy is
|
||||
# head-side SURPLUS and `comm -23` (base minus head) is blind to extras on the
|
||||
# head side, so uniqueness-on-HEAD is a separate assert. rig's symptom is not
|
||||
# box's — changelog_section() has `if (found) exit`, so it stops at the second
|
||||
# copy and TRUNCATES rather than absorbing.
|
||||
T="$(monorepo dupe)"
|
||||
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 DUPLICATED version heading FAILS" 1 "DUPLICATE release heading" mono "$T"
|
||||
check "monotonic: ...and the failure names the repeated version" 1 "## 0.2.0" mono "$T"
|
||||
# ...and that the duplicate really does truncate, so the assert above is
|
||||
# guarding a live defect rather than a stylistic preference: extraction stops
|
||||
# at the second copy, dropping the body that sits under it.
|
||||
check "monotonic: the duplicate TRUNCATES extraction (rig's symptom, not box's)" 0 \
|
||||
"A pending thing" changelog_section "$T/CHANGELOG.md" 0.2.0
|
||||
check "monotonic: ...the real body under the second copy is dropped" 1 "" \
|
||||
sect_has "$T/CHANGELOG.md" 0.2.0 "A shipped thing"
|
||||
|
||||
# '## Unreleased' is deliberately OUTSIDE the guarded set: it fails the
|
||||
# version shape, so the ceremony stamping it away — the one edit that legally
|
||||
# removes a top heading — is invisible here. This is the case that would make
|
||||
# every release PR unshippable if the set were "all '## ' headings".
|
||||
T="$(monorepo stamp)"
|
||||
monowrite "$T" '# Changelog' '' '## 0.3.0 — 2026-07-20' '' \
|
||||
'- **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: stamping '## Unreleased' into a release passes (not guarded)" 0 "" mono "$T"
|
||||
# ...and the ceremony's re-arm — a fresh empty Unreleased above the stamp —
|
||||
# is equally fine, which is CONTRIBUTING step 1's tree.
|
||||
T="$(monorepo stamp-rearmed)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.3.0 — 2026-07-20' '' \
|
||||
'- **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: the re-armed ceremony tree passes too" 0 "" mono "$T"
|
||||
|
||||
# The skip path, both halves. A base ref that does not resolve is a sensible
|
||||
# local degradation — and a silent one, which is the failure shape this family
|
||||
# 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 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 \
|
||||
"fetch-depth: 0" mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1
|
||||
|
||||
# A missing changelog is an error on any setting — it is not a degradation,
|
||||
# it is a wrong invocation.
|
||||
T="$(monorepo nofile)"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
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: ...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"
|
||||
# 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.
|
||||
#
|
||||
# Scoped to the step's OWN block, deliberately. As a file-wide grep this
|
||||
# negative forbade any FUTURE step in ci.yml from being pull_request-gated and
|
||||
# would have failed citing #98 when one legitimately was — #98 constrains this
|
||||
# step, not the file. The companion check below is what keeps the awk honest:
|
||||
# an extractor that matched nothing would turn the negative into a tautology
|
||||
# that passes forever, including after someone renames the step and re-adds
|
||||
# the gate.
|
||||
# Terminates on a new STEP or a new JOB. The job boundary is not optional: the
|
||||
# monotonic step is the LAST step of its job, so stopping only at the next
|
||||
# `- name:` runs the block into the job below and swallows that job's
|
||||
# level `if:` — the same bug this scoping fixed, moved from "any step in the
|
||||
# file" to "this step plus the head of the next job" (found on box#144).
|
||||
mono_step_block() {
|
||||
awk '/^ - name: no shipped changelog heading/ {f=1; print; next}
|
||||
f && (/^ - / || /^ [^ ]/) {exit}
|
||||
f {print}' "$CIY"
|
||||
}
|
||||
# Anchored: an `if:` inside a `run:` line is not a step condition.
|
||||
mono_step_gated() { mono_step_block | grep -q '^ if:'; }
|
||||
check "ci.yml: the monotonic step itself is NOT pull_request-gated (#98)" 1 "" \
|
||||
mono_step_gated
|
||||
check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \
|
||||
"changelog-monotonic" mono_step_block
|
||||
# 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 "" \
|
||||
grep -qF "fetch-depth: 0" "$CIY"
|
||||
|
||||
# --- release.yml: the pins ---------------------------------------------------
|
||||
# The workflow itself runs only on a tag push upstream, so pin its
|
||||
# load-bearing pieces the way the harness pins root-only paths (repo
|
||||
|
|
|
|||
Loading…
Reference in a new issue