feat: release flow — install-from-tag, release.yml, and -dev versions (#83) #90

Merged
dan-claude-bot merged 6 commits from feat/release-flow into main 2026-07-18 22:24:34 +00:00
9 changed files with 437 additions and 6 deletions

30
.github/scripts/release-notes.sh vendored Normal file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
# section of the changelog: every line between its '## <version> — <date>'
# header and the next '## '. This is what release.yml hands to
# 'gh release create', so the release notes are the curated prose we wrote,
# not the PR list GitHub would generate (#83). Fails loudly when the section
# is missing or empty — a tag without its changelog section is a release
# ritual skipped, and an empty release body would paper over it.
#
# A file of its own (not inlined in release.yml) so test/release.sh drives
# the same extraction against fixtures and the real CHANGELOG.md.
ver="${1:-}"
changelog="${2:-CHANGELOG.md}"
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
# $2 of a section header ('## 0.6.0 — 2026-07-18') is the bare version —
# compared WHOLE, so 0.6.0 can never match a 0.6.0-rc1 section (or vice
# versa), and no regex-escaping of dots. sed drops the blank padding under
# the header; the command substitution eats the trailing blanks.
notes="$(awk -v ver="$ver" '
/^## / { grab = ($2 == ver); next }
grab { print }
' "$changelog" | sed '/./,$!d')"
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#83)" >&2; exit 1; }
printf '%s\n' "$notes"

View file

@ -23,6 +23,8 @@ jobs:
run: bash test/cli.sh
- name: labels state-machine tests
run: bash test/labels-reconcile.sh
- name: release-flow tests
run: bash test/release.sh
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
# disposable VM, which is exactly the substrate the rehearsal needs. It runs

42
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,42 @@
name: release
# The release publisher (#83), on a bare X.Y.Z tag push (the 0.6.0 tag set
# the precedent — no 'v' prefix). Two facts, then one act: the tag must name
# the tree's own VERSION (a mismatch fails loudly and creates NOTHING — a
# wrong release is worse than a missing one), and the release body is that
# version's CHANGELOG.md section (.github/scripts/release-notes.sh, shared
# with test/release.sh) — the curated prose, not the generated PR list. No
# assets are uploaded: for a pure-bash tree, GitHub's source tarball for the
# tag IS the package, and install.sh downloads exactly that.
on:
push:
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
# below, not be silently skipped by a pattern that didn't match.
tags: ["**"]
permissions:
contents: write # gh release create
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: the tag must name the tree's VERSION
run: |
ver="$(cat VERSION)"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
- name: release notes — the version's own CHANGELOG.md section
# release-notes.sh fails loudly on a missing/empty section, which
# fails the release here — before anything is created.
run: |
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: create the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"

View file

@ -7,6 +7,28 @@ which records not just what changed but what each drill run proved.
### Added
- **The installer defaults to the latest release, and releases publish
themselves** (#83) — `curl | bash` used to hand out whatever `main` was at
that second: the 0.6.0 release was a bookmark, not a package, and two
operators "on 0.6.0" could be running different trees. `install.sh` now
resolves the latest release tag by following GitHub's `releases/latest`
redirect (one HEAD request — no API, no token, no rate-limit pain) and
downloads that tag's tarball; a failed resolution refuses loudly, naming
`BOX_REF` as the way out — it never hangs and never silently falls back to
`main`. A set `BOX_REF` is tried as a tag first, then as a branch, so one
knob yields three channels: default = latest release, `BOX_REF=0.6.0` =
pinned, `BOX_REF=main` = dev. A new `release.yml` (on a bare `X.Y.Z` tag
push — the `0.6.0` tag set the no-`v` precedent) asserts the tag names the
tree's own `VERSION` (a mismatch fails loudly and creates nothing) and
publishes the GitHub release with that version's `CHANGELOG.md` section as
the body (`.github/scripts/release-notes.sh` — the curated prose, not the
generated PR list; no assets, the source tarball for the tag IS the
package). And `main`'s `VERSION` now carries `-dev` between releases
(this PR: `0.6.1-dev`): the versioned layout names install trees after
`VERSION`, so a `main` install without the bump would land in
`versions/0.6.0` and impersonate the released tree. `test/release.sh`
drives all of it offline — the extraction against fixtures and the real
changelog, the resolution and every channel against a shim curl.
- **`setup-host` auto-picks a free subnet — nested box-in-box with zero
flags** (#80, completing its fix #1: "refuse … or automatically select a
non-colliding subnet"). A bare `box setup-host` now decides the subnet

View file

@ -41,6 +41,29 @@ labels tell you where everything is without opening anything.
7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
## Releases
A release is a PR, then a tag ([#83](https://github.com/heavy-duty/box/issues/83)):
1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION`
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
date (feature PRs land their changelog entry as part of the PR, so the
section is already written). This PR is where the release ritual hangs:
the full drill on real hardware, recorded in
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
PR, a release still proves the boundary.
2. **Merge, then tag the merge commit** bare `X.Y.Z` — no `v` prefix, the
`0.6.0` tag set the precedent — and push the tag.
[release.yml](.github/workflows/release.yml) takes it from there: it
asserts the tag names the tree's own `VERSION` (a mismatch fails loudly
and creates nothing) and publishes the GitHub release with that version's
`CHANGELOG.md` section as the body. No assets — the source tarball for
the tag is the package, and `install.sh` downloads exactly that.
3. **Immediately after: bump `main`'s `VERSION` to `X.Y.(Z+1)-dev`.** Not
cosmetic — the versioned layout names install trees after `VERSION`, so a
`main` install without the bump would land in `versions/X.Y.Z` and
impersonate the release you just cut.
## Labels — who sets what
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is

View file

@ -45,6 +45,22 @@ design rationale.
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
```
By default that installs the **latest release** — the installer resolves the
release tag off GitHub's `releases/latest` redirect (no API, no token) and
downloads exactly that tree, so two operators running it get the same box.
If the resolution fails it says so and stops — it never silently hands out
`main`. `BOX_REF` picks another channel (a set ref is tried as a tag first,
then as a branch — [#83](https://github.com/heavy-duty/box/issues/83)):
```sh
curl -fsSL .../install.sh | bash # the latest release (default)
curl -fsSL .../install.sh | BOX_REF=0.6.0 bash # pin a release
curl -fsSL .../install.sh | BOX_REF=main bash # the development tip
```
(A dev tree's `VERSION` carries a `-dev` suffix, so it lands beside your
releases under `versions/`, never on top of one.)
It asks first — **"Install box?"** — then downloads the tree into a
**versioned** install (the way plenty of CLIs manage theirs), links `box` onto
your `PATH`, and on a fresh host asks a second question: **"Set up this

View file

@ -1 +1 @@
0.6.0
0.6.1-dev

View file

@ -24,7 +24,11 @@ set -euo pipefail
# downloading — for CI and the drill, so what lands is the code under review.
REPO="${BOX_REPO:-heavy-duty/box}"
REF="${BOX_REF:-main}"
# Three install channels, one knob (#83): BOX_REF unset installs the LATEST
# RELEASE (the tag resolved from GitHub's releases/latest redirect, below);
# BOX_REF=<tag> pins a release; BOX_REF=<branch> (say, main) is the dev
# channel. A set ref is tried as a tag first, then as a branch.
REF="${BOX_REF:-}"
# Root installs GLOBALLY, non-root installs per-user. box's install tree is
# EXECUTED by other users (the multi-user host path: rig installs box once, every
# incus-group operator runs it) — unlike rig, which is root-only and can hide in
@ -91,6 +95,23 @@ existing_boxes() {
} 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
}
# The latest release, resolved the no-API way (#83): GitHub answers
# https://github.com/<repo>/releases/latest with a redirect to
# .../releases/tag/<tag>, so one HEAD request reads the tag off the Location
# header — no API, no token, no rate-limit pain. Prints the bare tag; fails
# when the redirect does not answer or does not name a tag (a repo with no
# releases redirects to /releases), so the caller can refuse LOUDLY instead
# of silently installing main. test/release.sh drives this against a shim
# curl serving canned redirects.
latest_release_tag() {
local loc
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$REPO/releases/latest")" || return 1
case "$loc" in
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
*) return 1 ;;
esac
}
# --- prerequisites ---------------------------------------------------------
# curl only when something must be downloaded — a local BOX_INSTALL_SOURCE
# needs none, which is what lets test/cli.sh drive REAL installs offline.
@ -102,7 +123,7 @@ command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Pleas
if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
SRCDESC="local source $BOX_INSTALL_SOURCE"
else
SRCDESC="$REPO@$REF"
SRCDESC="$REPO@${REF:-latest release}"
fi
# --- confirm first ---------------------------------------------------------
@ -178,12 +199,30 @@ if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
die "BOX_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
fi
else
# No BOX_REF → the latest release, resolved only now (AFTER the confirm:
# even a redirect probe is network the operator has not yet said yes to).
# A failed resolution REFUSES with the way out — it must never hang, and
# never silently hand out main when the operator asked for a release (#83).
if [ -z "$REF" ]; then
REF="$(latest_release_tag)" \
|| die "could not resolve the latest release (no release tag behind https://github.com/$REPO/releases/latest). Check the network, and that $REPO has releases — or pick the ref yourself: BOX_REF=<tag> pins a release, BOX_REF=main installs the development tip."
SRCDESC="$REPO@$REF"
log "latest release: $REF"
fi
INSTALLED_FROM="$REPO@$REF"
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing box from $REPO@$REF"
# A ref is a TAG first (the pinned-release channel), a branch second (the
# dev channel, BOX_REF=main) — and the fallback only exists for a ref the
# OPERATOR named: a resolved latest tag has no branch to fall through to.
URL="https://github.com/$REPO/archive/refs/tags/$REF.tar.gz"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|| die "failed to download $URL"
if ! curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz"; then
[ -n "${BOX_REF:-}" ] || die "failed to download $URL"
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "no tag '$REF' — trying it as a branch: $URL"
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|| die "failed to download it as either — '$REF' is neither a tag nor a branch of $REPO"
fi
log "extracting archive"
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \

257
test/release.sh Normal file
View file

@ -0,0 +1,257 @@
#!/usr/bin/env bash
# The release flow (#83), proven offline. Run: bash test/release.sh
#
# Three surfaces: the changelog-section extraction release.yml publishes
# (.github/scripts/release-notes.sh, driven against fixtures AND the real
# CHANGELOG.md so the header format cannot drift under it), the
# latest-release tag resolution install.sh defaults to (the extracted
# function, driven against a shim curl serving canned redirects), and the
# three install channels — REAL install.sh runs against throwaway roots,
# with the shim curl standing in for GitHub. Nothing here touches the
# network; the same discipline as test/cli.sh. Deliberately no `set -e` —
# the harness asserts on failing commands.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
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))
}
NOTES="$ROOT/.github/scripts/release-notes.sh"
WORK="$(mktemp -d)"
# ---------------------------------------------------------------------------
# release-notes.sh — the extraction, against a fixture changelog that carries
# every boundary: an Unreleased section that must never leak into a release,
# two adjacent versions, a version that prefixes another (0.7.0 vs
# 0.7.0-rc1), and a stamped-but-empty section that must refuse.
# ---------------------------------------------------------------------------
check "release-notes: runnable bash" 0 "" bash -n "$NOTES"
FIX="$WORK/CHANGELOG.md"
cat > "$FIX" <<'EOF'
# Changelog
Intro prose that belongs to no section.
## Unreleased
- **Not yet released** — must never appear in a release body.
## 0.7.0 — 2026-07-20
### Added
- **The seven-oh entry** — prose for 0.7.0, and only 0.7.0.
## 0.7.0-rc1 — 2026-07-19
- **The rc entry** — must not ride along with 0.7.0.
## 0.6.0 — 2026-07-18
- **The six-oh entry** — the previous release's prose.
## 0.5.0 — 2026-07-15
EOF
check "extract: prints the asked-for version's prose" 0 "The seven-oh entry" bash "$NOTES" 0.7.0 "$FIX"
check "extract: keeps the section's own subheaders" 0 "### Added" bash "$NOTES" 0.7.0 "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: stops at the NEXT section" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "rc entry"' _ "$NOTES" "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: never leaks Unreleased into a release" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "Not yet released"' _ "$NOTES" "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: never prints the header itself" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "^## "' _ "$NOTES" "$FIX"
check "extract: the version is matched WHOLE (rc1 is its own section)" \
0 "The rc entry" bash "$NOTES" 0.7.0-rc1 "$FIX"
check "extract: an adjacent older version still resolves" 0 "six-oh" bash "$NOTES" 0.6.0 "$FIX"
check "extract: a missing version refuses by name" 1 "no section for '9.9.9'" bash "$NOTES" 9.9.9 "$FIX"
check "extract: ...and names the ritual that was skipped" 1 "#83" bash "$NOTES" 9.9.9 "$FIX"
check "extract: a stamped-but-EMPTY section refuses" 1 "no section for '0.5.0'" bash "$NOTES" 0.5.0 "$FIX"
check "extract: no version argument is a usage error" 2 "usage:" bash "$NOTES"
check "extract: a missing changelog refuses by path" 1 "no such file" bash "$NOTES" 1.0.0 "$WORK/nope.md"
# The REAL changelog: released sections must keep extracting, or release.yml
# breaks the day it runs — this is the guard against header-format drift.
check "extract: the real 0.6.0 section extracts" 0 "restricted tier" bash "$NOTES" 0.6.0 "$ROOT/CHANGELOG.md"
check "extract: the real 0.5.0 section extracts" 0 "" bash "$NOTES" 0.5.0 "$ROOT/CHANGELOG.md"
# ---------------------------------------------------------------------------
# release.yml — a daemon-free run cannot push a tag, so the wiring is
# grepped, fail-closed (the house discipline): the VERSION assertion, the
# shared extraction script, and that the tag is verified before creation.
# ---------------------------------------------------------------------------
RY="$ROOT/.github/workflows/release.yml"
check "release.yml: exists" 0 "" test -f "$RY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "release.yml: asserts tag == VERSION before creating anything" 0 "" \
grep -qF 'GITHUB_REF_NAME" != "$ver"' "$RY"
check "release.yml: the mismatch creates NOTHING (exit 1)" 0 "" \
grep -qF 'creating nothing' "$RY"
check "release.yml: the body comes from the shared extraction script" 0 "" \
grep -qF '.github/scripts/release-notes.sh' "$RY"
check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \
grep -qF -- '--verify-tag' "$RY"
# ---------------------------------------------------------------------------
# latest_release_tag — extracted from install.sh (the source-the-pure-function
# trick) and driven against a shim curl. The shim serves the ONE seam the
# function uses: -w '%{redirect_url}' on the releases/latest probe.
# ---------------------------------------------------------------------------
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
cat > "$SHIMDIR/curl" <<'SHIM'
#!/usr/bin/env bash
# Fake curl for the release drills: answers the releases/latest probe with
# $FAKE_REDIRECT on stdout (the -w '%{redirect_url}' seam) — or fails with
# $FAKE_CURL_RC (network down) — and serves downloads (-o <file>) by copying
# $FAKE_TARBALL when the URL is $FAKE_SERVE_URL, else exit 22 (curl's own
# 404-under--f code). Every URL is appended to $FAKE_CURL_LOG so a test can
# assert exactly what was asked for, and in what order.
url="" out=""
while [ $# -gt 0 ]; do
case "$1" in
-o|--output) out="$2"; shift 2 ;;
-w|--write-out) shift 2 ;;
-*) shift ;;
*) url="$1"; shift ;;
esac
done
[ -n "${FAKE_CURL_LOG:-}" ] && printf '%s\n' "$url" >> "$FAKE_CURL_LOG"
case "$url" in
*/releases/latest)
[ "${FAKE_CURL_RC:-0}" -eq 0 ] || exit "${FAKE_CURL_RC}"
printf '%s' "${FAKE_REDIRECT:-}"; exit 0 ;;
*)
if [ -n "${FAKE_SERVE_URL:-}" ] && [ "$url" = "$FAKE_SERVE_URL" ]; then
cp "${FAKE_TARBALL:?}" "${out:?}"; exit 0
fi
exit 22 ;;
esac
SHIM
chmod +x "$SHIMDIR/curl"
TAGFN="$(mktemp)"
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN"
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN"
ltag() { # ltag <redirect_url> [curl_rc]
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
}
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \
ltag "https://github.com/heavy-duty/box/releases"
check "resolve: no redirect at all fails" 1 "" ltag ""
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
rm -f "$TAGFN"
# ---------------------------------------------------------------------------
# The three channels, driven through REAL install.sh runs (#83): default =
# latest release, BOX_REF=<tag> = pinned, BOX_REF=<branch> = dev. The shim
# curl serves a fabricated release tarball shaped exactly like GitHub's (one
# top-level directory), and its log proves WHICH URLs the installer asked
# for. FAKE_TARBALL carries VERSION 9.9.9 so nothing collides with the tree
# under test.
# ---------------------------------------------------------------------------
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
echo "9.9.9" > "$SRC/VERSION"
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
ninst() { # ninst <box_home> <box_bin> [VAR=val ...] — install.sh, shim network
local h="$1" b="$2"; shift 2
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
}
# --- channel 1: the default is the latest RELEASE ---------------------------
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
check "default channel: the download is the TAG tarball" 0 "" \
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
check "default channel: it never asked for a branch" 1 "" \
grep -q "refs/heads" "$L1"
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
cat "$H1/versions/9.9.9/INSTALLED_FROM"
check "default channel: the install answers through the chain" 0 "box 9.9.9" \
env HOME="$FAKEHOME" "$B1/box" --version
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
check "pinned channel: BOX_REF=<tag> installs that tag" 0 "done" \
ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \
grep -q "releases/latest" "$L2"
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \
ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz"
check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \
head -1 "$L3"
check "dev channel: then the branch" 0 "" \
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
# --- the failure is LOUD, never a silent fall-through to main ---------------
H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log"
check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \
ninst "$H4" "$B4" FAKE_CURL_RC=6
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \
grep -q "archive/" "$L4"
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions"
check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \
ninst "$H4" "$B4" BOX_REF=no-such-ref
# ---------------------------------------------------------------------------
# The -dev convention (#83): main's VERSION carries -dev between releases, so
# a dev install lands beside releases in versions/ instead of impersonating
# one — and the docs keep the promises this PR makes.
# ---------------------------------------------------------------------------
check "CONTRIBUTING documents the post-release -dev bump" 0 "" \
grep -q -- '-dev' "$ROOT/CONTRIBUTING.md"
check "CONTRIBUTING documents the release ritual (tag == VERSION)" 0 "" \
grep -qi 'release' "$ROOT/CONTRIBUTING.md"
check "README documents the default (latest release) channel" 0 "" \
grep -qF 'latest release' "$ROOT/README.md"
check "README documents the pinned channel" 0 "" \
grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
check "README documents the dev channel" 0 "" \
grep -qF 'BOX_REF=main' "$ROOT/README.md"
echo "---"
echo "$PASS passed, $FAIL failed"
rm -rf "$WORK"
[ "$FAIL" -eq 0 ]