fix: the release ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108) #110
5 changed files with 337 additions and 1 deletions
145
.github/scripts/changelog-armed.sh
vendored
Executable file
145
.github/scripts/changelog-armed.sh
vendored
Executable file
|
|
@ -0,0 +1,145 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# changelog-armed.sh [<changelog>] [<version-file>] — assert that
|
||||||
|
# CHANGELOG.md is ARMED: that there is a heading for the next PR's entry to
|
||||||
|
# land under, and that it is the right one for the state this tree is in.
|
||||||
|
#
|
||||||
|
# The failure it exists to catch (#108, heavy-duty/rig#66) leaves no trace:
|
||||||
|
# the ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and
|
||||||
|
# nothing puts the heading back. A PR authored BEFORE the release wrote its
|
||||||
|
# entry under '## Unreleased'; that heading is gone by the time it merges, so
|
||||||
|
# git lands the entry under whatever heading now occupies that position — the
|
||||||
|
# just-shipped section — CLEANLY, with no conflict. The one signal an author
|
||||||
|
# would trust ("git told me to look") is absent exactly when the result is
|
||||||
|
# wrong, and the drift is only ever discovered by reading the file.
|
||||||
|
#
|
||||||
|
# The rule, keyed on VERSION, because the two states are genuinely different:
|
||||||
|
#
|
||||||
|
# VERSION ends in -dev -> the top section MUST be '## Unreleased'
|
||||||
|
# VERSION is bare -> the top section may be '## Unreleased' (armed,
|
||||||
|
# the ceremony's own re-arm) or the stamped
|
||||||
|
# section for exactly that VERSION — AND the
|
||||||
|
# section for that VERSION must exist and carry
|
||||||
|
# prose, because it is the one about to ship
|
||||||
|
#
|
||||||
|
# Keying on VERSION is the whole design, and the reason this is not simply
|
||||||
|
# "require '## Unreleased'". That unconditional form is what rig#44 and
|
||||||
|
# heavy-duty/cast#108 had to REVERT: it is false by construction on the
|
||||||
|
# ceremony PR's own tree, which makes the release unshippable through a green
|
||||||
|
# CI. Anyone tempted to simplify this back should read those two first.
|
||||||
|
#
|
||||||
|
# The consequence worth stating plainly: a ceremony PR that stamps and forgets
|
||||||
|
# to re-arm still passes here — its VERSION is bare, and a bare tree is
|
||||||
|
# allowed to be stamped. It goes red the moment the '-dev' bump lands on main,
|
||||||
|
# which release.yml does automatically in the same job as the publish. So the
|
||||||
|
# guard does not block the release; it refuses to let main SIT disarmed, which
|
||||||
|
# is the window a late PR can fall into.
|
||||||
|
#
|
||||||
|
# A file of its own (not inlined in ci.yml) so test/release.sh can drive it
|
||||||
|
# against constructed trees for both states — the same discipline as
|
||||||
|
# release-notes.sh.
|
||||||
|
|
||||||
|
changelog="${1:-CHANGELOG.md}"
|
||||||
|
version_file="${2:-VERSION}"
|
||||||
|
|
||||||
|
# release-notes.sh lives beside this script; the bare-VERSION branch runs it
|
||||||
|
# rather than re-implementing the extraction, so the guard and the publisher
|
||||||
|
# cannot disagree about what a section is or when one counts as empty.
|
||||||
|
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
[ -f "$changelog" ] || { echo "changelog-armed: no such file: $changelog" >&2; exit 1; }
|
||||||
|
[ -f "$version_file" ] || { echo "changelog-armed: no such file: $version_file" >&2; exit 1; }
|
||||||
|
|
||||||
|
ver="$(tr -d '[:space:]' < "$version_file")"
|
||||||
|
[ -n "$ver" ] || { echo "changelog-armed: $version_file is empty" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The TOP section: the first '## ' heading in the file. Everything above it is
|
||||||
|
# the changelog's own preamble and belongs to no section.
|
||||||
|
top="$(grep -m1 '^## ' "$changelog" || true)"
|
||||||
|
[ -n "$top" ] || {
|
||||||
|
echo "changelog-armed: $changelog has no '## ' section at all — nothing for a PR entry to land under" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# '## 0.7.0 — 2026-07-19' -> '0.7.0'. Split on whitespace, same shape
|
||||||
|
# release-notes.sh matches on, so the two cannot disagree about what a
|
||||||
|
# section header is.
|
||||||
|
top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')"
|
||||||
|
|
||||||
|
case "$ver" in
|
||||||
|
*-dev)
|
||||||
|
if [ "$top_ver" != "Unreleased" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
changelog-armed: VERSION is '$ver' (a development tree) but the top section of
|
||||||
|
$changelog is:
|
||||||
|
|
||||||
|
$top
|
||||||
|
|
||||||
|
A -dev tree MUST carry '## Unreleased' at the top. Without it, a PR that
|
||||||
|
wrote its entry under '## Unreleased' before the release merges CLEANLY into
|
||||||
|
the section above — the one that already shipped — and the changelog quietly
|
||||||
|
misattributes it (#108, heavy-duty/rig#66).
|
||||||
|
|
||||||
|
The fix is to re-arm: add an empty '## Unreleased' immediately above
|
||||||
|
'$top'. The release ceremony is supposed to do this in the same edit that
|
||||||
|
stamps the version — see CONTRIBUTING.md, "Releases".
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# A bare VERSION is the ceremony tree and the merge commit that publishes
|
||||||
|
# it. Both arrangements are legal there: re-armed ('## Unreleased' back on
|
||||||
|
# top, above the section just stamped) or not yet re-armed (the stamped
|
||||||
|
# section still on top). What is NOT legal is a stamped top section naming
|
||||||
|
# some OTHER version — that is a ceremony that stamped the wrong number,
|
||||||
|
# and release.yml would publish a body that is not this release's.
|
||||||
|
if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
changelog-armed: VERSION is '$ver' but the top section of $changelog is:
|
||||||
|
|
||||||
|
$top
|
||||||
|
|
||||||
|
A bare VERSION means this tree is a release. Its top section must be either
|
||||||
|
'## Unreleased' (re-armed after stamping) or the stamped section for '$ver'
|
||||||
|
itself. A stamped section naming a different version means the ceremony
|
||||||
|
stamped the wrong number, and the published release body would come from
|
||||||
|
the wrong section.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# The top heading is deliberately left UNCONSTRAINED above — both ceremony
|
||||||
|
# shapes must stay legal, which is the #44 / cast#108 lesson and is not
|
||||||
|
# negotiable. That asymmetry leaves a gap of its own, the HALF-ceremony
|
||||||
|
# tree: VERSION bumped to the release, a populated '## Unreleased' still on
|
||||||
|
# top, and no stamped section for the version anywhere. The test above is
|
||||||
|
# false on its first clause, short-circuits, and passes. Nothing else
|
||||||
|
# refuses until release.yml extracts the notes — which happens AFTER the
|
||||||
|
# merge, on main, and publishes a release with an empty body, the worst
|
||||||
|
# place for this to land. So make the same assert one step earlier by
|
||||||
|
# running the very script release.yml runs (heavy-duty/rig#67).
|
||||||
|
if ! bash "$here/release-notes.sh" "$ver" "$changelog" >/dev/null 2>&1; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
changelog-armed: VERSION is '$ver' but $changelog has no non-empty section for
|
||||||
|
'$ver'. The top section is:
|
||||||
|
|
||||||
|
$top
|
||||||
|
|
||||||
|
This is a HALF-DONE ceremony: the version was bumped but its section was
|
||||||
|
never stamped — the stamp is MISSING, not misnumbered. A bare VERSION means
|
||||||
|
this tree is a release, and the section it is about to publish has to exist
|
||||||
|
and have prose in it. Left alone, this passes CI, merges, and only then does
|
||||||
|
release.yml refuse to extract the notes — on main, after the fact, with the
|
||||||
|
release already half-shipped.
|
||||||
|
|
||||||
|
The fix is the ceremony's first edit (CONTRIBUTING.md, "Releases"): stamp
|
||||||
|
'## Unreleased' into '## $ver — DATE', then put an empty '## Unreleased'
|
||||||
|
back above it.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "changelog-armed: VERSION '$ver' agrees with the top section ($top_ver)"
|
||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -25,6 +25,12 @@ jobs:
|
||||||
run: bash test/labels-reconcile.sh
|
run: bash test/labels-reconcile.sh
|
||||||
- name: release-flow tests
|
- name: release-flow tests
|
||||||
run: bash test/release.sh
|
run: bash test/release.sh
|
||||||
|
# The changelog is ARMED for the next entry (#108). Its own step rather
|
||||||
|
# than a line inside test/release.sh: this one asserts a fact about THIS
|
||||||
|
# tree, not about the release machinery, so when it goes red the log
|
||||||
|
# says which check found the drift without anyone reading a suite.
|
||||||
|
- name: changelog is armed for the next entry
|
||||||
|
run: bash .github/scripts/changelog-armed.sh
|
||||||
|
|
||||||
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
||||||
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
||||||
|
|
|
||||||
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -34,6 +34,51 @@ which records not just what changed but what each drill run proved.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let
|
||||||
|
`main` sit disarmed** (#108) — the ceremony stamps `## Unreleased` into
|
||||||
|
`## X.Y.Z — DATE` by hand, and nothing put the heading back, so `main`
|
||||||
|
sat with no `## Unreleased` from the release until the next PR that
|
||||||
|
happened to re-create one. A PR authored *before* the release wrote its
|
||||||
|
entry under `## Unreleased`; with that heading gone, git lands the entry
|
||||||
|
under whatever now occupies the position — **the section that just
|
||||||
|
shipped** — and it merges **cleanly**. No conflict, no error, no red X:
|
||||||
|
the one signal an author would trust is absent exactly when the outcome
|
||||||
|
is wrong, and the changelog credits a released version with a change it
|
||||||
|
does not contain until a human reads the file. Confirmed in the sibling
|
||||||
|
repo (heavy-duty/rig#66); box has not drifted yet, and the reason is
|
||||||
|
luck rather than design — 0.6.0's ceremony (`77599ab`) added its heading
|
||||||
|
*without* removing `## Unreleased`, so main was never disarmed, while
|
||||||
|
0.7.0 did disarm it and left a window that nothing happened to cross.
|
||||||
|
Two halves land together. The ceremony step in `CONTRIBUTING.md` is now
|
||||||
|
explicitly **two edits**: stamp, then put an empty `## Unreleased` back
|
||||||
|
above the section just stamped — it belongs there and not in
|
||||||
|
`release.yml`, which only ever touches `VERSION`. And
|
||||||
|
`.github/scripts/changelog-armed.sh` enforces it in CI, keyed on
|
||||||
|
`VERSION` because the two states are genuinely different: a `-dev` tree
|
||||||
|
must carry `## Unreleased` on top, a bare-`VERSION` tree (the ceremony
|
||||||
|
PR, and the merge that publishes it) may carry either that or its own
|
||||||
|
stamped section. The keying is the whole design and not an
|
||||||
|
over-complication — box previously had **no** top-section guard at all,
|
||||||
|
and the obvious one, an unconditional `## Unreleased` requirement, is
|
||||||
|
false by construction on the ceremony PR's own tree, which is why rig#44
|
||||||
|
and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm
|
||||||
|
does not block the release; it turns `main` red on the very next push,
|
||||||
|
the automatic `-dev` bump the release itself makes. Leaving the bare
|
||||||
|
branch's top heading unconstrained is what keeps both ceremony shapes
|
||||||
|
legal, and a review round on the sibling fix (heavy-duty/cast#114) found
|
||||||
|
the gap that asymmetry leaves: a **half-ceremony** tree — `VERSION`
|
||||||
|
bumped, `## Unreleased` still populated on top, and the section for that
|
||||||
|
version never stamped — makes the wrong-number test false on its first
|
||||||
|
clause, short-circuits, and passes. Nothing then refuses until
|
||||||
|
`release.yml` extracts the notes, which is *after* the merge, on `main`,
|
||||||
|
with the release already half-shipped. So the bare branch now also
|
||||||
|
requires that the section it is about to publish exists and is non-empty,
|
||||||
|
and it asserts that by running `release-notes.sh` — the very script
|
||||||
|
`release.yml` runs — so the guard and the publisher cannot drift apart
|
||||||
|
over what a section is. The message is its own: a missing stamp is not a
|
||||||
|
misnumbered one, and an operator sent to correct a version number that is
|
||||||
|
already right will not find the real problem. Matches
|
||||||
|
heavy-duty/rig#67, so the three repos agree.
|
||||||
- **`box restore` asks before it destroys — and the confirmation prompt is
|
- **`box restore` asks before it destroys — and the confirmation prompt is
|
||||||
now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly
|
now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly
|
||||||
discard user state, and only one of them asked. The table gave `restore`
|
discard user state, and only one of them asked. The table gave `restore`
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,48 @@ A release is a PR, and merging it ships it
|
||||||
1. **The release PR** — `release: X.Y.Z`, labeled `release` — bumps `VERSION`
|
1. **The release PR** — `release: X.Y.Z`, labeled `release` — bumps `VERSION`
|
||||||
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
|
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
|
||||||
date (feature PRs land their changelog entry as part of the PR, so the
|
date (feature PRs land their changelog entry as part of the PR, so the
|
||||||
section is already written). This PR is where the release ritual hangs:
|
section is already written).
|
||||||
|
|
||||||
|
**Stamping is two edits, not one — the second is re-arming.** After
|
||||||
|
rewriting `## Unreleased` into `## X.Y.Z — DATE`, put an **empty
|
||||||
|
`## Unreleased` back at the top**, immediately above the section you just
|
||||||
|
stamped:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
## 0.7.1 — 2026-07-19
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Not cosmetic, and not deferrable to the next PR that happens to need it.
|
||||||
|
Between the stamp and the next re-creation of that heading, `main` has no
|
||||||
|
`## Unreleased`. A PR authored *before* the release wrote its entry under
|
||||||
|
that heading; with the heading gone, git lands the entry under whatever
|
||||||
|
now occupies the position — **the section that just shipped** — and it
|
||||||
|
merges **cleanly**, no conflict, no signal. The changelog then credits a
|
||||||
|
released version with a change it does not contain, and nothing but a
|
||||||
|
human reading the file will ever say so
|
||||||
|
([#108](https://github.com/heavy-duty/box/issues/108); confirmed in the
|
||||||
|
sibling repo as
|
||||||
|
[heavy-duty/rig#66](https://github.com/heavy-duty/rig/issues/66)).
|
||||||
|
|
||||||
|
CI enforces the arming rule with
|
||||||
|
[.github/scripts/changelog-armed.sh](.github/scripts/changelog-armed.sh),
|
||||||
|
keyed on `VERSION`: a `-dev` tree must carry `## Unreleased` on top; a
|
||||||
|
bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may
|
||||||
|
carry either `## Unreleased` or its own stamped section. That is why the
|
||||||
|
guard cannot simply demand `## Unreleased` unconditionally — the
|
||||||
|
unconditional form is false on the ceremony PR's own tree and makes the
|
||||||
|
release unshippable, which is why rig and cast both reverted it. The
|
||||||
|
practical consequence: forgetting to re-arm does **not** block the release
|
||||||
|
PR, it turns `main` red on the very next push — the automatic `-dev` bump
|
||||||
|
the release itself makes. Do it in the ceremony PR and main is never
|
||||||
|
disarmed at all.
|
||||||
|
|
||||||
|
This PR is where the release ritual hangs:
|
||||||
the full drill on real hardware, recorded in
|
the full drill on real hardware, recorded in
|
||||||
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
|
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
|
||||||
PR, a release still proves the boundary.
|
PR, a release still proves the boundary.
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,105 @@ check "release.yml: the release bumps main to the next -dev itself" 0 "" \
|
||||||
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
|
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
|
||||||
grep -qF "opening the bump PR instead" "$RY"
|
grep -qF "opening the bump PR instead" "$RY"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# changelog-armed.sh (#108) — the changelog has a heading for the NEXT entry.
|
||||||
|
#
|
||||||
|
# The drift this catches produces no conflict and no error: the ceremony
|
||||||
|
# stamps '## Unreleased' away, and a PR authored before the release merges its
|
||||||
|
# entry cleanly into the section that just shipped. box has no top-section
|
||||||
|
# guard at all today — the two checks above pin only that the 0.6.0 and 0.5.0
|
||||||
|
# sections still extract, which a disarmed main passes happily.
|
||||||
|
#
|
||||||
|
# BOTH states are constructed as real trees and the real script is run against
|
||||||
|
# them, because the failure mode of the naive fix is precisely a state
|
||||||
|
# mismatch: an unconditional '## Unreleased' requirement is green on main and
|
||||||
|
# false on the ceremony PR's own tree, which is why rig#44 and
|
||||||
|
# heavy-duty/cast#108 both had to revert one. A test that only drives the
|
||||||
|
# -dev state would have shipped that bug again.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ARMED="$ROOT/.github/scripts/changelog-armed.sh"
|
||||||
|
check "changelog-armed: runnable bash" 0 "" bash -n "$ARMED"
|
||||||
|
|
||||||
|
# tree <dir> <version> <changelog-body...> — a two-file tree to run against
|
||||||
|
tree() {
|
||||||
|
local d="$WORK/$1" v="$2"; shift 2
|
||||||
|
mkdir -p "$d"
|
||||||
|
printf '%s\n' "$v" > "$d/VERSION"
|
||||||
|
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||||
|
echo "$d"
|
||||||
|
}
|
||||||
|
armed() { bash "$ARMED" "$1/CHANGELOG.md" "$1/VERSION"; }
|
||||||
|
|
||||||
|
# --- the -dev steady state: armed is the only legal shape ------------------
|
||||||
|
T="$(tree dev-armed 0.7.1-dev '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||||
|
check "armed: a -dev tree with '## Unreleased' on top passes" 0 "agrees" armed "$T"
|
||||||
|
T="$(tree dev-disarmed 0.7.1-dev '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||||
|
check "armed: a -dev tree WITHOUT it fails — the #108 drift, caught" 1 "MUST carry" armed "$T"
|
||||||
|
check "armed: ...and the failure says how to fix it (re-arm)" 1 "re-arm" armed "$T"
|
||||||
|
check "armed: ...naming the issue and its origin" 1 "heavy-duty/rig#66" armed "$T"
|
||||||
|
|
||||||
|
# --- the ceremony PR: bare VERSION, BOTH arrangements legal ----------------
|
||||||
|
# This is the pair that the reverted guards got wrong. Neither may fail, or
|
||||||
|
# the release PR cannot go green and the ceremony is unshippable.
|
||||||
|
T="$(tree rel-stamped 0.7.1 '## 0.7.1 — 2026-07-19' '' '- **This release**')"
|
||||||
|
check "armed: a bare VERSION with its OWN stamped section on top passes" 0 "agrees" armed "$T"
|
||||||
|
T="$(tree rel-rearmed 0.7.1 '## Unreleased' '' '## 0.7.1 — 2026-07-19' '' '- **This release**')"
|
||||||
|
check "armed: ...and so does the RE-ARMED ceremony tree (the shape #108 asks for)" \
|
||||||
|
0 "agrees" armed "$T"
|
||||||
|
# The one bare-VERSION arrangement that is wrong: a stamp naming another
|
||||||
|
# version. release.yml would publish a body that is not this release's.
|
||||||
|
T="$(tree rel-wrong 0.7.1 '## 0.7.0 — 2026-07-19' '' '- **Some other release**')"
|
||||||
|
check "armed: a bare VERSION under someone ELSE's stamped section fails" 1 "wrong number" armed "$T"
|
||||||
|
|
||||||
|
# --- the HALF-ceremony: the gap the two bare-VERSION clauses leave ---------
|
||||||
|
# VERSION bumped to the release, '## Unreleased' still populated on top, and
|
||||||
|
# the section for that version never stamped at all. The wrong-number test
|
||||||
|
# above is false on its FIRST clause here and short-circuits, so before
|
||||||
|
# heavy-duty/rig#67's rule this tree passed the guard and was refused instead
|
||||||
|
# by release.yml — at publish time, after the merge, on main, with the release
|
||||||
|
# already half-shipped. Caught here one step earlier, by running the same
|
||||||
|
# extraction release.yml runs.
|
||||||
|
T="$(tree rel-half 0.8.0 '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||||
|
check "armed: a bare VERSION whose section was never stamped fails (half-ceremony)" \
|
||||||
|
1 "no non-empty section" armed "$T"
|
||||||
|
check "armed: ...and names the stamp as MISSING, not misnumbered" \
|
||||||
|
1 "MISSING, not misnumbered" armed "$T"
|
||||||
|
# The wording is the whole point of the separate branch: an operator sent to
|
||||||
|
# fix a version number that is already correct will not find the real problem.
|
||||||
|
not_wrong_number() { ! armed "$1" 2>&1 | grep -qF 'wrong number'; }
|
||||||
|
check "armed: ...and not as the wrong-number case, which has a different fix" \
|
||||||
|
0 "" not_wrong_number "$T"
|
||||||
|
# A section that exists but carries no prose is the same failure: release.yml
|
||||||
|
# would publish an empty body, which is what release-notes.sh already refuses.
|
||||||
|
T="$(tree rel-empty 0.7.1 '## 0.7.1 — 2026-07-19' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||||
|
check "armed: a bare VERSION whose section is stamped but EMPTY fails" \
|
||||||
|
1 "no non-empty section" armed "$T"
|
||||||
|
|
||||||
|
# --- degenerate trees refuse rather than pass by accident ------------------
|
||||||
|
T="$(tree no-sections 0.7.1-dev 'Prose and no headings at all.')"
|
||||||
|
check "armed: a changelog with no '## ' section at all fails" 1 "no '## ' section at all" armed "$T"
|
||||||
|
check "armed: a missing changelog refuses by path" 1 "no such file" \
|
||||||
|
bash "$ARMED" "$WORK/nope.md" "$ROOT/VERSION"
|
||||||
|
check "armed: a missing VERSION refuses by path" 1 "no such file" \
|
||||||
|
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/nope-version"
|
||||||
|
mkdir -p "$WORK/empty-ver"; : > "$WORK/empty-ver/VERSION"
|
||||||
|
check "armed: an empty VERSION refuses" 1 "is empty" \
|
||||||
|
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/empty-ver/VERSION"
|
||||||
|
|
||||||
|
# --- and the tree under test, which is the assertion that actually fires ---
|
||||||
|
check "armed: THIS tree's VERSION and CHANGELOG.md agree" 0 "agrees" \
|
||||||
|
bash "$ARMED" "$ROOT/CHANGELOG.md" "$ROOT/VERSION"
|
||||||
|
|
||||||
|
# The guard is only a guard if CI runs it, and the ceremony is only re-armed
|
||||||
|
# if the ceremony step says so. Fail-closed pins on both, since a guard nobody
|
||||||
|
# invokes and a step nobody wrote are the two ways this reverts silently.
|
||||||
|
check "ci.yml: runs the changelog-armed guard" 0 "" \
|
||||||
|
grep -qF 'changelog-armed.sh' "$ROOT/.github/workflows/ci.yml"
|
||||||
|
check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \
|
||||||
|
grep -qF 'Stamping is two edits, not one' "$ROOT/CONTRIBUTING.md"
|
||||||
|
check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \
|
||||||
|
grep -qF 'changelog-armed.sh' "$ROOT/CONTRIBUTING.md"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# latest_release_tag — extracted from install.sh (the source-the-pure-function
|
# latest_release_tag — extracted from install.sh (the source-the-pure-function
|
||||||
# trick) and driven against a shim curl. The shim serves the ONE seam the
|
# trick) and driven against a shim curl. The shim serves the ONE seam the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue