From 437a3a8e35fbc989efae5976d2c29bda00b3d751 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 00:01:15 +0000 Subject: [PATCH] test+ci: add CI workflow and a dependency-free test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit box had no CI and no unit tests — only the live-host drill. Mirror rig's CI: one `check` job = globstar `shellcheck -x` over bin/* and **/*.sh, then `bash test/cli.sh`. The suite is dependency-free and runs non-root with no Incus: the full CLI contract; install.sh's DEST/BINDIR branch driven functionally against a shim `id` (both tiers + the BOX_HOME/BOX_BIN overrides); the root-only a+rX and #66's confirm/no-op flow grep-guarded; tmux asserted in every template. Pre-existing repo shellcheck findings (bin/box SC2034/SC2015/ SC2020, and file-level SC2015 idioms in doctor.sh/wipe.sh/migrate-host.sh) were resolved — real fixes where behaviour allows, reasoned disables otherwise — so the new CI is green over the whole repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 32 +++++++++ bin/box | 16 ++++- drill/doctor.sh | 5 ++ drill/wipe.sh | 5 ++ host/migrate-host.sh | 10 ++- test/cli.sh | 146 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/cli.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..845da81 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: ci +on: + push: + branches: [main] + pull_request: +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: shellcheck + # -x follows `source`/`.` directives; box has no lib split today, but the + # flag costs nothing and keeps the invocation identical to rig's. + # globstar so a script in a new subdirectory is linted without anyone + # remembering to edit this list; bin/* covers the extensionless entrypoint + # (bin/box). The file list is printed so under-coverage shows up in the log. + run: | + shopt -s globstar + files=(bin/* **/*.sh) + printf 'shellcheck: %s\n' "${files[@]}" + shellcheck -x "${files[@]}" + - name: cli tests + run: bash test/cli.sh + + # NOT run here: the drill (drill/drill.sh) and the multi-user rehearsal + # (drill/multiuser.sh). Both stand up a real Incus, rearrange the host's + # network/firewall, and — for the rehearsal — create throwaway system users. + # None of that belongs in CI: it needs a disposable host and root, and it is + # the answer to #72 Task 0's substrate question, which a container runner + # cannot answer. CI stays static + dependency-free (shellcheck + cli.sh), + # exactly as this repo's design demands ("isolation claims are tested on a + # real host, never reasoned about" — docs/box-design.md). diff --git a/bin/box b/bin/box index c04f0e0..5613ded 100755 --- a/bin/box +++ b/bin/box @@ -641,6 +641,13 @@ load_template() { *) die "template '$t': not a KEY=\"value\" line: $line" ;; esac val="${val#\"}"; val="${val%\"}" + # T_DESC is parsed for symmetry with the other BOX_* keys, but cmd_templates + # re-reads BOX_DESCRIPTION straight from the file (a box is listed without + # ever loading its template), so the parsed value here is never read. Keep the + # row — deleting it would turn box.env's own key into an "unknown key" error at + # mint time. (SC2034 disabled for the branch below; the directive must sit on + # the whole case, not an individual arm.) + # shellcheck disable=SC2034 case "$key" in BOX_DESCRIPTION) T_DESC="$val" ;; BOX_IMAGE) T_IMAGE="$val" ;; @@ -651,7 +658,12 @@ load_template() { *) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;; esac done <"$dir/box.env" - [ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required" + # Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still + # dies (which is what we want) — but it reads as an if-then-else it is not, so + # spell the guard out (SC2015). + if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then + die "template '$t': BOX_IMAGE and BOX_USER are required" + fi # Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57) # > BOX_* environment (how a small host or the drill shrinks every box it # mints) > the template's file > defaults. Values pass to Incus verbatim — @@ -793,7 +805,7 @@ box_net_ip() { pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)" [ -n "$pfx" ] || return 1 incus list "$1" --format csv --columns 4 2>/dev/null \ - | tr -d '"' | tr ' ,' '\n\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep . + | tr -d '"' | tr ' ,' '\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep . } # VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through. diff --git a/drill/doctor.sh b/drill/doctor.sh index 546c7cd..b0e7ff7 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -14,6 +14,11 @@ # # This script is the answer to "what state is the host actually in?" — the # question that kept getting answered by hand. +# +# ok/no/inf/head_ all return 0, so the 'A && ok "…" || no "…"' idiom this file +# is built on cannot hit the C-may-run-when-A-is-true trap SC2015 warns about +# (same reasoning as drill.sh's ok/no). +# shellcheck disable=SC2015 set -u FIX=0; PIN=0 diff --git a/drill/wipe.sh b/drill/wipe.sh index e368f5a..a6c4ed0 100644 --- a/drill/wipe.sh +++ b/drill/wipe.sh @@ -19,6 +19,11 @@ # NOT 'set -e': on a wipe, a step that finds nothing to remove is success, # not failure. Every removal states what it did; silence is never trusted # (the exit-code lesson, again). +# +# The file is one long 'removal && say "did X" || say "X failed"'. say always +# returns 0, so the C-may-run-when-A-is-true trap SC2015 warns about cannot fire +# here (same reasoning as drill.sh's ok/no). +# shellcheck disable=SC2015 set -u YES=0; PURGE_STORAGE=0 diff --git a/host/migrate-host.sh b/host/migrate-host.sh index 688e920..e7bf238 100644 --- a/host/migrate-host.sh +++ b/host/migrate-host.sh @@ -19,9 +19,13 @@ # # NOT 'set -e' around the per-box work: a box that fails one step is reported # and skipped, not a crash that abandons the rest mid-migration. +# +# The report idiom is 'action && say "did X" || warn/die': say and warn always +# return 0, so the C-may-run-when-A-is-true trap SC2015 warns about cannot fire +# on those lines (same reasoning as drill.sh's ok/no). +# shellcheck disable=SC2015 set -u -GW_NEW=10.88.0.1 say() { printf 'migrate: %s\n' "$*"; } warn() { printf 'migrate: WARNING: %s\n' "$*" >&2; } die() { printf 'migrate: ERROR: %s\n' "$*" >&2; exit 1; } @@ -86,9 +90,9 @@ rehome_one() { # 3. VERIFY THE EFFECT, not the exit codes (the whole repo's lesson). The box # must be on 10.88 and actually resolve+reach the internet on its new leg # before we call it migrated. - local i ip + local _i ip ip="" - for i in $(seq 1 30); do + for _i in $(seq 1 30); do ip="$(incus exec "$b" -- ip -4 -o addr show scope global /dev/null \ | awk '{for(i=1;i +# 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)) +} + +BOX="$ROOT/bin/box" + +# --------------------------------------------------------------------------- +# The CLI contract: dispatch, help, usage errors. No incus needed — these all +# resolve before any daemon call. Exit codes are box's own (0 ok / 1 wrong / +# 2 you-asked-wrong), read straight from bin/box and confirmed by running it. +# --------------------------------------------------------------------------- +# box with no args is 'help' (cmd="${1:-help}"), which prints the general usage +# and exits 0 — NOT rig's exit-2 bare-usage. Assert box's actual contract. +check "no args → general help, exit 0" 0 "USAGE" "$BOX" +check "no args help names the command form" 0 "box " "$BOX" +check "--help exits 0" 0 "USAGE" "$BOX" --help +check "-h exits 0" 0 "USAGE" "$BOX" -h +check "help exits 0" 0 "USAGE" "$BOX" help +check "help → that command's usage" 0 "usage: box new" "$BOX" help new +check "--version exits 0" 0 "box" "$BOX" --version +# Unknown command is a usage error (2), and it says so — the suggester may add a +# 'did you mean', but the stem is stable. +check "unknown command exits 2" 2 "unknown command" "$BOX" frobnicate +check "unknown command points at help" 2 "box help" "$BOX" zzzzzz +# Options before the command are the classic mistake; box names the fix. +check "option before command exits 2" 2 "options come after the command" "$BOX" --json list +# A missing required positional is a usage error carrying that command's synopsis. +check "new without --name exits 2" 2 "usage: box new" "$BOX" new +check "shell without a box exits 2" 2 "usage: box shell" "$BOX" shell +check "restore without arg2 needs a box first" 2 "usage: box restore" "$BOX" restore +# An unknown flag is refused, not swallowed as a positional (the --labl bug). +check "unknown flag on list exits 2" 2 "unknown option" "$BOX" list --nope +# A flag that needs a value and gets none. +check "--name with no value exits 2" 2 "--name needs a value" "$BOX" new --name + +# --------------------------------------------------------------------------- +# A shim `id` on PATH: lets us drive install.sh's DEST branch with a canned uid + +# group output, exactly the way rig drives assert_runner_repo against fixtures. +# --------------------------------------------------------------------------- +SHIMDIR="$(mktemp -d)" +cat > "$SHIMDIR/id" <<'SHIM' +#!/usr/bin/env bash +# Fake `id`: -u prints $FAKE_UID, -nG prints $FAKE_GROUPS. Just enough for +# install.sh's DEST branch, which only ever asks these two. +case "${1:-}" in + -u) printf '%s\n' "${FAKE_UID:-1000}" ;; + -nG) printf '%s\n' "${FAKE_GROUPS:-}" ;; + *) exit 0 ;; +esac +SHIM +chmod +x "$SHIMDIR/id" + +# --------------------------------------------------------------------------- +# install.sh — #71 global/root install. bash -n first, then drive the actual +# DEST/BINDIR branch with the shim id (the functional proof the contract asks +# for), then grep the root-only pieces that a daemon-free run cannot exercise. +# --------------------------------------------------------------------------- +check "install.sh is valid bash" 0 "" bash -n "$ROOT/install.sh" +# Extract EXACTLY the DEST/BINDIR if/else/fi (the first `id -u -eq 0` block) and +# print what it resolved — the same "run the pure block in isolation" trick rig +# uses for its embedded dump script. Fail closed: a mangled extraction is caught +# by the /opt/box grep below before any resolution is trusted. +DBLOCK="$(mktemp)" +awk '/id -u.*-eq 0/{f=1} f{print} f&&/^fi$/{exit}' "$ROOT/install.sh" > "$DBLOCK" +# The $DEST/$BINDIR here are LITERAL text appended into the extracted block — they +# must expand when that block RUNS, not when this printf writes it. Hence single +# quotes; SC2016 is the intent. +# shellcheck disable=SC2016 +printf '\nprintf "DEST=%%s BINDIR=%%s\\n" "$DEST" "$BINDIR"\n' >> "$DBLOCK" +check "install.sh: DEST block extracted (guards the awk)" 0 "/opt/box" cat "$DBLOCK" +check "install.sh: the extracted DEST block is valid bash" 0 "" bash -n "$DBLOCK" + +dest() { # dest [extra env assignments...] — resolve DEST/BINDIR + local uid="$1"; shift + FAKE_UID="$uid" HOME=/home/tester PATH="$SHIMDIR:$PATH" env "$@" bash "$DBLOCK" +} +# Root: the global path — a system tree other users can read (#71). +check "install.sh: root → DEST=/opt/box" 0 "DEST=/opt/box" dest 0 +check "install.sh: root → BINDIR=/usr/local/bin" 0 "BINDIR=/usr/local/bin" dest 0 +# Non-root: unchanged, the solo path. +check "install.sh: non-root → DEST=\$HOME/.local" 0 "DEST=/home/tester/.local/share/box" dest 1000 +check "install.sh: non-root → BINDIR=\$HOME/.local" 0 "BINDIR=/home/tester/.local/bin" dest 1000 +# BOX_HOME / BOX_BIN still win on BOTH branches — the scripting override. +check "install.sh: BOX_HOME overrides the root default" 0 "DEST=/srv/box" dest 0 BOX_HOME=/srv/box +check "install.sh: BOX_BIN overrides the root default" 0 "BINDIR=/srv/bin" dest 0 BOX_BIN=/srv/bin +check "install.sh: BOX_HOME overrides the non-root default" 0 "DEST=/srv/box" dest 1000 BOX_HOME=/srv/box +rm -f "$DBLOCK" +# The root-only world-readable chmod (#71): the tree is EXECUTED by other users, +# so root must open read+traverse. Grep it, and that it is root-guarded so the +# per-user install stays byte-identical to before. +# $DEST is a LITERAL in the grep pattern (install.sh's own variable) — single +# quotes intended. +# shellcheck disable=SC2016 +check "install.sh: root makes the tree world-readable (a+rX)" 0 "" \ + grep -qF 'chmod -R a+rX "$DEST"' "$ROOT/install.sh" +check "install.sh: the a+rX is root-guarded" 0 "" \ + bash -c 'grep -B2 "chmod -R a+rX" "'"$ROOT"'/install.sh" | grep -q "id -u.*-eq 0"' +# #66's flow, preserved: confirm-before-download, and no-op if already installed. +check "install.sh: still confirms before downloading (#66)" 0 "" \ + grep -qF 'confirm "Install box from' "$ROOT/install.sh" +check "install.sh: still no-ops on an existing install (#66)" 0 "" \ + grep -qF 'already installed' "$ROOT/install.sh" + +# --------------------------------------------------------------------------- +# Templates — #65 tmux. `box tmux` runs `tmux new-session` INSIDE the box, so a +# template that never installs tmux fails with "tmux: command not found". Every +# template must carry it in its cloud-init package list. +# --------------------------------------------------------------------------- +for t in blank claude codex grok; do + check "template '$t': installs tmux (#65)" 0 "" \ + grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$ROOT/templates/$t/user-data.yaml" +done + + +echo "---" +echo "$PASS passed, $FAIL failed" +rm -rf "$SHIMDIR" +[ "$FAIL" -eq 0 ]