Merge pull request #128 from claude-bot-andresmgsl/build/106-install-lifecycle
feat: CI drills the install lifecycle — four beats against a real tree
This commit is contained in:
commit
9cc7568ca5
5 changed files with 221 additions and 3 deletions
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
|
|
@ -64,6 +64,21 @@ jobs:
|
||||||
# half-done pin bump goes red here.
|
# half-done pin bump goes red here.
|
||||||
- uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
|
- 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
|
# 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
|
# stands up throwaway containers, and a slow image pull must never delay the
|
||||||
# fast shellcheck + cli.sh feedback above. ubuntu-latest ships Docker running
|
# fast shellcheck + cli.sh feedback above. ubuntu-latest ships Docker running
|
||||||
|
|
|
||||||
|
|
@ -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)
|
- 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)
|
- `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)
|
- 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)
|
- `kimi-box` joins the box tenant roles — the Kimi CLI agent guest (#109)
|
||||||
- The `changelog-armed` guard returns, version-keyed (#112, ceremony#13)
|
- The `changelog-armed` guard returns, version-keyed (#112, ceremony#13)
|
||||||
- The `.ceremony/` doctrine mirror, verified by `docs-sync` on every PR (#112, ceremony#19)
|
- The `.ceremony/` doctrine mirror, verified by `docs-sync` on every PR (#112, ceremony#19)
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ set -euo pipefail
|
||||||
# protect, and the operator flipping versions on purpose is the common case.
|
# 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.
|
# A pre-versioning flat tree is migrated in place, so upgrading is seamless.
|
||||||
#
|
#
|
||||||
# RIG_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
|
# RIG_INSTALL_SOURCE=<dir-or-tarball> is the LOCAL channel, a supported input
|
||||||
# downloading — for CI and the test suite, so what lands is the code under
|
# like RIG_REF (#106): installs from that tree instead of downloading — CI's
|
||||||
# review.
|
# 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}"
|
REPO="${RIG_REPO:-heavy-duty/rig}"
|
||||||
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
||||||
|
|
|
||||||
171
test/install-lifecycle.sh
Executable file
171
test/install-lifecycle.sh
Executable file
|
|
@ -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 <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))
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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 <root> — 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 <capture> <root> — how the tree drifted, by name
|
||||||
|
tree_state "$2" | diff "$1" -
|
||||||
|
}
|
||||||
|
|
||||||
|
no_residue() { # no_residue <path>... — 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 ]
|
||||||
|
|
@ -170,6 +170,35 @@ H5="$WORK/h5"; B5="$WORK/b5"
|
||||||
check "channel: a ref that is neither tag nor branch dies naming both tries" \
|
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
|
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:<path>" 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"
|
rm -rf "$WORK"
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue