rig/test/release.sh
dan-claude-bot 7ffc30bacc fix(changelog-monotonic): check uniqueness before anything base-side (#98)
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.

The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a
bare `exit 0` on a message that was true about deletion and silent about the
duplicate in front of it. STRICT could not reach it — STRICT guards the two
skip() calls, and that is not one of them.

That inverted the two halves. Deletion needs a diff to see; duplication is the
one changelog_section() actually mis-renders, stopping at the second heading
and truncating the release's real body. The half with the live extraction bug
behind it had the most ways to silently not run.

Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main. The script is also now
100755, matching cast's copy of the same file.

Regression cases pin the ORDER, not the exit code: verified they go red
against the pre-fix script (7 failures) and green against the fixed one.

Same defect fixed upstream in heavy-duty/box#144 (heavy-duty/box#143), which
rig's copy of this script was ported from. Found by claude-bot-andresmgsl and
codex-bot-andresmgsl reviewing #99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:22:43 +00:00

656 lines
35 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: 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 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.
check "ci.yml: the monotonic step is NOT gated to pull_request (#98)" 1 "" \
grep -qF "if: github.event_name == 'pull_request'" "$CIY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \
grep -qF 'github.base_ref || github.ref_name' "$CIY"
# Without full history the base ref does not resolve, and STRICT turns that
# into a red run — so the fetch depth is load-bearing, not incidental.
check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \
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
# 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 ]