name: ci on: push: branches: [main] pull_request: jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: # fetch-depth: 0, for the changelog-monotonic step below and only # for it. That check is about a DIFF — which release headings the # merge base had — so it needs the base branch's history present, # and the default depth-1 checkout has none of it. An explicit # `git fetch origin ` would be narrower, but it has to be # right on both event types and on fork PRs, and getting it subtly # wrong degrades to a SKIP (a guard that silently stops guarding — # the exact failure this repo keeps refusing). Full history on a # pure-bash tree costs a second; the STRICT flag below turns any # remaining skip red rather than green. fetch-depth: 0 - 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. # # dotglob is not decoration (#116): globstar makes `**` descend, but a # glob still does not MATCH a dot-prefixed name, so `**/` never entered # `.github/` — and the whole release path (changelog-armed.sh, which # gates every PR, release-notes.sh, labels-reconcile.sh) went unlinted # while the comment above told the next author it was covered. # Measured when this invariant was introduced: dotglob added exactly # those three and nothing else — a checkout's `.git` carries no `*.sh` # (its hooks ship as `*.sample`), so `**/*.sh` does not wander into it. # # The sweep below is the CLASS check, same shape as the eof_guard_sweep # in test/cli.sh (#112): the one-time fix is `dotglob`, but what keeps # the gap from reopening is asserting that every TRACKED script is in # the set actually handed to shellcheck. `git ls-files` is the authority # on what the repo contains; if the glob ever drifts from it again — # another dot-directory, another shopt subtlety — CI says which files # escaped instead of quietly linting a subset and passing. run: | shopt -s globstar dotglob files=(bin/* **/*.sh) printf 'shellcheck: %s\n' "${files[@]}" missing="$(comm -13 \ <(printf '%s\n' "${files[@]}" | sort -u) \ <(git ls-files '*.sh' | sort -u))" if [ -n "$missing" ]; then echo "tracked scripts the shellcheck sweep does not cover (#116):" printf '%s\n' "$missing" | sed 's/^/ /' exit 1 fi shellcheck -x "${files[@]}" - name: cli tests run: bash test/cli.sh - name: release-flow tests run: bash test/release.sh # The changelog is ARMED for the next entry (#108). Its own step rather # than a line inside test/release.sh: this one asserts a fact about THIS # tree, not about the release machinery, so when it goes red the log # says which check found the drift without anyone reading a suite. - name: changelog is armed for the next entry uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0 # ...and no SHIPPED release heading was deleted or DUPLICATED (#122, #143). # Its own step for the same reason as the one above — when it goes red the # log names the invariant that broke — but a DIFFERENT invariant: armed is # a fact about this tree, monotonicity is a fact about this tree versus # its merge base. STRICT=1 so a checkout that cannot reach the base ref # fails here instead of skipping quietly forever. # # NOT pull-request-only, and that is the #143 fix at the workflow level. # The two halves have different vacuity: 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 on `pull_request` left a duplicate that # reached main by any other route unasserted forever. # # The `|| github.ref_name` fallback is load-bearing, not defensive. On a # push event `github.base_ref` is EMPTY, so the argument would collapse to # a bare `origin/`, which does not resolve — and STRICT=1 correctly # promotes that to a hard failure, turning every push to main red. With # the fallback it resolves to the pushed branch, whose merge base with # HEAD is HEAD or its parent: containment passes vacuously, exactly as the # old `if` intended, while uniqueness now runs on every push. # ...and a RELEASE tree has a drill record. Its own step for the same # reason as the two above — when it goes red the log names the invariant # that broke — and a third invariant again: armed is about the shape of # the changelog, monotonic about the diff, this one about whether the # ritual CONTRIBUTING.md hangs on the release PR actually happened. # # NOT pull-request-only, and for the same reasoning the monotonic guard # was un-gated in #143: the release does not finish at the PR. The merge # that publishes it is a PUSH to main carrying the same bare VERSION, # and it is release.yml's trigger — so a check that only ever ran on the # PR would leave the tree that actually ships unasserted, and any bare # VERSION reaching main by another route (a direct push, a backfill, # the manual tag path) unasserted forever. On a -dev tree, which is # every ordinary push and PR, the script no-ops green by design, so # running it everywhere costs nothing and closes the route. - name: a release tree has a drill record uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0 - name: no shipped changelog heading was deleted or duplicated uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0 - name: vendored ceremony doctrine matches the pin uses: heavy-duty/ceremony/actions/docs-sync@0.1.0 # 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 # in container mode: the tier's mechanics (grant, confinement, the network # contract, revoke) are identical for containers and VMs — the nft bridge # drop, the ACL, dns.mode=none and port_isolation all bind to boxnet, not # to the instance type. What container mode canNOT validate is the VM trust # boundary itself; that stays a real-hardware ritual (drill/RUNS.md), same # as the full drill. So: every PR proves the tier's semantics, and a # release still proves the boundary. rehearsal: runs-on: ubuntu-latest timeout-minutes: 40 steps: - uses: actions/checkout@v4 - name: install incus run: | sudo apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus - name: global install, via install.sh itself (the #71 layout, versioned) # install.sh, not a cp -r mimic: BOX_INSTALL_SOURCE points it at this # checkout, so CI proves the INSTALLER under review — the versioned # layout, the current symlink, the PATH chain — not a hand-built # imitation of it. Setup is run explicitly in the next step, so its # output is its own CI section. run: | sudo BOX_YES=1 BOX_SKIP_SETUP_HOST=1 BOX_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh # assert what landed: the layout, the chain, and that it answers readlink -f /usr/local/bin/box | grep '^/opt/box/versions/' /usr/local/bin/box --version /usr/local/bin/box versions - name: setup-host run: sudo bash /opt/box/current/host/setup-host.sh - name: doctor — the baseline is provable before anything is judged run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh - name: multi-user rehearsal (criteria a-l, container mode) run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container # The #70 round-trip, on the SAME live daemon: a box's state must # survive 'box rm' via export → import. Container mode for the same # reason the rehearsal uses it — export/import are backup mechanics # (tarball out, tarball in, re-stamp), identical across instance types; # the VM trust boundary stays a real-hardware ritual. Every assertion # is state observed AFTER the original box was deleted: the file # written pre-export, the snapshot, the boundary tag, a live agent. - name: export/import round-trip — state survives 'box rm' (#70) run: | set -eux sudo box new --name keeper --container sudo box exec keeper -- sh -c 'echo survives > /home/dev/proof' sudo box snapshot keeper pre-export sudo box down keeper sudo box export keeper /tmp/keeper.tar.gz sudo test -s /tmp/keeper.tar.gz sudo box rm keeper --force sudo box import /tmp/keeper.tar.gz --name keeper2 test "$(sudo incus config get keeper2 user.box)" = 1 sudo incus exec keeper2 -- true sudo box exec keeper2 -- cat /home/dev/proof | grep -qx survives sudo incus snapshot list keeper2 --format csv | grep -q '^pre-export' # the collision boundary, live: the name is taken, import must refuse if sudo box import /tmp/keeper.tar.gz --name keeper2; then echo 'collision was not refused'; exit 1 fi sudo box rm keeper2 --force - name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue # The full-removal order, end to end on the real daemon: revoke a # granted user (--purge asserts its own absence, incl. the incus-user # state dir), tear the stack down, uninstall the tree — then assert # NOTHING survived: no networks, profiles, ACLs, nft tables, systemd # units, files or symlinks. The uninstall was flaky exactly because # nobody measured this. run: | set -x sudo useradd -m -s /bin/bash uninstdrill sudo BOX_YES=1 /usr/local/bin/box grant uninstdrill uid="$(id -u uninstdrill)" sudo BOX_YES=1 /usr/local/bin/box revoke uninstdrill --purge sudo test ! -e "/var/lib/incus/users/$uid" ! sudo incus project show "user-$uid" ! sudo incus config trust list --format csv | grep -q "incus-user-$uid" # The COMBINED verb, --force only, deliberately no BOX_YES and no # TTY: this is the exact invocation that used to die at teardown's # own prompt when consent was not forwarded (--purge-host now # passes --yes through under --force/BOX_YES). sudo /usr/local/bin/box uninstall --all --purge-host --force # zero residue: the daemon's state... ! sudo incus network show boxnet ! sudo incus profile show box-net ! sudo incus network acl show box-isolate # ...the firewall and its boot persistence... ! sudo nft list table inet box ! sudo nft list table bridge box sudo test ! -e /etc/systemd/system/box-firewall.service sudo test ! -e /usr/local/sbin/box-firewall # ...and the install itself: files AND symlinks, both name generations sudo test ! -e /opt/box sudo test ! -e /usr/local/bin/box sudo test ! -L /usr/local/bin/box sudo test ! -e /usr/local/bin/claudebox sudo test ! -L /usr/local/bin/claudebox # NOT run here: the full drill (drill/drill.sh). It rehearses the whole # surface — cold template mints, expose, migration — and wants a real host # and the better part of an hour. The rehearsal job above is the CI-shaped # slice of the same discipline: isolation claims are still tested on a real # daemon, never reasoned about (docs/box-design.md).