forked from heavy-duty/rig
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony PR — including every PR that improves this very workflow. The old assert pair turned each of those merges into a red run on main. The fused decide step reads the version against the PR base and answers all four states: -dev unchanged = work, green NOTICE no-op; bare unchanged but already released = work in the post-release window, same no-op; -dev-but-changed and bare-unchanged-never-released = half-ceremonies, refused loudly; bare-and-changed = the ceremony. Later steps gate on its output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
325 lines
16 KiB
Bash
325 lines
16 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"
|
|
# shellcheck disable=SC2016 # same: positional args resolve inside the inner shell
|
|
check "CHANGELOG.md: the top section extracts non-empty (the format fits the tool)" 0 "" \
|
|
bash -c '. "$2/.github/scripts/release-lib.sh"; [ -n "$(changelog_section "$1" "$(grep -m1 "^## " "$1" | awk "{print \$2}")")" ]' _ "$ROOT/CHANGELOG.md" "$ROOT"
|
|
|
|
# --- 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.
|
|
check "release.yml: fires when a PR into main closes (merge = ship)" 0 "" \
|
|
grep -qF "pull_request:" "$RY"
|
|
check "release.yml: only a MERGED PR releases (closed-unmerged never fires)" 0 "" \
|
|
grep -qF "github.event.pull_request.merged == true" "$RY"
|
|
check "release.yml: only the 'release' label carries the intent" 0 "" \
|
|
grep -qF "contains(github.event.pull_request.labels.*.name, 'release')" "$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 half-ceremony (-dev but changed) refuses" 0 "" \
|
|
grep -qF "half a ceremony" "$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"
|
|
check "release.yml: ...at the MERGE commit" 0 "" mjob_has "merge_commit_sha"
|
|
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 ]
|