box/.github/workflows/ci.yml
2026-07-23 13:40:44 +00:00

217 lines
12 KiB
YAML

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 <base>` 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 on this tree: dotglob adds 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: no PR code runs on a self-hosted runner
uses: heavy-duty/ceremony/actions/runner-isolated@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).