forked from heavy-duty/rig
fix: catch a deleted or duplicated release heading in CHANGELOG.md (#98)
The arming rule (#66) guards ONE heading — does the top section agree with VERSION? — and is silent about the rest of the file. The failure that lives there is an author adding an entry under `## Unreleased` who replaces the shipped heading below it instead of inserting above it. git merges the one-line edit cleanly, `changelog_armed()` stays green (correctly: the top section is still right), and the shipped release loses its section entirely. It surfaces a whole release later, when release.yml refuses to publish a section `changelog_section()` can no longer find by heading. "A heading disappeared" is a property of a DIFF, not of a tree, so this is its own script rather than a clause in the arming check — which is also driven from test/release.sh against constructed non-git VERSION + CHANGELOG pairs that could not express it. The rule needs no tuning: release headings are append-only, so SUPERSET is exact, and the ceremony's stamp passes by construction because `Unreleased` fails the version shape. Ported from heavy-duty/box#122, with box's second half intact: containment cannot catch a DUPLICATED heading, since the duplicate is head-side surplus and `comm -23` is blind to extras there — so uniqueness on HEAD is asserted alongside it. rig's symptom differs from box's and the comments say so: box's extractor re-arms on every `## ` line and ABSORBS what sits between the copies, while rig's `changelog_section()` has `if (found) exit` and TRUNCATES at the second copy, dropping the release's real body. Wired on pull requests only (on a push to main the merge base is HEAD, so the assert is vacuous), with CHANGELOG_MONOTONIC_STRICT=1 and fetch-depth: 0 so a checkout that cannot reach the base ref fails rather than skipping quietly forever. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ac1bb3bf76
commit
b2e7febf08
4 changed files with 435 additions and 0 deletions
218
.github/scripts/changelog-monotonic.sh
vendored
Normal file
218
.github/scripts/changelog-monotonic.sh
vendored
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
#!/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 " 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.)"
|
||||
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
|
||||
# shape and is excluded by construction.
|
||||
headings_raw() {
|
||||
awk '
|
||||
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
|
||||
'
|
||||
}
|
||||
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
|
||||
# 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
|
||||
|
||||
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"
|
||||
27
.github/workflows/ci.yml
vendored
27
.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,21 @@ 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
|
||||
# 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'
|
||||
env:
|
||||
CHANGELOG_MONOTONIC_STRICT: '1'
|
||||
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref }}"
|
||||
|
||||
# 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
|
||||
|
|
|
|||
42
CHANGELOG.md
42
CHANGELOG.md
|
|
@ -8,6 +8,48 @@ 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.
|
||||
|
||||
- **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
|
||||
|
|
|
|||
148
test/release.sh
148
test/release.sh
|
|
@ -190,6 +190,154 @@ 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: the head heading-set equals the base's, so containment
|
||||
# holds trivially. The green message names the count it checked, because a
|
||||
# guard that prints nothing is indistinguishable from one that did nothing.
|
||||
T="$(monorepo clean)"
|
||||
check "monotonic: an untouched branch passes" 0 "all 2 release heading(s)" 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 locally" 0 "SKIPPED" mono_noref "$T"
|
||||
check "monotonic: ...and the skip says nothing was checked" 0 "Nothing was checked" 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"
|
||||
|
||||
# --- 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"
|
||||
# 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