forked from heavy-duty/rig
Drill records move from sections inside drill/RUNS.md to one file per version at drills/<version>.md. The old guard had to parse headings: an em-dash prefix match, an optional " — DATE" tail, a whole-version comparison so 0.3.0-rc1 could not satisfy 0.3.0, and a separate non-blank-body rule. All of it existed only because records shared one file, and both sibling repos shipped a defect out of that complexity in review — a `sed '/./,$!d'` extractor where `.` matches a space (box#149, cast#138), and heading-grammar drift. One file per version makes nearly all of it unrepresentable: 0.3.0.md and 0.3.0-rc1.md are different files, and the filesystem does the whole-version comparison. The awk drill_section() machinery is gone. What survives is the one rule splitting the files does not make unrepresentable: a file of only whitespace is not a record. Plain drills/, NOT .drills/ — a dot-directory is invisible to globs without dotglob, the cause of #70 here and box#116/box#118. drill/RUNS.md is deleted; it was created in this same unmerged PR, held no real records, and its useful reasoning moves to drills/README.md. (box keeps ITS drill/RUNS.md — that one is a genuine harness log.) Also corrects the ordering framing in CONTRIBUTING and the new README: the three repos' drills are INDEPENDENT, run in any order. What makes that safe is that each pins the same fixed set of candidate refs, so box and rig measure the same pair — that, not sequencing, is what dissolves the mutual recursion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
887 lines
48 KiB
Bash
887 lines
48 KiB
Bash
#!/usr/bin/env bash
|
|
# The release flow's testable half (#32): changelog extraction, latest-tag
|
|
# resolution, and the installer's three channels. Dependency-free and
|
|
# NETWORK-FREE — wherever the code under test would call curl, the curl on
|
|
# PATH is a stub this harness wrote. Run: bash test/release.sh
|
|
# Deliberately no `set -e` — the harness asserts on failing commands.
|
|
set -u
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$ROOT" || exit 1
|
|
# The extraction the workflow runs is the extraction under test — one
|
|
# function, sourced by release.yml and by this harness (repo precedent:
|
|
# test/labels-reconcile.sh sourcing the reconciler's decide_state).
|
|
# shellcheck source=.github/scripts/release-lib.sh
|
|
. "$ROOT/.github/scripts/release-lib.sh"
|
|
PASS=0 FAIL=0
|
|
|
|
# check <desc> <want_exit> <want_substr> <cmd...>
|
|
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
|
# contains want_substr.
|
|
check() {
|
|
local desc="$1" want="$2" substr="$3"; shift 3
|
|
local out rc
|
|
out="$("$@" 2>&1)"; rc=$?
|
|
if [ "$rc" -ne "$want" ]; then
|
|
echo "FAIL: $desc — exit $rc, wanted $want"
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
FAIL=$((FAIL + 1)); return
|
|
fi
|
|
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
|
|
echo "FAIL: $desc — output missing '$substr'"
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
FAIL=$((FAIL + 1)); return
|
|
fi
|
|
echo "ok: $desc"; PASS=$((PASS + 1))
|
|
}
|
|
|
|
WORK="$(mktemp -d)"
|
|
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
|
|
|
# --- changelog_section: the release body, extracted --------------------------
|
|
# A fixture changelog with the three heading shapes the flow produces: the
|
|
# bare '## Unreleased', stamped '## X.Y.Z — date' releases, and a last
|
|
# section that runs to EOF.
|
|
FIXCH="$WORK/CHANGELOG.fixture.md"
|
|
cat > "$FIXCH" <<'MD'
|
|
# Changelog
|
|
|
|
History before 0.1.0 lives in git.
|
|
|
|
## Unreleased
|
|
|
|
- an unreleased entry
|
|
|
|
## 0.2.0 — 2026-07-18
|
|
|
|
### Added
|
|
|
|
- **the newer entry** (#42) — prose.
|
|
|
|
### Fixed
|
|
|
|
- a fix in 0.2.0
|
|
|
|
## 0.1.0 — 2026-07-01
|
|
|
|
- the first entry
|
|
MD
|
|
sect_has() { changelog_section "$1" "$2" | grep -qF -e "$3"; }
|
|
check "changelog: extracts the asked-for section" 0 "the newer entry" \
|
|
changelog_section "$FIXCH" 0.2.0
|
|
check "changelog: the whole section, subheadings included" 0 "a fix in 0.2.0" \
|
|
changelog_section "$FIXCH" 0.2.0
|
|
check "changelog: stops at the next release heading" 1 "" \
|
|
sect_has "$FIXCH" 0.2.0 "the first entry"
|
|
check "changelog: never leaks the preceding section" 1 "" \
|
|
sect_has "$FIXCH" 0.2.0 "an unreleased entry"
|
|
check "changelog: the heading itself is not the body" 1 "" \
|
|
sect_has "$FIXCH" 0.2.0 "## 0.2.0"
|
|
first_line() { changelog_section "$1" "$2" | head -n1; }
|
|
check "changelog: leading blank lines are dropped" 0 "### Added" \
|
|
first_line "$FIXCH" 0.2.0
|
|
check "changelog: the bare Unreleased heading matches too" 0 "an unreleased entry" \
|
|
changelog_section "$FIXCH" Unreleased
|
|
check "changelog: the last section runs to EOF" 0 "the first entry" \
|
|
changelog_section "$FIXCH" 0.1.0
|
|
absent() { [ -z "$(changelog_section "$1" "$2")" ]; }
|
|
check "changelog: an unknown version yields NOTHING (the refusal signal)" 0 "" \
|
|
absent "$FIXCH" 3.3.3
|
|
check "changelog: a date-stamped heading never matches by date" 0 "" \
|
|
absent "$FIXCH" 2026-07-18
|
|
|
|
# ...and the SHIPPED changelog fits the extractor. The real file has two
|
|
# legitimate states, and the old check knew only one (#44, found the day the
|
|
# first release PR turned CI red): BETWEEN releases there is an `## Unreleased`
|
|
# section feature PRs append to; on a `release: X.Y.Z` tree — and on main
|
|
# right after it, until the next feature PR — that section IS the stamped
|
|
# `## X.Y.Z — date`. Demanding the literal heading (or, worse, an issue
|
|
# number inside it) made the release PR of the ceremony unshippable by
|
|
# construction. What the guard is FOR is format drift: whatever the top
|
|
# section is called, the exact function release.yml runs must extract it
|
|
# non-empty.
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "CHANGELOG.md: has a top section (Unreleased or a stamped release)" 0 "" \
|
|
bash -c '[ -n "$(grep -m1 "^## " "$1")" ]' _ "$ROOT/CHANGELOG.md"
|
|
|
|
# --- the arming rule: is main's changelog ready for a late merge? ------------
|
|
# #66: stamping the Unreleased heading DISARMS the file. A PR authored before
|
|
# a release and merged after it wrote its entry under `## Unreleased`; once
|
|
# that heading has become `## X.Y.Z — date`, git lands the entry under the
|
|
# release that already shipped — cleanly, no conflict, nothing for the author
|
|
# to notice. It happened here: #60's #58 entry landed inside `## 0.1.0` at
|
|
# 67386b4, repaired two minutes later by 0ff520c.
|
|
#
|
|
# The check above cannot see this, and #44 is why: demanding a literal
|
|
# `## Unreleased` is FALSE BY CONSTRUCTION on the tree the ceremony's own PR
|
|
# produces, which made the release PR unshippable. That relaxation must not
|
|
# be undone.
|
|
#
|
|
# What distinguishes the two states the old guard collapsed is VERSION.
|
|
# A stamped top section is legal exactly when VERSION is bare — the ceremony
|
|
# PR, and main until the -dev bump lands. The moment VERSION carries -dev,
|
|
# main is a place feature PRs merge into, and the top section MUST be
|
|
# `## Unreleased` or the next late merge is misfiled.
|
|
#
|
|
# Note the asymmetry, which is deliberate: on a BARE version the top heading
|
|
# is not constrained at all. The ceremony re-arms in the same PR
|
|
# (CONTRIBUTING step 1), so its tree legitimately carries an EMPTY
|
|
# `## Unreleased` above the section it just stamped — and an empty top
|
|
# section is exactly what the old non-empty assert would have rejected.
|
|
# What must extract non-empty on a bare VERSION is the section that SHIPS,
|
|
# which is the same assert release.yml makes before it publishes.
|
|
#
|
|
# changelog_armed <version> <changelog-file> — 0 armed, 1 disarmed.
|
|
changelog_armed() {
|
|
local ver="$1" file="$2" top
|
|
top="$(grep -m1 '^## ' "$file")"
|
|
[ -n "$top" ] || return 1
|
|
case "$ver" in
|
|
*-dev) [ "$top" = "## Unreleased" ] ;;
|
|
*) [ -n "$(changelog_section "$file" "$ver")" ] ;;
|
|
esac
|
|
}
|
|
|
|
# The guard itself, against the real tree.
|
|
check "CHANGELOG.md: armed for the VERSION it carries (#66)" 0 "" \
|
|
changelog_armed "$(cat "$ROOT/VERSION")" "$ROOT/CHANGELOG.md"
|
|
|
|
# ...and the rule proven against trees built for the purpose, because a guard
|
|
# that is only ever run against a passing tree has not been shown to fail.
|
|
# Each is a real VERSION + CHANGELOG.md pair the flow actually produces.
|
|
armtree() { # armtree <name> <version> <changelog-body...> -> prints the dir
|
|
local d="$WORK/arm-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION"
|
|
shift 2; printf '%s\n' "$@" > "$d/CHANGELOG.md"; printf '%s' "$d"
|
|
}
|
|
armed() { changelog_armed "$(cat "$1/VERSION")" "$1/CHANGELOG.md"; }
|
|
|
|
# The ceremony PR's own tree, re-armed per CONTRIBUTING step 1: VERSION bare,
|
|
# an empty Unreleased sitting above the section it just stamped. GREEN — this
|
|
# is the case #44 was about, and the empty section must not break it.
|
|
T="$(armtree ceremony 0.2.0 '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
|
check "arming: the re-armed ceremony tree passes (#44 stays fixed)" 0 "" armed "$T"
|
|
|
|
# The same ceremony WITHOUT the re-arm — old-style, stamped straight over the
|
|
# heading. Also GREEN: VERSION is bare, so a stamped top is legal. The guard
|
|
# refuses to make the ceremony unshippable, which is the whole #44 lesson.
|
|
T="$(armtree ceremony-old 0.2.0 '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
|
check "arming: an un-re-armed ceremony tree still passes (bare VERSION)" 0 "" armed "$T"
|
|
|
|
# main AFTER release.yml's -dev bump, with the changelog left disarmed. This
|
|
# is #66 exactly, and the state cast sat in at the time of writing. RED.
|
|
T="$(armtree disarmed 0.2.1-dev '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
|
check "arming: a -dev main with a stamped top section FAILS (#66)" 1 "" armed "$T"
|
|
|
|
# The same main, re-armed. The Unreleased section is EMPTY — no feature PR has
|
|
# merged since the release — and that is a correct, expected state. GREEN.
|
|
T="$(armtree rearmed 0.2.1-dev '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
|
check "arming: a -dev main with an EMPTY Unreleased passes (no entries yet)" 0 "" armed "$T"
|
|
|
|
# Steady state between releases: entries accumulating under Unreleased.
|
|
T="$(armtree steady 0.2.1-dev '# Changelog' '' '## Unreleased' '' '### Fixed' '' '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
|
check "arming: the normal between-releases tree passes" 0 "" armed "$T"
|
|
|
|
# A release PR that bumped VERSION but forgot to stamp: the version it claims
|
|
# to ship has no section, so release.yml would publish empty notes. RED here,
|
|
# one round earlier than the workflow's own refusal.
|
|
T="$(armtree unstamped 0.3.0 '# Changelog' '' '## Unreleased' '' '- **A pending thing** (#2) — prose.')"
|
|
check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed "$T"
|
|
|
|
# And a file with no '## ' heading at all is disarmed, not silently fine.
|
|
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"
|
|
|
|
# --- the drill rule: does the version being shipped have a record? ----------
|
|
# CONTRIBUTING ("Releasing") has always required a real-hardware drill and
|
|
# nothing enforced it, so no release in this family has ever carried one: every
|
|
# other ceremony step is checked by a script, and the one that costs an
|
|
# afternoon was checked by a reviewer remembering. A bot finally blocked on it.
|
|
#
|
|
# Records are ONE FILE PER VERSION, at drills/<version>.md. The first cut of
|
|
# this guard kept them as sections in a single drill/RUNS.md and needed a
|
|
# heading grammar, an optional-date tail, a whole-version comparison and a
|
|
# non-blank-body rule to read them back — all of it there only because the
|
|
# records shared a file, and both sibling repos shipped a defect out of it in
|
|
# review. Splitting the files deletes most of these tests along with the code
|
|
# they covered: `0.3.0.md` and `0.3.0-rc1.md` cannot be confused by any
|
|
# grammar, because there is no grammar.
|
|
#
|
|
# Fixtures carry their OWN version file and their OWN drills dir, inside the
|
|
# fixture dir. This is not tidiness — it is heavy-duty/box#146, verbatim:
|
|
# fixtures that read the REPO's VERSION exercised the `-dev` branch on every
|
|
# ordinary tree, so the whole bare-version half of the guard was untested and
|
|
# went red for the first time while somebody was cutting a release. A fixture
|
|
# must state the tree it is about.
|
|
DRILL="$ROOT/.github/scripts/drill-recorded.sh"
|
|
check "drill-recorded.sh: exists and is the guard under test" 0 "" test -f "$DRILL"
|
|
check "drill-recorded.sh: is executable" 0 "" test -x "$DRILL"
|
|
|
|
drilltree() { # drilltree <name> <version> -> prints the dir (no drills/ yet)
|
|
local d="$WORK/drill-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION"
|
|
printf '%s' "$d"
|
|
}
|
|
drillrec() { # drillrec <dir> <version> <line...> — write drills/<version>.md
|
|
mkdir -p "$1/drills"; local f="$1/drills/$2.md"; shift 2
|
|
printf '%s\n' "$@" > "$f"
|
|
}
|
|
drill() { bash "$DRILL" "$1/drills" "$1/VERSION" 2>&1; }
|
|
|
|
# A development tree. Vacuous by construction — every ordinary PR looks like
|
|
# this, and none of them can be asked to have drilled a release that does not
|
|
# exist. It passes with no drills/ directory present AT ALL, which is the
|
|
# state this repo ships in today.
|
|
T="$(drilltree dev 0.2.1-dev)"
|
|
check "drill: a -dev tree passes with NO drills dir at all" 0 "" drill "$T"
|
|
check "drill: ...saying so out loud, not exiting 0 in silence" 0 \
|
|
"nothing to assert" drill "$T"
|
|
|
|
# The release ceremony tree, drilled and recorded. GREEN.
|
|
T="$(drilltree recorded 0.3.0)"
|
|
drillrec "$T" 0.3.0 '# Release drill — 0.3.0 — 2026-07-21' '' \
|
|
'Host: bare Debian 13. Candidate refs pinned: box@1a2b3c4, cast@9a0b1c2.' '' \
|
|
'- convergence, then re-converge: clean' \
|
|
'- db-integration: 14/14' '- runner lifecycle: PASS'
|
|
drillrec "$T" 0.2.0 '# Release drill — 0.2.0 — 2026-07-01' '' 'an older run'
|
|
check "drill: a bare VERSION with a non-empty record for it passes" 0 \
|
|
"records a drill for 0.3.0" drill "$T"
|
|
|
|
# The gate itself: a release tree with no drills/ directory at all. RED,
|
|
# naming the version, because "which release is unevidenced" is the only fact
|
|
# the author needs. This is the state a repo is in the first time it cuts a
|
|
# release under the gate — it must read as a to-do, not a broken invocation.
|
|
T="$(drilltree norecord 0.3.0)"
|
|
check "drill: a bare VERSION with NO drills dir FAILS" 1 \
|
|
"no drill record" drill "$T"
|
|
check "drill: ...and the failure names the version" 1 "VERSION is 0.3.0" drill "$T"
|
|
check "drill: ...and names the file it wanted" 1 "drills/0.3.0.md" drill "$T"
|
|
|
|
# A drills/ that exists but holds nothing for THIS version. Same failure —
|
|
# other releases having been drilled says nothing about this one.
|
|
T="$(drilltree otherversion 0.4.0)"
|
|
drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the previous release'
|
|
check "drill: a drills dir with no file for THIS version FAILS" 1 \
|
|
"no drill record" drill "$T"
|
|
check "drill: ...naming the version that is unevidenced" 1 "VERSION is 0.4.0" drill "$T"
|
|
|
|
# ...and the failure has to say how to get out of it. Both moves are a commit
|
|
# on the PR, and the second one is the point of asking for a RECORD rather
|
|
# than a RESULT: a waiver is allowed, it just cannot be silent.
|
|
check "drill: ...and the failure names the unblock — run the drill" 1 \
|
|
"RUN THE DRILL" drill "$T"
|
|
check "drill: ...and the waiver, recorded, as the other way out" 1 \
|
|
"MAINTAINER WAIVER" drill "$T"
|
|
check "drill: ...and points at the README for what a record contains" 1 \
|
|
"README.md" drill "$T"
|
|
|
|
# An EMPTY file at the right path. This is the failure a laxer guard invites —
|
|
# the ceremony PR touches the file to get green and fills it in never.
|
|
T="$(drilltree emptyfile 0.3.0)"
|
|
mkdir -p "$T/drills"; : > "$T/drills/0.3.0.md"
|
|
check "drill: a PRESENT but EMPTY record FAILS" 1 "no drill record" drill "$T"
|
|
|
|
# ...and WHITESPACE is not a record either. This is the ONE piece of the old
|
|
# section-parsing rule set that splitting the files did not make
|
|
# unrepresentable, so it is the one that still needs a test. It is here because
|
|
# the siblings got it wrong: box#149 and cast#138 both extracted with
|
|
# `sed '/./,$!d'`, where `.` matches a space, so one tab satisfied the gate and
|
|
# shipped an evidence-free release. All three reviewers caught it there.
|
|
# Nothing caught it here, because there was nothing to catch — which is exactly
|
|
# the state in which a later simplification quietly reintroduces it.
|
|
T="$(drilltree blank 0.3.0)"
|
|
drillrec "$T" 0.3.0 ' ' ' ' ''
|
|
check "drill: a record of only spaces, tabs and newlines FAILS (box#149, cast#138)" \
|
|
1 "no drill record" drill "$T"
|
|
|
|
# The version is matched WHOLE, both directions — and now the filesystem does
|
|
# it, since the version IS the filename. A drill run against a release
|
|
# candidate is not evidence for the final release, and the reverse is equally
|
|
# false: in both cases the string that matched is not the artefact that ships.
|
|
T="$(drilltree whole-rc 0.3.0)"
|
|
drillrec "$T" 0.3.0-rc1 '# Release drill — 0.3.0-rc1' '' 'the rc drill'
|
|
check "drill: an -rc1 record does NOT satisfy the bare version" 1 \
|
|
"no drill record" drill "$T"
|
|
T="$(drilltree whole-final 0.3.0-rc1)"
|
|
drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the final drill'
|
|
check "drill: ...and a bare-version record does NOT satisfy the -rc1" 1 \
|
|
"no drill record" drill "$T"
|
|
|
|
# A missing VERSION file is a wrong invocation, not a degradation — there is
|
|
# no version to be lenient about, so it must never read as a pass.
|
|
T="$(drilltree noversion 0.3.0)"
|
|
rm -f "$T/VERSION"
|
|
check "drill: a missing VERSION file is an error, never a pass" 1 "no such file" drill "$T"
|
|
|
|
# The real files, last. The shipped README must be readable, and the guard the
|
|
# repo actually runs must pass on the VERSION the repo actually carries.
|
|
check "drills/README.md: exists" 0 "" test -f "$ROOT/drills/README.md"
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "drills/README.md: documents the one-file-per-version naming rule" 0 "" \
|
|
bash -c 'grep -qF "drills/<version>.md" "$1"' _ "$ROOT/drills/README.md"
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "drills/README.md: says a FAILED drill is still a valid record" 0 "" \
|
|
bash -c 'grep -qi "failed drill is still a valid record" "$1"' _ "$ROOT/drills/README.md"
|
|
# The old single-file record must be gone, not merely unreferenced: a stale
|
|
# drill/RUNS.md would be a second place to write a record that nothing reads.
|
|
check "drill/RUNS.md: is gone — records live one per version now" 1 "" \
|
|
test -e "$ROOT/drill/RUNS.md"
|
|
check "drill-recorded.sh: passes against the real tree" 0 "" \
|
|
bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION"
|
|
# ...and with no arguments at all, since that is how ci.yml invokes it. The
|
|
# defaults must be the paths this repo actually uses.
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "drill-recorded.sh: ...and with its DEFAULT arguments, as CI runs it" 0 "" \
|
|
bash -c 'cd "$1" && bash .github/scripts/drill-recorded.sh' _ "$ROOT"
|
|
|
|
# ci.yml: the guard runs from there, so pin the wiring — a script nothing
|
|
# invokes is not a check (same reasoning as the monotonic pins above).
|
|
check "ci.yml: runs the drill guard" 0 "" grep -q "drill-recorded.sh" "$CIY"
|
|
# ...and is NOT trigger-gated. It is vacuous on every -dev tree already, so an
|
|
# `if:` could only ever exempt the one tree it exists for.
|
|
drill_step_block() {
|
|
awk '/^ - name: a release version has a recorded drill/ {f=1; print; next}
|
|
f && (/^ - / || /^ [^ ]/) {exit}
|
|
f {print}' "$CIY"
|
|
}
|
|
drill_step_gated() { drill_step_block | grep -q '^ if:'; }
|
|
check "ci.yml: the drill step itself is NOT trigger-gated" 1 "" drill_step_gated
|
|
check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \
|
|
"drill-recorded" drill_step_block
|
|
|
|
# CONTRIBUTING must state the gate, and must state what the drill actually is.
|
|
# The three repos' drills are INDEPENDENT — run in any order, on any schedule —
|
|
# and what makes that safe is that each one pins the same fixed set of
|
|
# CANDIDATE refs, so box and rig measure the same pair. That, not sequencing,
|
|
# is what dissolves the mutual recursion (rig builds the host box runs on, and
|
|
# box mints the seeds rig converges). An earlier draft of this doc claimed a
|
|
# fixed box → rig → cast release order; it is wrong, and this pins the
|
|
# correction.
|
|
CONTRIB="$ROOT/CONTRIBUTING.md"
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "CONTRIBUTING: the release flow names the drill gate" 0 "" \
|
|
bash -c 'grep -qF "drills/<version>.md" "$1"' _ "$CONTRIB"
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "CONTRIBUTING: ...and says the three repos' drills are INDEPENDENT" 0 "" \
|
|
bash -c 'grep -qi "drills are independent" "$1"' _ "$CONTRIB"
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "CONTRIBUTING: ...pinned to one fixed set of candidate refs" 0 "" \
|
|
bash -c 'grep -qi "same fixed set of candidate refs" "$1"' _ "$CONTRIB"
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "CONTRIBUTING: ...which is what dissolves the recursion, not sequencing" 0 "" \
|
|
bash -c 'grep -qF "RIG_REF" "$1"' _ "$CONTRIB"
|
|
# The negative that keeps the correction from being re-lost: no fixed release
|
|
# order may be claimed. Nothing requires box to ship before rig.
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "CONTRIBUTING: ...and never claims a fixed box-then-rig release order" 1 "" \
|
|
bash -c 'grep -qi "box first, then rig" "$1"' _ "$CONTRIB"
|
|
|
|
# --- 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
|
|
# precedent: the tag-refusal greps in test/cli.sh).
|
|
RY="$ROOT/.github/workflows/release.yml"
|
|
check "release.yml: exists" 0 "" test -f "$RY"
|
|
check "release.yml: triggers on tag pushes" 0 "" grep -q "tags:" "$RY"
|
|
check "release.yml: sources the shared lib (one extractor, not a copy)" 0 "" \
|
|
grep -q "release-lib.sh" "$RY"
|
|
check "release.yml: the body comes from changelog_section" 0 "" \
|
|
grep -q "changelog_section CHANGELOG.md" "$RY"
|
|
check "release.yml: a tag/VERSION mismatch refuses to create" 0 "" \
|
|
grep -q "refusing to create a release" "$RY"
|
|
check "release.yml: an empty changelog section refuses too" 0 "" \
|
|
grep -q "has no '## " "$RY"
|
|
check "release.yml: gh release create verifies the tag" 0 "" \
|
|
grep -q -- "--verify-tag" "$RY"
|
|
# Ordering: the mismatch assert must precede the create (line compare, the
|
|
# repo's marker-then-box idiom; defaults fail closed).
|
|
assert_at="$(grep -n "refusing to create a release" "$RY" | head -n1 | cut -d: -f1)"
|
|
create_at="$(grep -n "gh release create" "$RY" | head -n1 | cut -d: -f1)"
|
|
check "release.yml: the assert precedes the create" \
|
|
0 "" test "${assert_at:-999999}" -lt "${create_at:-0}"
|
|
|
|
# --- release.yml, the merge path: the pins (#47; box#96's design) ------------
|
|
# Merging the release-labeled ceremony PR IS the release. Same grep-pin
|
|
# treatment for the merge path's load-bearing pieces: the gate, the four
|
|
# fail-loud asserts, the same-job tag+publish, and the surviving tag-push
|
|
# fallback.
|
|
# The merge door rides pushes to MAIN, not pull_request events: a fork PR's
|
|
# pull_request run gets a read-only GITHUB_TOKEN (permissions: cannot raise
|
|
# it), and every ceremony PR this org merges is cross-repo from the bot
|
|
# fork — the tag create would 403 after green asserts (#48 round 1). The
|
|
# label — the operator's intent — is read via the API off the merge commit.
|
|
check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \
|
|
grep -qF "branches: [main]" "$RY"
|
|
# YAML maps are last-key-wins: a second sibling push: key silently replaces
|
|
# the first and kills a door (grok's round-2 catch — the tag fallback had
|
|
# stopped triggering). Exactly ONE push key may exist.
|
|
check "release.yml: exactly one on.push key (duplicate keys drop a door)" 0 "1" \
|
|
grep -cE '^ push:' "$RY"
|
|
check "release.yml: ...and the doors split on the ref (tag door takes tags)" 0 "" \
|
|
grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY"
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
check "release.yml: the release label is read via the API off the merge commit" 0 "" \
|
|
grep -qF 'commits/$MERGE_SHA/pulls' "$RY"
|
|
check "release.yml: a transition without a labeled PR refuses" 0 "" \
|
|
grep -qF "no merged, release-labeled PR is behind this commit" "$RY"
|
|
# The decide step tells the label's two meanings apart (LABELS.md gives
|
|
# `release` to release-flow WORK as well as to the ceremony PR): work under
|
|
# the label is a green NOTICE no-op — in the -dev steady state and in the
|
|
# post-release window (bare, unchanged, already released) — while every
|
|
# half-ceremony refuses. Pin each verdict's message and the gating output.
|
|
check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \
|
|
grep -qF "release-flow work under the release label, not a ceremony" "$RY"
|
|
check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \
|
|
grep -qF "a dev tree is by definition not a release" "$RY"
|
|
check "release.yml: decide — post-release-window work no-ops green" 0 "" \
|
|
grep -qF "release-flow work merged in the post-release window" "$RY"
|
|
check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \
|
|
grep -qF "Refusing to guess" "$RY"
|
|
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
|
check "release.yml: decide gates every later step on ceremony=yes" 0 "" \
|
|
bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$RY"
|
|
check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \
|
|
grep -qF "refusing to publish an empty release" "$RY"
|
|
check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \
|
|
grep -qF "refusing to re-release" "$RY"
|
|
# Same-job matters: a GITHUB_TOKEN-created tag fires no tag-push workflow,
|
|
# so the publish must live NEXT TO the tag creation. The workflow keeps
|
|
# release-on-merge as its last job (pinned by comment there) so the awk
|
|
# range runs to EOF; both acts must land inside it.
|
|
MJOB="$(awk '/^ release-on-merge:/,0' "$RY")"
|
|
mjob_has() { printf '%s' "$MJOB" | grep -qF -e "$1"; }
|
|
check "release.yml: the merge job API-creates the tag itself" 0 "" \
|
|
mjob_has "git/refs"
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
check "release.yml: ...at the pushed main head (github.sha = the merge commit)" 0 "" mjob_has 'sha="$MERGE_SHA"'
|
|
# The release re-arms main itself: the post-release -dev bump is arithmetic,
|
|
# not judgment, so it rides the same job — direct push, PR fallback.
|
|
check "release.yml: the release bumps main to the next -dev itself" 0 "" \
|
|
grep -qF "bump main to the next -dev" "$RY"
|
|
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
|
|
grep -qF "opening the bump PR instead" "$RY"
|
|
check "release.yml: ...and publishes in the SAME job" 0 "" \
|
|
mjob_has "gh release create"
|
|
# Ordering, the marker-then-box idiom again: the last assert's refusal must
|
|
# precede the tag creation (asserts first, acts last; defaults fail closed).
|
|
massert_at="$(grep -n "refusing to re-release" "$RY" | head -n1 | cut -d: -f1)"
|
|
mtag_at="$(grep -n "git/refs" "$RY" | head -n1 | cut -d: -f1)"
|
|
check "release.yml: the merge-path asserts precede the tag" \
|
|
0 "" test "${massert_at:-999999}" -lt "${mtag_at:-0}"
|
|
# ...and the manual path SURVIVES: tag-push trigger plus a push-gated job,
|
|
# the documented fallback and backfill.
|
|
check "release.yml: the tag-push trigger survives (manual fallback intact)" 0 "" \
|
|
grep -qF "tags: ['**']" "$RY"
|
|
check "release.yml: the fallback job is gated to push events" 0 "" \
|
|
grep -qF "github.event_name == 'push'" "$RY"
|
|
|
|
# --- the installer's ref logic, extracted ------------------------------------
|
|
# install.sh must stay a single curl|bash file, so its channel functions live
|
|
# inline; extract them here and drive them for real (the valid_version awk
|
|
# idiom from test/cli.sh), against a stub curl — never the network.
|
|
RL="$WORK/installer-fns.sh"
|
|
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL"
|
|
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL"
|
|
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
|
|
|
|
STUB="$WORK/stub"; mkdir -p "$STUB"
|
|
cat > "$STUB/curl" <<'CURL'
|
|
#!/usr/bin/env bash
|
|
# The harness's curl — never the network. Scripted via env:
|
|
# CURL_STUB_FAIL nonempty -> every call exits 22 (curl's HTTP error)
|
|
# CURL_STUB_REDIRECT what -w %{redirect_url} answers (the HEAD probe)
|
|
# CURL_STUB_OK substring a download URL must carry to succeed
|
|
# CURL_STUB_TARBALL copied to -o's target on a successful download
|
|
# CURL_STUB_LOG every URL asked for, one per line, appended
|
|
set -u
|
|
out="" url="" probe=0
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-o) out="$2"; shift 2 ;;
|
|
-w) probe=1; shift 2 ;;
|
|
-*) shift ;;
|
|
*) url="$1"; shift ;;
|
|
esac
|
|
done
|
|
if [ -n "${CURL_STUB_LOG:-}" ]; then printf '%s\n' "$url" >> "$CURL_STUB_LOG"; fi
|
|
if [ -n "${CURL_STUB_FAIL:-}" ]; then exit 22; fi
|
|
if [ "$probe" -eq 1 ]; then printf '%s' "${CURL_STUB_REDIRECT:-}"; exit 0; fi
|
|
case "$url" in
|
|
*"${CURL_STUB_OK:-/__nothing_succeeds__/}"*) cp "${CURL_STUB_TARBALL:?}" "${out:?}"; exit 0 ;;
|
|
*) exit 22 ;;
|
|
esac
|
|
CURL
|
|
chmod +x "$STUB/curl"
|
|
|
|
rlt() { # rlt [VAR=val ...] — resolve_latest_tag under the stub curl
|
|
# The single-quoted $1 is the inner bash's positional, not this shell's.
|
|
# shellcheck disable=SC2016
|
|
env PATH="$STUB:$PATH" "$@" bash -c 'set -euo pipefail
|
|
. "$1"; resolve_latest_tag heavy-duty/rig' _ "$RL"
|
|
}
|
|
check "resolve: a releases/tag redirect yields the tag" 0 "0.1.0" \
|
|
rlt CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/0.1.0
|
|
# A repo with NO releases redirects to /releases (measured live against
|
|
# heavy-duty/rig itself) — that must fail, never invent a ref.
|
|
check "resolve: the no-releases redirect (/releases) fails" 1 "" \
|
|
rlt CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
|
check "resolve: no redirect at all fails" 1 "" rlt
|
|
check "resolve: a tagless releases/tag/ redirect fails" 1 "" \
|
|
rlt CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/
|
|
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
|
|
rlt CURL_STUB_FAIL=1
|
|
|
|
rcu_line() { # rcu_line <n> — the nth candidate URL for an explicit ref
|
|
bash -c 'set -euo pipefail
|
|
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$1"
|
|
}
|
|
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
|
|
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
|
|
check "candidates: refs/heads is the fallback" 0 \
|
|
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
|
|
|
|
# --- the three channels, driven through the REAL installer -------------------
|
|
# Full install.sh runs against throwaway roots with the stub curl on PATH: the
|
|
# channel selection, the tag-first fallback, and the loud no-releases refusal
|
|
# are all DRIVEN, not grepped (the test/cli.sh install-drill idiom).
|
|
TBDIR="$WORK/tb"; mkdir -p "$TBDIR/rig-7.7.7-relflow/bin"
|
|
cp "$ROOT/bin/rig" "$TBDIR/rig-7.7.7-relflow/bin/rig"
|
|
chmod +x "$TBDIR/rig-7.7.7-relflow/bin/rig"
|
|
echo "7.7.7-relflow" > "$TBDIR/rig-7.7.7-relflow/VERSION"
|
|
tar -C "$TBDIR" -czf "$WORK/release.tgz" rig-7.7.7-relflow
|
|
|
|
rinst() { # rinst <home> <bin> [VAR=val ...] — a real install.sh run, stubbed net
|
|
local h="$1" b="$2"; shift 2
|
|
env -u RIG_REF PATH="$STUB:$PATH" HOME="$FAKEHOME" \
|
|
RIG_ROLE_MARKER="$WORK/no-marker" RIG_HOME="$h" RIG_BIN="$b" \
|
|
CURL_STUB_TARBALL="$WORK/release.tgz" "$@" bash "$ROOT/install.sh"
|
|
}
|
|
|
|
# Channel 1 — RIG_REF unset, a release exists: resolve the tag, download
|
|
# refs/tags/<tag>, and the installed tree records exactly that ref.
|
|
H1="$WORK/h1"; B1="$WORK/b1"
|
|
check "channel latest: resolves and installs the release tag" 0 "done" \
|
|
rinst "$H1" "$B1" \
|
|
CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/7.7.7-relflow \
|
|
CURL_STUB_OK=refs/tags/7.7.7-relflow
|
|
check "channel latest: the tree landed under the tag's version" 0 "" \
|
|
test -x "$H1/versions/7.7.7-relflow/bin/rig"
|
|
check "channel latest: INSTALLED_FROM names the resolved tag" 0 \
|
|
"heavy-duty/rig@7.7.7-relflow" cat "$H1/versions/7.7.7-relflow/INSTALLED_FROM"
|
|
|
|
# Channel 1, transitional — RIG_REF unset, NO release exists (rig today):
|
|
# fail LOUDLY, name RIG_REF=main as the way out, install nothing. The stub
|
|
# would happily serve refs/heads/main here — a silent fallback would pass the
|
|
# download and FAIL this check by succeeding.
|
|
H2="$WORK/h2"; B2="$WORK/b2"
|
|
check "channel latest: no releases yet — dies, never hangs, never falls back" \
|
|
1 "RIG_REF=main" rinst "$H2" "$B2" \
|
|
CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases \
|
|
CURL_STUB_OK=refs/heads/main
|
|
check "channel latest: the refusal says what is missing" 1 "no release" \
|
|
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
|
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
|
|
|
|
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
|
|
# never consulted (a pin resolves nothing).
|
|
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
|
|
check "channel pinned: RIG_REF=<tag> installs from refs/tags" 0 "refs/tags/7.7.7-relflow" \
|
|
rinst "$H3" "$B3" RIG_REF=7.7.7-relflow \
|
|
CURL_STUB_OK=refs/tags/7.7.7-relflow CURL_STUB_LOG="$LOG3"
|
|
check "channel pinned: no releases/latest probe for an explicit ref" 1 "" \
|
|
grep -q "releases/latest" "$LOG3"
|
|
check "channel pinned: exactly one download (the tag hit first)" 0 "1" \
|
|
grep -c . "$LOG3"
|
|
|
|
# Channel 3 — RIG_REF=<branch>: the tag candidate misses, refs/heads lands.
|
|
H4="$WORK/h4"; B4="$WORK/b4"; LOG4="$WORK/log4"
|
|
check "channel dev: a branch ref falls back to refs/heads" 0 "done" \
|
|
rinst "$H4" "$B4" RIG_REF=feature-x \
|
|
CURL_STUB_OK=refs/heads/feature-x CURL_STUB_LOG="$LOG4"
|
|
check "channel dev: the tag URL was still tried FIRST" 0 "refs/tags/feature-x" \
|
|
sed -n 1p "$LOG4"
|
|
check "channel dev: ...then the branch URL" 0 "refs/heads/feature-x" \
|
|
sed -n 2p "$LOG4"
|
|
|
|
# Neither a tag nor a branch: both candidates miss, and the die says so.
|
|
H5="$WORK/h5"; B5="$WORK/b5"
|
|
check "channel: a ref that is neither tag nor branch dies naming both tries" \
|
|
1 "not a tag and not a branch" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
|
|
|
rm -rf "$WORK"
|
|
|
|
echo "---"
|
|
echo "$PASS passed, $FAIL failed"
|
|
[ "$FAIL" -eq 0 ]
|