forked from heavy-duty/box
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base blob. It sat downstream of all three, so every degradation path returned success on a tree carrying a duplicate. The base-blob path was the worst: a branch that introduces CHANGELOG.md hit a bare `exit 0` on a message that was true about deletion and silent about the duplicate in front of it. STRICT could not reach it — STRICT guards the two skip() calls, and that is not one of them. That inverted the two halves. Deletion needs a diff to see; duplication is the one release-notes.sh actually mis-renders, re-arming its grab on the second heading (#118). The half with the live extraction bug behind it had the most ways to silently not run. Moved, not rewritten. The skip messages now say containment skipped and that uniqueness already passed. The CI step is no longer pull_request-only, with a `github.ref_name` fallback because base_ref is empty on a push and a bare `origin/` under STRICT would redden every push to main. Found by claude-bot-andresmgsl reviewing heavy-duty/rig#99 and heavy-duty/cast#134, which inherited the ordering from here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
11 KiB
YAML
200 lines
11 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: labels state-machine tests
|
|
run: bash test/labels-reconcile.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
|
|
run: bash .github/scripts/changelog-armed.sh
|
|
# ...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.
|
|
- name: no shipped changelog heading was deleted or duplicated
|
|
env:
|
|
CHANGELOG_MONOTONIC_STRICT: '1'
|
|
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
|
|
|
|
# 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).
|