diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c412b37..c06f673 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,21 @@ jobs: # half-done pin bump goes red here. - uses: heavy-duty/ceremony/actions/docs-sync@0.1.0 + # The install LIFECYCLE against a tree install.sh itself produced — the four + # beats box and cast already run in CI (#106): install from this checkout, + # assert what landed, a converging re-run proven by an EMPTY DIFF (never an + # exit code), uninstall --all ending in the absence assert (`! -e` AND + # `! -L` — only the second sees a dangling symlink). Separate from `check` + # for the same reason db-integration is: fast feedback first. The runner's + # real $HOME is the point — no throwaway roots here; the suite refuses to + # run where a rig is already installed, so it cannot eat a real install. + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: the install lifecycle — four beats against a real tree + run: bash test/install-lifecycle.sh + # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and # stands up throwaway containers, and a slow image pull must never delay the # fast shellcheck + cli.sh feedback above. ubuntu-latest ships Docker running diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ff681..9710954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ on the way to cutting its first release, and this file starts there. - GitHub entry templates route humans to Discussions and prefill triage work orders and pull requests (#123) - `rig platform` prints a stable machine `ID`, derived from `/etc/machine-id`, never the raw value (#95) - Platform, drill, docs and labels changes receive dedicated scope labels (#119) +- CI drills the install lifecycle against a real tree — install from the checkout, converge to an empty diff, uninstall to proven absence (#106) - `kimi-box` joins the box tenant roles — the Kimi CLI agent guest (#109) - The `changelog-armed` guard returns, version-keyed (#112, ceremony#13) - The `.ceremony/` doctrine mirror, verified by `docs-sync` on every PR (#112, ceremony#19) diff --git a/install.sh b/install.sh index 8dd3ed4..3c1e30b 100644 --- a/install.sh +++ b/install.sh @@ -29,9 +29,11 @@ set -euo pipefail # protect, and the operator flipping versions on purpose is the common case. # A pre-versioning flat tree is migrated in place, so upgrading is seamless. # -# RIG_INSTALL_SOURCE= installs from a local tree instead of -# downloading — for CI and the test suite, so what lands is the code under -# review. +# RIG_INSTALL_SOURCE= is the LOCAL channel, a supported input +# like RIG_REF (#106): installs from that tree instead of downloading — CI's +# install-lifecycle job and the test suites use it, so what lands is the code +# under review. A path that is neither refuses by name, never falls back to +# a download. REPO="${RIG_REPO:-heavy-duty/rig}" REF="${RIG_REF:-}" # empty = the latest release, resolved below diff --git a/test/install-lifecycle.sh b/test/install-lifecycle.sh new file mode 100755 index 0000000..1a221a9 --- /dev/null +++ b/test/install-lifecycle.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# The install LIFECYCLE, driven end to end against a tree install.sh itself +# produced (#106) — the four beats box and cast already run in CI, which rig, +# the repo whose headline claim is convergence, ran nowhere: +# +# 1. install from THIS checkout (RIG_INSTALL_SOURCE — the local channel) +# 2. assert what landed (layout, current, the PATH chain) +# 3. a converging re-run (an EMPTY DIFF, never an exit code) +# 4. uninstall --all (ending in the absence assert) +# +# test/cli.sh drives the same verbs against throwaway roots; this suite runs +# them in the environment cli.sh deliberately fakes — the real default paths +# under the runner's own $HOME. Run: bash test/install-lifecycle.sh (CI's +# `install:` job). RIG_HOME/RIG_BIN redirect the roots for a local run; the +# refusal below explains when you need them. +# +# Deliberately no `set -e` — a failing beat is data, and the summary is the +# verdict (the test/release.sh harness shape). +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" || exit 1 +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)) +} + +# The roots install.sh will use, computed by ITS rules (install.sh:55-60), so +# every assert below points at what the installer actually touched. +DEST="${RIG_HOME:-$HOME/.local/share/rig}" +if [ "$(id -u)" -eq 0 ]; then + BINDIR="${RIG_BIN:-/usr/local/bin}" +else + BINDIR="${RIG_BIN:-$HOME/.local/bin}" +fi + +# Beat 4 REMOVES the install at those roots, so a rig that already lives there +# is a refusal, not a fixture — this suite must never eat an operator's +# install. CI runners are clean; a workstation run points the roots at +# something disposable. +if [ -e "$DEST" ] || [ -L "$DEST" ] || [ -e "$BINDIR/rig" ] || [ -L "$BINDIR/rig" ]; then + echo "install-lifecycle: a rig install already exists ($DEST or $BINDIR/rig)" >&2 + echo "install-lifecycle: refusing to drive the lifecycle over it — re-run against scratch roots:" >&2 + echo " W=\$(mktemp -d); RIG_HOME=\$W/rig RIG_BIN=\$W/bin bash test/install-lifecycle.sh" >&2 + exit 2 +fi + +VER="$(cat "$ROOT/VERSION")" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# tree_state — what "changed nothing" must mean: every file's bytes, +# every path's type and mode, every symlink's target. Beat 3 captures this +# before and after the re-run and diffs the two. +tree_state() { + (cd "$1" || return 1 + find . -type f -exec sha256sum {} + | LC_ALL=C sort + find . -type l -printf '%p -> %l\n' | LC_ALL=C sort + find . -printf '%y %m %p\n' | LC_ALL=C sort) +} + +diff_state() { # diff_state — how the tree drifted, by name + tree_state "$2" | diff "$1" - +} + +no_residue() { # no_residue ... — 0 iff every path is GONE: file, dir OR + local p bad=0 # symlink. `! -e` alone follows the link and cannot see it + for p in "$@"; do # dangling — the residue a broken uninstall actually leaves. + if [ -e "$p" ] || [ -L "$p" ]; then echo "still present: $p"; bad=1; fi + done + return "$bad" +} + +# --- instrument honesty ------------------------------------------------------ +# The diff and the absence assert must be able to FAIL, or beats 3 and 4 prove +# nothing — so break each one against a scratch tree first, on every run +# (the test/drill.sh doctrine: mechanical, not a one-off claim in a PR). +SCR="$WORK/scr"; mkdir -p "$SCR/tree/bin" +echo content > "$SCR/tree/bin/rig" +ln -s bin/rig "$SCR/tree/link" +tree_state "$SCR/tree" > "$SCR/cap" +check "honesty: an untouched tree reads as zero drift" 0 "" \ + diff_state "$SCR/cap" "$SCR/tree" +echo drift >> "$SCR/tree/bin/rig" +check "honesty: a mutated file is drift, named" 1 "bin/rig" \ + diff_state "$SCR/cap" "$SCR/tree" +tree_state "$SCR/tree" > "$SCR/cap" +ln -sfn ../elsewhere "$SCR/tree/link" +check "honesty: a retargeted symlink is drift" 1 "elsewhere" \ + diff_state "$SCR/cap" "$SCR/tree" +tree_state "$SCR/tree" > "$SCR/cap" +touch "$SCR/tree/leftover" +check "honesty: an ADDED file is drift (what a non-convergent installer leaves)" 1 "leftover" \ + diff_state "$SCR/cap" "$SCR/tree" +# The beat-4 distinction, demonstrated: `test ! -e` PASSES on a dangling +# symlink (it follows the link), so on its own it would certify a broken +# uninstall clean — only `! -L` sees the corpse. +ln -s "$SCR/nowhere" "$SCR/dangling-rig" +check "honesty: test ! -e cannot see a dangling symlink (the lie)" 0 "" \ + test ! -e "$SCR/dangling-rig" +check "honesty: the absence assert can (! -L is the catch)" 1 "still present" \ + no_residue "$SCR/dangling-rig" +rm "$SCR/dangling-rig" +check "honesty: a really-gone path passes the absence assert" 0 "" \ + no_residue "$SCR/dangling-rig" + +# --- beat 1: install from THIS checkout -------------------------------------- +# RIG_INSTALL_SOURCE is the supported local channel (its contract — dir, +# tarball, loud refusal, no silent download fallback — is test/release.sh's); +# in CI $ROOT is $GITHUB_WORKSPACE, so what lands is the code under review. +b1() { RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"; } +check "beat 1: install.sh installs this checkout" 0 "done" b1 + +# --- beat 2: assert what landed ---------------------------------------------- +check "beat 2: the tree landed in versions/$VER" 0 "" \ + test -x "$DEST/versions/$VER/bin/rig" +check "beat 2: current points at versions/$VER" 0 "versions/$VER" \ + readlink "$DEST/current" +check "beat 2: the PATH symlink rides the chain" 0 "$DEST/current/bin/rig" \ + readlink "$BINDIR/rig" +check "beat 2: ...and resolves into versions/ (cast's assert)" 0 "/versions/$VER/bin/rig" \ + readlink -f "$BINDIR/rig" +check "beat 2: rig --version answers through the whole chain" 0 "rig $VER" \ + "$BINDIR/rig" --version +check "beat 2: INSTALLED_FROM names the local source" 0 "local:$ROOT" \ + cat "$DEST/versions/$VER/INSTALLED_FROM" + +# --- beat 3: the converging re-run ------------------------------------------- +# "Ran twice without crashing" is the self-deception this beat exists to +# refuse (#106): the assert is an empty diff of captured state, plus current +# still pointing where it did. +tree_state "$DEST" > "$WORK/before" +CUR_BEFORE="$(readlink "$DEST/current")" +check "beat 3: the re-run is a no-op that says so" 0 "already installed" b1 +check "beat 3: ...and changed NOTHING — the diff is the verdict" 0 "" \ + diff_state "$WORK/before" "$DEST" +check "beat 3: current did not move" 0 "" \ + test "$(readlink "$DEST/current")" = "$CUR_BEFORE" + +# --- beat 4: uninstall --all, ending in the absence assert ------------------- +check "beat 4: uninstall --all removes the whole install" 0 "uninstalled" \ + "$BINDIR/rig" uninstall --all --force +check "beat 4: zero residue at the install root" 0 "" no_residue "$DEST" +check "beat 4: zero residue on PATH — not even a dangling symlink" 0 "" \ + no_residue "$BINDIR/rig" +# The doctrine spelled out as its two distinct asserts (#106): -e for +# presence, -L for the dangling link -e cannot see. +check "beat 4: test ! -e on the PATH entry" 0 "" test ! -e "$BINDIR/rig" +check "beat 4: test ! -L on the PATH entry" 0 "" test ! -L "$BINDIR/rig" +check "beat 4: test ! -e on the install root" 0 "" test ! -e "$DEST" +check "beat 4: test ! -L on the install root" 0 "" test ! -L "$DEST" + +echo "---" +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/test/release.sh b/test/release.sh index d890489..8e0103a 100644 --- a/test/release.sh +++ b/test/release.sh @@ -170,6 +170,35 @@ 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 +# --- the local channel: RIG_INSTALL_SOURCE (#106) ---------------------------- +# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh +# both install THIS checkout through it. What release.sh owes is the channel's +# contract: a directory installs, a tarball installs, neither touches the +# network, and a bad path refuses BY NAME — never a silent fallback to +# downloading a release, which would leave a green CI job testing the wrong +# tree. The stub curl's log is the network witness: any download, even an +# attempted one, would land a URL in it. +H6="$WORK/h6"; B6="$WORK/b6"; LOG6="$WORK/log6" +check "channel local: a directory installs" 0 "done" \ + rinst "$H6" "$B6" RIG_INSTALL_SOURCE="$TBDIR/rig-7.7.7-relflow" CURL_STUB_LOG="$LOG6" +check "channel local: the tree landed under its VERSION" 0 "" \ + test -x "$H6/versions/7.7.7-relflow/bin/rig" +check "channel local: INSTALLED_FROM records local:" 0 \ + "local:$TBDIR/rig-7.7.7-relflow" cat "$H6/versions/7.7.7-relflow/INSTALLED_FROM" +check "channel local: curl was never consulted" 1 "" test -s "$LOG6" +H7="$WORK/h7"; B7="$WORK/b7"; LOG7="$WORK/log7" +check "channel local: a tarball installs too" 0 "done" \ + rinst "$H7" "$B7" RIG_INSTALL_SOURCE="$WORK/release.tgz" CURL_STUB_LOG="$LOG7" +check "channel local: the tarball's tree landed" 0 "" \ + test -x "$H7/versions/7.7.7-relflow/bin/rig" +check "channel local: ...also without a download" 1 "" test -s "$LOG7" +H8="$WORK/h8"; B8="$WORK/b8"; LOG8="$WORK/log8" +check "channel local: a missing path refuses BY NAME" 1 "$WORK/no-such-source" \ + rinst "$H8" "$B8" RIG_INSTALL_SOURCE="$WORK/no-such-source" CURL_STUB_LOG="$LOG8" +check "channel local: the refusal installed NOTHING" 1 "" test -e "$H8" +check "channel local: ...and downloaded nothing (no silent fallback)" 1 "" \ + test -s "$LOG8" + rm -rf "$WORK" echo "---"