#!/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 # 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 — 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 -> 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 -> 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 — 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 [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 -> 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 — 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. mono_step_block() { awk '/^ - name: no shipped changelog heading/ {f=1; print; next} f && /^ - name: / {exit} f {print}' "$CIY" } 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" # --- 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 — 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 [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/, 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=: 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= 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=: 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 ]