box/CHANGELOG.md

185 lines
7 KiB
Markdown
Raw Permalink Normal View History

# Changelog
History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md),
which records not just what changed but what each drill run proved.
feat: merging a release-labeled PR is the release (#96) The 0.7.0 ceremony exposed the gap: the release PR merged with four approvals and nothing happened, correctly, because publishing hung off a separate, manual, silent-when-forgotten tag push — the worst failure shape, no error and no red X. The ship decision already lives in the release PR, so the merge now IS the release. release.yml grows a second door: pull_request closed on main, gated on merged == true AND the hand-set release label (read from the event payload — no extra permission). Four asserts, in order, each fail-loud and creating nothing: VERSION at the merge commit is non--dev; VERSION changed in this PR (merge vs first parent — the -dev interlock that kills a mislabeled ordinary PR); the version's CHANGELOG.md section extracts non-empty via the existing release-notes.sh; and no tag or release exists yet. Then, in the same job, it creates the tag ref at the merge commit via the API and publishes with gh release create --verify-tag. Same-job on purpose: a GITHUB_TOKEN-created tag triggers no workflows (GitHub's anti-recursion), so the tag door can never fire off it and double-publish, and the no-existing assert covers a manual tag racing the merge. The tag-push path stays step-for-step identical as the documented manual fallback and backfill, gated to the push event so a closed PR never runs it against a branch ref. CONTRIBUTING.md's Releases section now reads "the maintainer's merge IS the release", with the manual tag ritual kept as the fallback. test/release.sh grep-pins the merged+labeled gate, all four asserts, the same-job tag+publish, and that the tag-push trigger survives — in the same daemon-free, fail-closed style. Fixes #96 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 15:19:31 +00:00
## Unreleased
2026-07-21 14:49:51 +00:00
## 0.9.0 — 2026-07-21
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
### Added
- `box import` stamps the trip, leaving the artifact's own mint stamp intact
(#131)
- A minted box records how it was minted, and `box info` reads it back (#103)
- A clone re-stamps its own provenance instead of inheriting its source's
(#103)
- `box info` grew a provenance block, blank on boxes that predate the stamp
(#103)
- Every fresh mint marks a `pristine` snapshot, before rig converges anything
(#104, heavy-duty/rig#62)
- A mint that converges a tenant role marks a `bootstrapped` snapshot (#130)
refactor: one drill record per version, in drills/ Drill records move from sections inside drill/RUNS.md to one file per version: drills/<version>.md. drill/RUNS.md is untouched — it stays the harness's own run log, traps table and lore. drills/ is release evidence only, and the docs now say which is which. The old guard parsed headings: em-dash field matching, an optional ' — DATE' tail, whole-version comparison so 0.9.0-rc1 could not satisfy 0.9.0, avoiding \x escapes because CI runs mawk not gawk, and a non-blank body rule. Every one of those existed only because records shared one file, and two separate defects were found in review because of that complexity — the sed '/./,$!d' whitespace bypass, and heading-grammar drift from the sibling repos. One file per version makes almost all of it unrepresentable: 0.9.0.md and 0.9.0-rc1.md are simply different files, so whole-version matching is free rather than a trap. The guard keeps only the rule that was never about headings — a record of pure whitespace is not a record — and keeps the failure message that names the version, the path, the unblock, and the recorded-waiver escape hatch. Plain drills/, not .drills/ — a dot-directory is invisible to globs without dotglob, which is what caused #116 and #118. Also corrects the release-drill framing: the three repos' drills are INDEPENDENT and run in any order. What dissolves the box<->rig recursion is that every drill pins the same fixed candidate refs, not sequencing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:26:47 +00:00
- CI refuses a release PR with no drill record at `drills/<version>.md`
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
### Changed
- `state:needs-human` is set at handoff, not by the cron (#141)
- PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
is in the way); `state:needs-rebase` is retired
- BREAKING: the tenant templates carry rig's family suffix — `claude`
`claude-box`, `codex``codex-box`, `grok``grok-box`, `staging`
`staging-box` (#123, heavy-duty/rig#76)
- Changelog entries are one line each, and the whole file now follows the rule
(#147)
fix: lint the release path — globstar does not descend into dot-directories CI's shellcheck sweep set globstar and globbed `bin/* **/*.sh`. globstar makes `**` descend into subdirectories, but a glob still does not MATCH a dot-prefixed name, so `**/` never entered `.github/` and three scripts were never linted: changelog-armed.sh (the #108/#110 guard that gates every PR), release-notes.sh (which produces the published release body), and labels-reconcile.sh (the label state machine). That is the entire release path, while the step's own comment promised the opposite — that a script in a new subdirectory is linted without anyone editing a list. Latent, not broken: all three pass shellcheck as-is, so this is a no-op on current code. What changes is that a regression in them would be caught. dotglob alongside globstar closes it, measured rather than assumed: it adds exactly those three and nothing else — a checkout's .git carries no *.sh, its hooks shipping as *.sample, so `**/*.sh` does not wander into it. The one-time fix is dotglob; what keeps the gap shut is the CLASS check, in the same shape as the eof_guard_sweep of #112. The sweep now compares the globbed set against `git ls-files '*.sh'` and fails naming any tracked script it does not cover, so a future dot-directory or shopt subtlety cannot silently lint a subset and pass. eof_guard_sweep carried the identical blind spot — it rebuilds the same glob — and is widened the same way. A no-op today: the three scripts set errexit, so they are in that class by construction, but none of them reads. Refs #116
2026-07-19 23:32:49 +00:00
### Fixed
- `test/release.sh` is green on the release ceremony's own tree
- `changelog-monotonic.sh` no longer lets a duplicate heading through when it
cannot see the base (#143)
- An unreadable check rollup no longer reads as "nothing is failing"
- `state:needs-human` no longer appears on PRs a human cannot merge (#136)
- CI's shellcheck sweep now lints `.github/scripts/*.sh` (#116)
- A PR can no longer delete or duplicate a shipped changelog section and stay
green (#122)
- An upgrade over a pre-0.7.0 flat `/opt/box` no longer skips host setup (#115)
- Host setup runs the version it just installed, not whatever `current` points
at (#115)
- The pre-0.7.0 migration says what it left behind, and how to keep or reap it
(#117)
- `teardown-host.sh` refuses a terminal-less run instead of aborting mute
(#113)
- `drill/wipe.sh` no longer carries #102's SIGPIPE shape, and the pin sweeps
the class (#107)
- The racing-reader sweep guards the class, not one spelling, and names
`incus config trust list` as a second writer (#124)
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`, `awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the writer, and yield the same 141 under pipefail. The pin guarded the instance spelling of the very thing it existed to generalise. Both halves of the matcher are alternations now. Readers are deliberately not narrowed to the early-exit spellings: telling `grep -q` from `grep -c` by regex is precision that rots, and all six `ufw status` sites in the tree already capture first, so banning the pipe outright costs nothing real. Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into `grep -q` as the --purge leftover assert, under `set -euo pipefail` — so unlike drill/wipe.sh nothing but the writer's size was holding it, and left of `&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a host that still trusts the revoked user's certificate and called the purge complete: fail-open, on the path whose job is to prove access is gone. Now captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so the first CSV row anchors like the `^` it replaces. Writers are enumerated rather than generalised — ~150 legitimate `| grep` sites exist under host/ and drill/, nearly all re-reading a captured string — so the sweep claims only what it can check. The `id -nG | tr | grep -qx` shapes in grant/revoke/setup are left alone: single tiny writes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:09 +00:00
## 0.8.0 — 2026-07-19
### Added
- Merging the release PR is the release, and the release re-arms main itself
(#96)
### Fixed
- The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let main sit
disarmed (#108, heavy-duty/rig#67)
- Ctrl-D at a confirmation prompt aborts out loud instead of exiting in
silence (#111)
- `box restore` asks before it destroys, in the row's own words rather than
`rm`'s (#105)
- `box-firewall` could hand a UFW host the no-UFW firewall, ~2% of the time
(#102)
- A missing firewall log now diagnoses itself (#102)
- `box grant` provisions an `incus-admin` member instead of refusing them
(#99)
## 0.7.0 — 2026-07-19
### Added
- The installer defaults to the latest release, and releases publish
themselves (#83)
- `setup-host` auto-picks a free subnet — nested box-in-box with zero flags
(#80)
- `setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another (#80)
- `box doctor` knows the #80 signature: a gateway held as a local address, and
duplicate connected routes for the uplink subnet
- The `staging` template — a server-class, creds-free seed (#81)
- The `BOX_BOOTSTRAP_ROLE` template key, auto-run at mint (#81)
- The rig pin point: `RIG_REPO` / `RIG_REF` (#81)
- Server-posture template keys `BOX_REQUIRE_VM` and `BOX_AUTOSTART` (#81)
- The template test suite discovers `templates/*/` instead of hardcoding the
list (#81)
- `box export` / `box import` — a box's state that survives the box and the
host (#70)
- Versioned installs at `<root>/versions/<v>`, with `box versions` and
`box use` (#66)
- A real uninstall: `box uninstall [<version>] [--all] [--purge-host]`, ending
in an absence assert
- `BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree, and CI's
rehearsal drills the uninstall to zero residue
- `test/cli.sh` drives real installs against throwaway roots and a fake incus
(154 checks)
### Changed
- Thin templates — box mints a creds-free seed, rig's bootstrap roles converge
the tenant content (#81, heavy-duty/rig#31)
### Fixed
- A wedged `incus launch` fails loudly, not forever: the launch phase is
narrated and time-boxed (#93)
- UFW's gateway carve-out converges with the bridge, and the doctor can see it
(#86)
- The boot-time gateway fallback is gone — an unaddressed bridge leaves the
persisted UFW rules alone (#86)
- `revoke --purge` re-checks the incus-user state, and stats it through
`$SUDO`
- A wedged `$BINDIR/box` no longer blocks installing
2026-07-18 13:39:45 +00:00
## 0.6.0 — 2026-07-18
### Added
- The restricted tier: `box grant` / `box revoke` give a user their own boxes
on the shared hardened `boxnet` (#74)
- CI runs the multi-user rehearsal on a real Incus
- Global / root install — one world-readable tree at `/opt/box` (#71)
- CI and a test suite: `.github/workflows/ci.yml` and `test/cli.sh`
### Fixed
- `box restore` never worked against Incus 6 — it dispatched `incus restore`,
which does not exist
- `box tmux` works on every template — tmux is in each template's package list
(#65)
- `box setup-host` finishes in one run, re-execing itself under
`sg incus-admin` (#63)
- `setup-host` works as root, with or without `sudo`
- `setup-host` grants `incus-admin` to the human, not to root
- `box-firewall.service` reports its state honestly, via `RemainAfterExit=yes`
- `setup-host`'s apt calls can no longer hang on the dpkg lock
Make setup-host privilege-aware; make the drill prove the new contract Review found two real problems, both confirmed by reproducing them. setup-host hardcoded 'sudo' for every privileged call, so install.sh's deliberate root branch — the one that proceeds when id -u is 0 even with no sudo installed — handed off to a script that died on 'sudo: command not found' before doing anything (exit 127, reproduced with env -i and a minimal PATH). The root path was nominal, not real. Privilege is now resolved once: nothing at UID 0, sudo otherwise, a clear error if neither is possible. Two things fell out of that. Root does not need incus-admin at all (UID 0 opens the socket regardless), so adding root to the group was a no-op that also missed the human — under 'sudo install.sh' that is SUDO_USER, who is now the one granted the group. And apt must not hang: install.sh runs setup-host with nobody watching, while a fresh cloud image holds the dpkg lock in apt-daily for its first minutes, so the calls are now bounded and non-interactive. The drill did not exercise any of this. It ran setup-host immediately after install.sh, so the stack existed by the drill's own hand and a run passed identically whether or not install.sh had done a thing — a fresh run converged three times while its messages still described the pre-#63 "first pass may only add you to the group" behaviour. It now asserts the post-install stack in-group, before the clean or anything else mutates the host, which is the assertion that actually proves #64. setup-host then runs exactly once more, after the clean — that one is load-bearing, since the clean deliberately unsets dns.mode and something has to converge it back. DRILL_OWNS_SETUP=1 hands sequencing back to the drill. Pre-setup tripwires now read before install.sh, because install.sh is what triggers setup now; read afterwards they said nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:16:06 +00:00
### Changed
- `drill.sh` asserts the post-install stack instead of building it itself
- `install.sh` asks, sets up the host, and no-ops on re-run (#64)
- `install.sh` never overwrites an existing install
## 0.5.0 — 2026-07-15
The release the project was renamed in: the repo is `heavy-duty/box`, matching
the CLI it ships. Everything legacy-facing is honored forever — the
`user.claudebox=1` tag, the `.claudebox/` runbook folder, the old symlink the
installer retires — but nothing current carries the old name.
### Added
- `codex` and `grok` templates
- `box expose <box> <port> [<host-port>]` — a loopback-only door to a port
inside a box
- Inline resource overrides on `new`: `--cpu`, `--memory`, `--disk` (#57)
- Host lifecycle as verbs: `box setup-host`, `box teardown-host`,
`box migrate-host`
- The `.box/` recipe convention, renamed from `.claudebox/` (both spellings
read)
### Fixed
- VM mints no longer hang at GRUB — boxes launch with
`security.secureboot=false`
- `box expose` actually delivers packets
- Firewall rules converge on upgrade instead of pinning a host to the release
that first ran there
- Failed mints tell you why
- `grok` installs the binary it actually ships
### Changed
- Debrand complete — env vars, install dir, docs, template descriptions and
the README all say `box`; the install URL is `heavy-duty/box`
- The drill grew from 47 to 84 checks