Compare commits

...

79 commits
0.8.0 ... main

Author SHA1 Message Date
Daniel Marin
c33794ce70
Merge pull request #170 from codex-bot-andresmgsl/build/169-exec-newlines
Some checks failed
ci / check (push) Has been cancelled
ci / rehearsal (push) Has been cancelled
release / release (push) Has been cancelled
fix: preserve multiline box exec commands
2026-07-25 18:42:24 +01:00
codex-bot-andresmgsl
7e05328a6d test: rehearse exec boundary on real Incus 2026-07-25 17:24:26 +00:00
codex-bot-andresmgsl
7247768465 fix: preserve box exec command newlines 2026-07-25 17:16:00 +00:00
Daniel Marin
f8ca59c22e
Merge pull request #164 from codex-bot-andresmgsl/build/14-convert-box
feat: adopt the shared release ceremony
2026-07-23 18:07:55 +01:00
codex-bot-andresmgsl
e4d9005aca fix: keep labels caller within pinned feature set 2026-07-23 16:52:58 +00:00
codex-bot-andresmgsl
3bae42157e fix: honor the released ceremony guard set 2026-07-23 15:20:03 +00:00
codex-bot-andresmgsl
b0d4aff964 refactor: retire repository-local release machinery 2026-07-23 13:45:08 +00:00
codex-bot-andresmgsl
1c3d873bf2 feat: adopt ceremony callers and doctrine 2026-07-23 13:40:44 +00:00
Daniel Marin
c93e230396
Merge pull request #158 from dan-claude-bot/feat/kimi-box-template
Some checks failed
ci / check (push) Has been cancelled
ci / rehearsal (push) Has been cancelled
release / release-on-merge (push) Has been cancelled
release / release (push) Has been cancelled
feat: kimi-box template — the Moonshot Kimi CLI agent seed
2026-07-22 16:31:12 +01:00
dan-claude-bot
a4684f7afa docs+drill: add kimi-box to the two enumeration spots review caught
box-recipe.md's agent-template sentence and the drill's mint-surface
check (list + ok message) both enumerate the agent seeds by hand;
kimi-box joins them, matching what the grok-box addition did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:22:20 +00:00
dan-claude-bot
bf74744bda feat: kimi-box template — the Moonshot Kimi CLI agent seed
The fourth agent seed, thin per #81: Debian 13, the 'kimi' user, tmux
and rig — everything the box becomes lands via 'rig bootstrap kimi-box'
(heavy-duty/rig#109). Same resources as the sibling agent seeds. The
template loop in test/cli.sh discovers it dynamically; doc lists and the
README table gain the row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:11:22 +00:00
github-actions[bot]
a17903f07c chore: bump main to 0.9.1-dev — a dev install must not impersonate 0.9.0 2026-07-21 18:06:51 +00:00
Daniel Marin
86af660818
Merge pull request #148 from dan-claude-bot/release/0.9.0
release: 0.9.0
2026-07-21 19:06:38 +01:00
dan-claude-bot
2c3b329b8f docs: record the 0.9.0 drill as waived, with what that leaves unproven 2026-07-21 17:42:38 +00:00
dan-claude-bot
991acd2ba6 release: 0.9.0 2026-07-21 17:42:38 +00:00
Daniel Marin
0b5bedd6e0
Merge pull request #151 from dan-claude-bot/refactor/drills-per-version
refactor: one drill record per version, in drills/
2026-07-21 18:41:42 +01:00
dan-claude-bot
1ffd20d35b test: the guard's verdict must match the tree, not always pass 2026-07-21 16:42:39 +00:00
dan-claude-bot
f63fa952d4 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:35:28 +00:00
Daniel Marin
115b9c026e
Merge pull request #149 from dan-claude-bot/feat/drill-gate
feat: CI refuses a release PR with no drill record
2026-07-21 17:17:39 +01:00
dan-claude-bot
877d9ded55 fix: whitespace is not a drill record 2026-07-21 15:53:29 +00:00
dan-claude-bot
0678b22e01 feat: CI refuses a release PR with no drill record
CONTRIBUTING.md has said since #96 that the release PR is where the full
real-hardware drill hangs, recorded in drill/RUNS.md. No release has ever
done it: #95, #114 and #148 all shipped as a VERSION bump plus a CHANGELOG.md
stamp, and RUNS.md carries no '## Release drill' section at all. A reviewer
bot finally blocked on it — which is the point. The one time it was caught is
the one time somebody happened to look, and that is not a gate.

So the rule moves out of the document and into CI.

.github/scripts/drill-recorded.sh, keyed on VERSION the same way
changelog-armed.sh is: a -dev tree passes with nothing to assert (which is
what keeps the guard installable — a version that fired on every PR would be
switched off inside a day), and a bare VERSION must carry a section headed
'## Release drill — <version>', optional ' — <date>' tail, with prose under
it. The version is compared as a whole awk field, never as a substring, so
0.9.0 cannot be satisfied by a 0.9.0-rc1 drill or vice versa —
release-notes.sh's trap, solved the same way so the two cannot disagree
about what "the section for X" means.

What it asserts is a RECORD, deliberately, not a passing drill: CI cannot run
the drill (real hardware, the better part of an hour — ci.yml says as much
about the rehearsal job it runs instead). That also keeps the maintainer
waiver honest — a release that must ship undrilled writes that under the same
heading, so the skip is a reviewable line in the diff rather than silence.

Wired into ci.yml as its own step, NOT pull-request-only, for the reasoning
#143 applied to the monotonic guard: the merge that publishes a release is a
push to main carrying the same bare VERSION, so a PR-only check would leave
the tree that actually ships unasserted.

test/release.sh grows 27 cases (134 -> 161). Every fixture carries its own
VERSION and its own RUNS.md — reaching for $ROOT/VERSION is the coupling #146
had to fix, and it goes red on the ceremony tree, the one tree where the
release suite most needs to be trustworthy.

CONTRIBUTING.md now states the flow (draft -> ready -> bot round -> drill ->
state:needs-human -> merge), the heading format, that three releases shipped
through the gap, and the recorded-waiver escape.

It also describes the drill as ONE orchestrated run over the whole stack,
because box and rig are mutually recursive and cannot be linearly ordered:
rig sits below box as the host-builder ('rig bootstrap --host yes' installs
box and runs setup-host) and above it as the guest-converger (a box new seed
curls rig's installer and runs 'rig bootstrap <tenant>-box'), the inverted
edge bin/box already documents as rig#28. The run is host bootstrap -> box
new -> tenant converge -> cast. It drills CANDIDATE REFS, not released
artifacts: RIG_REPO/RIG_REF are mint-time environment variables defaulting
to heavy-duty/rig@main (bin/box:1116-1117), so a run pins the exact commits
under test and no repo must be released before another can be drilled.
Drilling the candidate is drilling the release — a release diff is VERSION +
CHANGELOG.md, so nothing executable differs. One run, one shared run ID; each
repo records its own legs citing that ID and the other two SHAs, and the
guard reads only this repo's file.

Recorded as a known gap, not fixed here: a released box still defaults
RIG_REF to main, so a box minted a week after a drill is not the drilled
combination. Pinning RIG_REF to a released rig tag in the templates is the
outstanding step from #81 (rig#32 step 5).

LABELS.md documents blocker:drill-pending — ceremony correct but unevidenced,
maintainer-created because the bot account gets a 403 on label creation, with
`blocked` standing in until it exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:15 +00:00
Daniel Marin
92dc3a2bdc
Merge pull request #147 from dan-claude-bot/docs/changelog-one-line
docs(changelog): one line per entry, and a pass over the whole file
2026-07-21 15:43:50 +01:00
Daniel Marin
67d5890e2e
Merge pull request #146 from dan-claude-bot/fix/release-suite-ceremony-tree
fix: the release suite accepts the ceremony's own tree
2026-07-21 15:43:35 +01:00
dan-claude-bot
683217ee42 docs(changelog): one line per entry, and a pass over the whole file 2026-07-21 13:56:34 +00:00
dan-claude-bot
f7058b2814 fix: the release suite accepts the ceremony's own tree 2026-07-21 13:47:53 +00:00
Daniel Marin
9fcd2e4a7f
Merge pull request #132 from dan-claude-bot/feat/bootstrapped-snapshot
feat: mark 'bootstrapped' after a rig hook box watched succeed
2026-07-21 13:21:31 +01:00
Daniel Marin
82c1888381
Merge pull request #133 from dan-claude-bot/feat/import-provenance
feat: box import records the trip, without rewriting who the box was
2026-07-21 13:09:30 +01:00
dan-claude-bot
4c595eb945 fix: offer the pristine rollback only when the mark actually exists
Both reviewers landed on the same line independently, and they are right.
cmd_new's hook-failure path offered `box restore $name pristine`
unconditionally, but three mints reach that line with no pristine mark, all
by this stack's own design: a `dir` pool (skipped, no CoW),
BOX_SNAPSHOT_PRISTINE=0, and a create incus refused (warned, mint continued).
On a `dir` host that is EVERY hook failure — so the operator standing at the
one moment the message exists for copy-pastes a restore that errors.

It could not simply be read off the return status. The never-fatal contract
means snapshot_mark returns 0 on all four paths on purpose — taken, skipped,
knob-disabled, refused — so `if snapshot_mark ...` cannot distinguish a mark
that exists from one that does not, and making it distinguish would put the
mint's success at the mercy of a checkpoint, which is what that contract
exists to prevent.

So the fact is recorded rather than inferred: `marks` accumulates the labels
snapshot_mark actually created, and `mark_taken <label>` answers the only
question a rollback offer may ask. Per-label, so one mark never answers for
another.

This is the same refusal the message one line earlier already makes when it
declines to invent a `bootstrapped` it never watched — a promise the disk
cannot deliver is the failure mode #130 was built to refuse. It just was not
applied to the restore offer sitting under it.

Pinned per path rather than once, because the three no-mark paths fail
differently and a single case would let the other two regress silently: five
drives over snapshot_mark + mark_taken (created / dir-skip / knob / refused /
no bleed between labels) and a static check that the call site is gated.
Verified by mutation — dropping the recording, and un-gating the offer, each
fail the suite rather than passing quietly.
2026-07-21 11:57:01 +00:00
dan-claude-bot
050c90966e 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-21 11:57:01 +00:00
dan-claude-bot
91349acac1 fix: the import count guard closes the octal hole, not just the garbage one
All three reviewers landed on the same line. Reproduced on this head under
`set -euo pipefail`:

    prev_n=08
    [ -n "$prev_n" ] && [ "$prev_n" -eq "$prev_n" ] 2>/dev/null || prev_n=0
    #  -> guard PASSES: test parses 08 as decimal
    n=$((prev_n + 1))
    #  -> bash: 08: value too great for base (error token is "08")

Two holes that had to close together. `test -eq` reads decimal, arithmetic
reads a leading zero as octal, so a value can pass the guard and still abort
the arithmetic it was guarding. The abort lands after the physical
`incus import` and before the stamp, the placement correction and the start —
the exact window the degrade-never-die contract exists to protect, and the
same side box_provenance()'s schema check falls on.

    case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
    n=$((10#$prev_n + 1))

Digits-only closes sign and garbage; 10# forces base ten. Checked across
08 -> 9, 007 -> 8, 5 -> 6, and '' / not-a-number / -3 / 3x / 00 -> 1.

The existing not-a-number fixture could never have caught this: that value
fails the guard and degrades, so it exercises the path that already worked.
Added a count=08 fixture beside it, asserting both halves — the import does
not fail, AND the count advances to 9 rather than degrading to 1, because 08
is a real previous total and reading it as anything else would be its own
small lie. A zero-padded count is not exotic; it is what any external tool
that formats numbers writes.

Verified by mutation: with the old guard restored both new assertions fail.

Also drops user.box.mode.asked from the IMPCLONE fixture (grok's nit). Since
#129 the clone path clears that key, so a fixture built from the mint shape
that kept it described a box the clone path cannot produce. Nothing asserts
it today — which is precisely why it would have rotted unnoticed.
2026-07-21 11:49:32 +00:00
Daniel Marin
64127b34e6
Merge pull request #128 from dan-claude-bot/feat/pristine-snapshot
feat: snapshot 'pristine' at mint, before the rig bootstrap hook
2026-07-21 12:49:22 +01:00
dan-claude-bot
b2afea580e feat: box import records the trip, without rewriting who the box was
An imported box kept the artifact's mint stamp verbatim (#103) — correct, the
mint time, box version, image and origin belong to the originating host and
should survive the trip. But nothing recorded the import, so an imported box
was indistinguishable from one minted here at the artifact's mint time.

Not origin=import. 'origin' answers how the instance came into BEING — mint or
clone — and overwriting it would make an exported clone come back claiming to
be an import, with nothing left saying it was ever a clone and an origin.from
naming a lineage no key explains. The import is a third fact, orthogonal to
the first two, so it takes its own keys and leaves every other one alone.

Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the
same repeated-event question: imported/imported.by pinned once and never
rewritten, imported.last/.last.by refreshed on every arrival, imported.count
for the trips in between. Last-wins alone would erase the evidence of the
earlier trips, which is the same mistake origin=import makes one level up.

box info prints IMPORTED directly under MINTED, because that adjacency is what
stops the artifact's mint time being misread as this host's. It states only
the ordering and never claims another host: box has no record of which host
minted a box, and a re-import onto the same host is the documented upgrade
flow. user.box.schema does not move — adding a key is not breaking — and is
not written by the import at all, so a legacy artifact still reads as
MINTED (not recorded) rather than acquiring a shape it does not have.

Closes #131.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:27:57 +00:00
dan-claude-bot
24a05c3020 fix: make the 'pristine' path's never-fatal contract structural, not incidental
Both from claude-bot's review of #128.

storage_driver's two driver probes had no '|| true' while the pool probe one
line up did. They were safe only via a bash subtlety: command substitution
strips errexit, so a failing 'incus storage show' fell through to the fallback
instead of aborting. Add 'shopt -s inherit_errexit' to bin/box — the class of
robustness tweak #107 documents sailing through review — and under pipefail a
restricted tier's probe refusal becomes a fatal abort mid-mint, inside the
function whose contract is NEVER fatal. All three probes now read alike and
depend on nothing. Driven test: refuse both probes with inherit_errexit on,
assert the caller survives and the driver reads empty (the unreadable-pool
case, which already takes the mark anyway).

The clone's inheritance narration piped a multi-line 'incus snapshot list'
straight into 'grep -q' — #124's racing-reader class in a 'set -euo pipefail'
file. A 141 there reads as "no pristine" and narrates the WRONG inheritance
shape on a clone that does carry one. Capture first, then read. Pinned by
shape (grep|head|sed|awk|read), not by instance spelling.

bin/box has ~10 more sites of that class and no sweep covers the file; one of
them unpins an exposure's static address on a fail-open read. Filed as #134
rather than widened into this PR.

Refs #104, #107, #124, #134.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:26:54 +00:00
dan-claude-bot
a091c6e496 feat: snapshot 'pristine' at mint, before the rig bootstrap hook
cmd_new's fresh-mint branch runs launch → wait_agent → cloud-init
status --wait → the rig bootstrap hook. In the gap between the last two
the guest IS pristine Debian plus box's thin seed — the state
heavy-duty/rig#62 calls "back to pristine Debian". It existed for a few
seconds on every mint box has ever taken and nothing captured it.

Take it there, unconditionally within a fresh mint and never fatally, so
'box restore <box> pristine' is a complete undo for every creds-free
tenant role. A --from clone takes none: it has no pristine moment, so it
inherits its source's snapshots or starts with none, and box will not
label a worked-in state as pristine. On a 'dir' pool (no CoW) the mark
would be a full multi-GB copy per mint, so the mint skips it loudly.

Closes #104
2026-07-21 11:26:54 +00:00
Daniel Marin
f84e474549
Merge pull request #127 from dan-claude-bot/test/racing-reader-sweep-class
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
2026-07-21 12:25:04 +01:00
Daniel Marin
8f1a281c20
Merge pull request #129 from dan-claude-bot/feat/mint-stamp
feat: a minted box records how it was minted, and box info reads it back
2026-07-21 12:24:40 +01:00
dan-claude-bot
256ded2e92 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-21 11:00:41 +00:00
Daniel Marin
4fe91ef562
Merge pull request #120 from dan-claude-bot/fix/wipe-sigpipe-shape
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
2026-07-21 11:55:33 +01:00
dan-claude-bot
c3823e9a3f test: the mint drive mints 'claude-box', the name the template has now
The rebase onto main was textually clean and semantically wrong. #123
renamed the tenant templates (claude -> claude-box, following rig#76) while
this branch was open; the mint-stamp drive was written against the old name
and git had no reason to say so — the two changes never touch the same line.

`box new --template claude` now dies at the template lookup, so all 18 mint
assertions failed on a shimmed mint that never reached a launch line. Renamed
at the two mint invocations and the two stamp assertions that read the new
name back (user.box.template, user.box.role).

Deliberately NOT renamed:

- `user.box.user=claude` — the seed user did not move. #123 split the two
  namespaces on purpose: the template and the rig role are `claude-box`,
  the user the role converges stays `claude`. The assertion pins that split.
- the canned `$STAMPED`/`$PRERENAME` info fixtures — they stand for boxes
  minted BEFORE the rename, which is exactly the case box promises to keep
  reading (cmd_new matches both spellings of user.box.template for the login
  hint). Renaming them would delete the coverage.
2026-07-21 10:51:08 +00:00
dan-claude-bot
aed2f5bf1f fix: a clone clears mode.asked — nobody asked THIS box anything (#103)
The clone's re-stamp split provenance into two columns: event facts
re-stamped (schema/version/created/origin/origin.from), lineage facts left
alone (template/user/image/role/rig/mode). Review found one key that sits in
neither: user.box.mode.asked.

It is a mint-EVENT fact by the split's own criterion — only the mint knew
whether a container was asked for or fallen back into for want of /dev/kvm —
but the asker was the SOURCE's operator. A clone refuses --vm/--container
outright, so nobody was asked anything about this instance. Riding through
'incus copy' untouched, it made 'box info' on a clone print

    MODE       vm (asked: auto)

describing a demand never made of it, with nothing marking it ancestral.

There is no true value to re-stamp it with, so it is CLEARED — cleared and
not set-to-empty, because an empty value is still a key a reader would find.
The unset tolerates failure: a source that predates the stamp never carried
the key, and a clone must not die over a key that was already absent. It
lands with the re-stamp and before the start, the same rule and the same
reason as the rest: a clone is never observable wearing an 'asked' its
operator never gave.

The read side needs no special case. The MODE line was already gated on
'asked', so absence renders as silence — the same absence-is-silence rule the
whole provenance block uses — while TYPE still reports VM or CT off the
instance type 'incus copy' preserves.

+7 checks (529 -> 536), each proven to bite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:47:50 +00:00
dan-claude-bot
be633aa5f8 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-21 10:47:50 +00:00
dan-claude-bot
aec22277cd fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is
closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141.

It was correct today, and only by accident — the file is `set -u` with no
`pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was
one line from wrong: adding `set -o pipefail` for unrelated robustness would
have silently skipped every UFW removal on a host the operator was told is
wiped, with no error and no red X. Measured on a shim: 5/5 runs took the
wrong branch under pipefail, 3/3 the right one without.

Transplant #106's pattern verbatim from host/teardown-host.sh: capture
ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop —
whose condition was itself an early-exit reader, plus an un-captured re-read
to get the number — as a `while :` that reads one capture per iteration and
breaks on absence. The re-scan stays per-delete, since numbers shift after
each removal; it just no longer races. Removals keep the file's
`cmd && say "did X"` idiom.

Generalize the test/cli.sh pin from the one site to the class: sweep every
host/*.sh and drill/*.sh for the racing shape and name the offenders, so a
new script in either directory inherits the pin instead of being one more
site to remember. Comment lines are stripped before matching — each fix's
own commentary quotes the racing shape to explain it, and a prose-blind pin
would fail on the comment documenting why it exists. The positive pins (the
capture, the break-on-absence) now run per file over both, so the sweep
cannot be satisfied by deleting a block instead of fixing it.

drill/doctor.sh was checked and needs nothing: it already reads into
`ufw_out` and is safe by construction, not by absent pipefail.

Refs #107
2026-07-21 10:39:03 +00:00
Daniel Marin
239428bf88
Merge pull request #119 from dan-claude-bot/fix/teardown-host-tty-gate
fix: teardown-host refuses a terminal-less run instead of aborting mute
2026-07-21 00:38:13 +01:00
Daniel Marin
9b06e29b47
Merge pull request #144 from dan-claude-bot/fix/monotonic-uniqueness-first
fix(changelog-monotonic): check uniqueness before anything base-side
2026-07-21 00:37:43 +01:00
dan-claude-bot
1e3fb473c3 test: terminate the ci.yml step block at the job boundary too
The monotonic step is the LAST step of the `check` job, so stopping only at
the next `- name:` ran the block into `rehearsal` and swallowed its job-level
`if:`. Unanchored `grep -q 'if:'` then fired on it — reintroducing the bug the
scoping was meant to fix, moved from "any step in the file" to "this step plus
the head of the next job".

Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.

Mutation-tested four ways: baseline green; an unrelated job gated stays green
(was the failure); the monotonic step gated fails; the step renamed trips the
companion found-the-block guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:54:31 +00:00
dan-claude-bot
4b658b6fb0 test: silence SC2016 on the new negative fixture
The inner `bash -c` takes its paths as positionals, so the single quotes are
deliberate — same class as the release-notes fixtures above it, and reusing
their exact disable wording.

Mine to catch: I linted .github/scripts, bin/box and install.sh by hand, while
CI lints `bin/* **/*.sh` under globstar+dotglob, which includes test/. The
narrower local invocation is what let this through; reproduced CI's exact
command before pushing this time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:43:35 +00:00
dan-claude-bot
94d830cdb3 fix(changelog-monotonic): report containment vacuous when the base IS HEAD
Dropping the pull_request gate made merge_base == HEAD a routine path rather
than a degradation, and the success line did not follow. On every push to main
the step printed "all N release heading(s) at the merge base are still present"
— a containment claim on the one event where deletion is undetectable, since
the comparison is the file against itself.

That is the dishonesty this PR fixed in the skip messages, surviving in the
success message. The line now has two forms: containment vacuous, naming
uniqueness as the half that ran, or the existing containment wording when a
real base exists. Both pinned.

Also scopes the ci.yml negative pin to the monotonic step's own block. As a
file-wide grep it forbade any FUTURE step from being pull_request-gated and
would have failed citing #143 when one legitimately was.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:35:01 +00:00
Daniel Marin
b7954e3f9b
Merge pull request #142 from dan-claude-bot/fix/labels-sweep-on-labeled
fix(labels): sweep on `labeled` so the handoff is immediate
2026-07-20 21:32:20 +01:00
dan-claude-bot
94f6ed0047 fix(changelog-monotonic): check uniqueness before anything base-side
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>
2026-07-20 20:16:44 +00:00
dan-claude-bot
243ccc3aa5 fix(changelog): cite the sibling PRs, not the tracking issues
The "Landed in all three repos together" line pointed at the sibling
tracking issues rather than the sibling PRs. The entry already opens with
its own issue ref, so a reader following "landed together" was sent to more
issues and never reached the actual sibling changes.

All three PRs carried it identically because the three entries came from one
generator that took sibling references from its issue-number map, and
expanded them into the sentence without re-wrapping — which is also why the
line ran to 108 columns in a file that wraps at 83.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:07:57 +00:00
dan-claude-bot
3155258cf5 fix(labels): sweep on labeled so the handoff is immediate
A review landing was never a trigger for the labels workflow, so the exact
moment `state:needs-human` became true — the third bot approving — fired
nothing, and the label waited on the `*/15` cron. That cron does not run at
its declared rate: measured across box, rig and cast over a two-hour window
on 2026-07-20, one scheduled run each against the eight `*/15` implies.

The obvious fix does not work. There is no `pull_request_review_target`, and
on fork PRs — all of them here — `pull_request_review` runs with a read-only
token and cannot label anything.

So the handoff wakes the sweep itself:

- `pull_request_target` also fires on `labeled`/`unlabeled`
- the author sets `state:needs-human` at handoff, as the third act after the
  round summary and the review request

The author's own label write fires the sweep that validates it — an
optimistic write, not a transfer of ownership. The reconciler confirms or
corrects it seconds later, and the cron falls back to a last resort. It
cannot loop: the reconciler writes with GITHUB_TOKEN, which does not create
workflow runs; agent writes use a PAT, which does.

`labels-reconcile.sh` is unchanged — it already recomputes every open PR
from scratch on every run, which is what makes the optimistic write safe.
The `scope` job is skipped on label events, where no path can have changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:44:07 +00:00
Daniel Marin
81c44c8b02
Merge pull request #139 from dan-claude-bot/docs/contributing-blocker-axis
docs(contributing): document the blocker axis and merge-next ownership
2026-07-20 20:07:20 +01:00
dan-claude-bot
b953d8b3c1 docs(contributing): document the blocker axis and merge-next ownership
The who-sets-what table is the day-to-day answer to "can I move this by
hand", and it never mentioned blocker:* -- a whole machine-owned family
added when state:needs-rebase was retired. merge-next was missing too, and
that is the one label whose ownership actually needs saying, because it is
the only one in the machine's vocabulary the machine deliberately does not
set.

Step 6 also read as though requesting the maintainer is sufficient to flip
state:needs-human. It is not: needs-human requires zero blockers, so the
request does nothing on a conflicted or red PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:38:18 +00:00
Daniel Marin
e6854ef62c
Merge pull request #138 from dan-claude-bot/fix/labels-two-axis
refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
2026-07-20 19:30:04 +01:00
dan-claude-bot
f956a85a1c fix(labels): a missing state label must skip the EDIT, not the whole PR
Round 2 review (claude-bot, codex-bot, grok-bot -- all three, independently).

The label pre-flight added in round 1 returned out of reconcile_pr entirely.
Everything below that point is independent of the state:* taxonomy: clearing a
stale merge-next, and the stale sweep. Stranding them meant a merge-next claim
reading "merge this one next" survived on a PR the board had moved to the
agent -- the same false invitation as #136, one scope smaller -- and the
staleness detector went silent. On a cold-start repo, where no state:* label
exists yet, that was EVERY PR.

It was also a regression against main rather than a missed improvement: the old
code failed the edit, logged, and fell through to both blocks. The round-1 fix
turned a per-edit failure into a per-PR abort.

Now skip_edit=true, and control reaches the rest of the function.

Also taken, both from claude-bot and grok-bot: the dead "$desired" term in the
filter loop, and `[ -n "$missing" ] && log` becoming a proper elif rather than
an &&-as-statement under set -e.

Four fixtures now drive reconcile_pr itself, stubbing run/gh -- the first in
this suite to reach past the pure functions, which is exactly why a per-PR
return was invisible to the fixtures that existed. Fixtures 68 -> 72.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:09:07 +00:00
dan-claude-bot
27cfc5b8c1 fix(labels): never name a label the repo lacks; a stale round owes an ask too
Round 1 review (claude-bot, codex-bot on #138 and the sibling PRs).

gh issue edit rejects the WHOLE call on one unknown label name, and this
change made state and blockers share a single edit for anti-flicker. On a repo
whose taxonomy predates the blocker:* labels -- every repo, at merge -- the
first sweep would therefore converge NOTHING on exactly the PRs the change
exists to fix, surfacing only as a warning in a cron log. Adds are now
filtered against the repo's real label set, read once per sweep. Removals need
no filter: they are built from has_label, so they provably exist. An
unreadable label set does not filter, because a failed read must not silently
strip the board.

blocker:unrequested also missed the stale round. MISSING and STALE are both
verdicts this head does not have -- nobody reviewed it, or everybody reviewed
something else -- and the agent owes an ask either way. The stale one is the
worse of the two: it has approvals on the page that no longer describe the
tree, which is the case LABELS.md already called the most dangerous.

Fixtures 66 -> 68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:50:00 +00:00
dan-claude-bot
074aea31d8 docs(changelog): record the UNREADABLE rollup fix
Its own Fixed entry rather than a line in the refactor above it: the two-axis
split is a design change, this is a correctness bug in a different function,
and a reader bisecting a false "merge me" wants the second one named.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:42:56 +00:00
dan-claude-bot
9bf669dabc fix(labels): a failed rollup read must not read as "nothing is failing"
gh returning nothing left the rollup KEY absent, which collapsed into the same
NONE as a PR that genuinely has no checks -- so an API hiccup presented as
mergeable-by-a-human. That is the unknown-certified-as-green shape this machine
exists to stop, surviving in the one place the #136 fix did not look.

checks_state now reports UNREADABLE for an absent key, and the sweep leaves
that PR exactly as it is rather than recomputing on facts it did not read.
Blocking on it instead would flap the whole board on one bad call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:36:46 +00:00
dan-claude-bot
2d49a5ad42 refactor(labels): split PR labels into state (whose ball) and blocker (what is in the way)
Retires state:needs-rebase. Mergeability, check status and the review round
are independent facts; forcing them through one totally-ordered label meant
one always won and the rest vanished, which is where every precedence bug in
this machine has lived. needs-rebase was the clearest casualty -- it fired on
both a conflict and a failing check, which need opposite work.

Blockers are a set, so there is no ordering between them to get wrong. The
ordered axis is now purely about reviews. bots-reviewing also tightens to mean
a request is actually live; a PR nobody was asked to review is the agent's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:27:06 +00:00
Daniel Marin
e408b7a0ed
Merge pull request #137 from dan-claude-bot/fix/labels-mergeability-aware
fix(labels): `state:needs-human` means a human could merge it right now
2026-07-20 18:02:52 +01:00
dan-claude-bot
b9527d3cc1 fix(labels): date a check run by when it BEGAN, not by the newest stamp it carries
Third and final correction to the supersede rule, and the second one
that silently restored #136.

Round 3 replaced "date by completion" with "date by the newest stamp
the run carries". That is still not an ordering on runs: `max` over
[startedAt, createdAt, completedAt] resolves to completedAt for a
FINISHED run and startedAt for a LIVE one — different quantities, so
the comparison was never between like and like.

The consequence is the ordinary concurrency-group path, not an edge
case. A run cancelled by a concurrency group does not stop instantly;
it drains AFTER its replacement has already started, so
predecessor.completedAt > successor.startedAt is the normal shape. On
this PR's own aa5a6ba the window was 13 seconds. Inside it the dead
predecessor out-dated the live run replacing it, and a green
predecessor reported SUCCESS while a re-run was still in flight:

  SUCCESS completing 15:30:13, replacement started 15:30:00
    max   -> SUCCESS   (#136: needs-human over a disabled merge button)
    first -> PENDING

Fixed with `max` -> `first`. The list is already in preference order,
so `first` IS "date it by when it began" — and a replacement always
begins after the run it replaces, whatever order they finish in. The
sentinel filtering and the undateable-sorts-last tiebreak are
unchanged; this narrows the rule to a quantity that actually orders.

Prescribed independently by claude-bot-andresmgsl and
codex-bot-andresmgsl.

Two fixtures pin the drain window in both colours. 49 -> 51.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:41:49 +00:00
dan-claude-bot
cbc8f4f7a8 test(labels): pin the reverse direction — a run that finished after an in-flight entry settles the context
The supersede fixtures pinned "an in-flight re-run is the newer word".
Nothing pinned the converse: a run that FINISHED after an earlier
in-flight entry is also the newer word, so the context is settled and
reads SUCCESS rather than staying PENDING.

That is the assertion that catches an over-correction — widening
"an undateable entry sorts last" into "an in-flight entry always wins".
Verified it fails under exactly that mutation and that nothing else in
the suite does.

Also corrects the comment above the undateable fixture: that case is
guarded by the sort tiebreak, not by the dating expression, so the two
changes are now separately pinned. Reverting only the dating expression
fails 2 fixtures (the two zero-sentinel ones), not 3.

48 -> 49. Reconciler unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:29:59 +00:00
dan-claude-bot
e40d2df5f1 fix(labels): date a check run by the newest stamp it carries, not by its completion
Round 3. @claude-bot-andresmgsl and @codex-bot-andresmgsl independently
caught that the supersede rule added in round 2 inverted itself on the
one shape its fixtures could not express: a re-run still in flight.

A running check does not omit `completedAt`. `gh` marshals the Go zero
time as the STRING "0001-01-01T00:00:00Z", and jq's `//` only falls
through null/false, so the sentinel won the sort key and sorted before
every real timestamp. The live re-run became the OLDEST entry in its
context, `last` discarded it, and the run it superseded was judged
instead — exactly backwards, and wrong in both directions:

  green + re-run in flight      -> SUCCESS   (should be PENDING)
  CANCELLED + re-run in flight  -> FAILURE   (should be PENDING)

The first is #136 restored by the very rule meant to close it: all bots
approve, mergeable, state:needs-human — over a tree whose merge button
branch protection has disabled. It was also a regression from round 1,
which caught it via `any(. == "")`. The second is the re-run flap the
supersede rule exists to prevent, narrowed rather than removed.

Fixed by taking the newest timestamp a run actually carries and
discarding BOTH spellings of absent — null and the zero sentinel —
rather than by reordering the fallbacks. An entry with no usable
timestamp now sorts LAST rather than first, so an undateable in-flight
run is never dropped in favour of a stale success. Every ambiguity
resolves toward "not settled".

The fixtures could not have caught this: the `run_()` helper sets only
`completedAt`, so every supersede fixture was a race between two
FINISHED runs. The helper now expresses an in-flight entry, and the
four new fixtures assert PENDING over both a green and a cancelled
predecessor. 44 -> 48; reverting just the dating expression fails 3 of
the 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:18:22 +00:00
dan-claude-bot
724f103908 fix(labels): an unrecognised check outcome blocks, and a staled round outranks an unfinished one
Round 2. Two blockers, both real, both closing the same hole this PR
exists to close — a `state:needs-human` that invites a human to merge a
tree that will not merge.

The check-rollup classifier enumerated the outcomes that block and let
the rest fall into `else "SUCCESS"`, so ERROR, CANCELLED and STALE all
read as green. Inverted: it now lists the outcomes that DON'T block —
SUCCESS, NEUTRAL, SKIPPED, plus the pending set — and treats everything
else as blocking. The direction is the point. The rollup mixes two
closed enums (CheckRun.conclusion, StatusContext.state) and an outcome
the list forgets is one we cannot certify as mergeable; the costs are
not symmetric, since a false FAILURE parks the PR on the agent who
looks, while a false SUCCESS is #136 exactly.

Once CANCELLED blocks, superseded runs must be dropped first: a re-run
does not evict the run it replaced, and this PR's own tip carries a
CANCELLED `scope` beside the SUCCESS `scope` that superseded it. Each
context now collapses to its newest entry before anything is judged,
keyed on workflow + job name because a bare job name is only unique
within its workflow. That preserves the re-run case the panel split
over while still blocking a cancelled run that is the newest word.

The classifier also moved out of main() into checks_state(). That is
why no fixture caught this: it was inline in the fetch loop, so the
fixtures could only inject CHECKS= as an already-decided string.

Second, decide_state() returned from inside the bot loop on the first
MISSING, so a STALE belonging to a later bot in BOTS was never read —
a round that was both unfinished and staled came out needs-human over
a head nobody had reviewed. The whole round is now collected before
precedence is applied to it as a unit, STALE before MISSING.

test/labels-reconcile.sh: 29 -> 44 fixtures, pinning the check-outcome
enum, the supersede rule (both orders, plus same name in another
workflow), and the mixed round at both ends of BOTS. All verified
non-vacuous against the round-1 code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:04:16 +00:00
dan-claude-bot
aa5a6baed6 fix(labels): state:needs-human means a human could merge it right now
decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
Combined with the `if requested "$HUMAN"` short-circuit at the top of its
precedence, the label was sticky: once the maintainer was requested, the PR
read state:needs-human through conflicts, through red CI, through a
force-push that staled every approval. Nothing demoted it.

Observed twice in one afternoon, in two different shapes. Three PRs sat at
state:needs-human while CONFLICTING for hours -- the board inviting a merge
GitHub had already disabled. And #119, after a rebase, read MERGEABLE, four
green checks, state:needs-human, with ZERO reviews bound to its head: every
visible signal saying "merge me" over a tree no reviewer had seen. That second
shape is the dangerous one, because unlike a conflict nothing on the page
contradicts it.

The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.

  CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
  approvals staled by a push    -> state:addressing   (nobody reviewed this tree)

An UNFINISHED round still yields to an explicit human request -- a maintainer
pulling a PR to themselves early is deliberate, and MISSING (nobody has
reviewed yet) is a different fact from STALE (everyone reviewed something
else). That distinction is why the two are handled in different arms rather
than collapsed.

UNKNOWN mergeability is deliberately NOT treated as unmergeable: GitHub
reports it for about a minute after every merge while it recomputes, and
flapping every open PR through needs-rebase on each merge would be worse than
the bug being fixed. A failed read of either fact degrades to the same "do not
know" value, for the same reason -- an API hiccup must not relabel the board.

Also adds merge-next, because a correct needs-human still does not say WHICH
PR to merge first, and order matters when they conflict through CHANGELOG.md.
Queue order is intent, so the reconciler never sets it; it only CLEARS it the
moment the PR stops being mergeable-by-a-human -- precisely the staleness that
made needs-human untrustworthy.

Both live shapes are pinned in test/labels-reconcile.sh (19 -> 29 fixtures),
including that UNKNOWN does not trigger needs-rebase and that a draft outranks
a conflict. Proven non-vacuous: dropping the mergeability arm fails 4
assertions, dropping the STALE precedence fails 2, restoring returns 29/0.

Closes #136

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:18:49 +00:00
dan-claude-bot
ecce72f025 test: the teardown check must not inherit BOX_YES
The new check drives host/teardown-host.sh for real — the first check in
the suite to execute a destructive script rather than grep it or run it
against shims. teardown-host honours an inherited BOX_YES, and test/cli.sh
never sanitised it, so under `BOX_YES=1 bash test/cli.sh` the invocation
took the consent arm instead of the gate: a real teardown of the host the
suite was running on, reported red only afterwards.

BOX_YES is the documented automation switch for this family, so an
operator's CI wrapper exporting it is the expected case, not a contrived
one. The file header promises the suite is runnable anywhere; before this
the promise held under any environment.

Fixed at the call site with `env -u BOX_YES`, plus a file-level `unset` so
the promise cannot be broken again by the next check that drives something
destructive.

Refs #113
2026-07-20 14:45:23 +00:00
dan-claude-bot
b3fa94a7d4 fix: teardown-host refuses a terminal-less run instead of aborting mute
host/teardown-host.sh had no `[ -t 0 ]` check before its confirmation
prompt. Without --yes/BOX_YES and without a terminal — CI, a pipe, a
nohup — it fell into `read`, took the instant EOF and exited 1 saying
only "aborted": a refusal naming neither the cause nor the override, in
the most destructive script in the tree.

It now refuses with the override named, exit 2 — "you invoked this
wrong", matching host/revoke-user.sh --purge and install.sh's confirm(),
versus 1 for "you were asked and you said no".

The gate sits below the --yes/BOX_YES arm, so consent given
non-interactively still runs headless, and above the first incus call,
so the refusal needs no daemon — which is what lets test/cli.sh drive it
for real instead of grepping for it.

Refs #113
2026-07-20 14:45:23 +00:00
Daniel Marin
a9e52325f4
Merge pull request #121 from dan-claude-bot/fix/flat-upgrade-setup-host
fix: run setup-host over a migrated flat tree, and name what the migration left
2026-07-20 15:43:11 +01:00
dan-claude-bot
b1968dabb7 fix: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.

The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.

Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.

Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.

test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.

Refs #115, #117
2026-07-20 14:18:21 +00:00
Daniel Marin
add1c2a142
Merge pull request #126 from dan-claude-bot/fix/changelog-heading-monotonicity
fix: refuse a PR that deletes a shipped changelog heading
2026-07-20 15:15:56 +01:00
dan-claude-bot
cb72c71c33 fix: the guard also refuses a DUPLICATED release heading, not just a deleted one
Review found the first cut incomplete, and the gap is the shape the #118
incident actually had. Containment catches a heading that VANISHED. It cannot
catch one that was DUPLICATED: the duplicate is head-side surplus, and
`comm -23` (base minus head) is blind to extras on the head side -- with or
without `sort -u`. Multiset comparison does not close it either, for the same
reason: base {0.8.0} minus head {0.8.0, 0.8.0} is still empty. The assert that
does close it is uniqueness of version headings ON HEAD, kept ALONGSIDE
containment rather than replacing it, since containment remains the right
check for deletions.

That matters because the duplicate is not hypothetical. It is what a bad
rebase of #116 produced an hour before this commit: two
`## 0.8.0 - 2026-07-19` headings with the incoming entry stranded between
them. Every other guard was green on that tree -- no conflict markers,
changelog-armed.sh happy because the top section was still right for the
VERSION, tests and shellcheck clean -- while release-notes.sh re-armed its
grab on the second heading and folded post-cut prose into the shipped 0.8.0
body, and the stranded entry would have been dropped from the next release's
notes as well.

Uniqueness fires on nothing legitimate: the ceremony stamps a NEW version, and
'Unreleased' fails the version shape before it reaches here.

Both trees are now pinned in test/release.sh side by side -- the deletion
near-miss and the real duplicate -- each with changelog-armed.sh asserted
green on it, which is the whole reason this script exists separately. The
duplicate case also pins that the containment arm stays silent on it, so a
future simplification cannot collapse the two asserts into one.

Proven non-vacuous: stubbing the uniqueness check to empty turns the suite red
on exactly the three new assertions (117/3); restoring it returns 120/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:08:56 +00:00
dan-claude-bot
ce57070c14 fix: refuse a PR that deletes a shipped changelog heading
changelog-armed.sh asks only whether the TOP section agrees with VERSION.
A PR that replaces '## X.Y.Z — DATE' with its own '## Unreleased' block —
git merges the edit cleanly, and a shipped section is silently absorbed into
Unreleased. The damage only surfaces at the next release, when
release-notes.sh cannot find the section it extracts by heading.

Add changelog-monotonic.sh: the set of '^## X.Y.Z' headings on a branch must
be a superset of the set at the merge base. Release headings are append-only,
so the rule has no legitimate violation — and the ceremony's stamp passes by
construction, adding X.Y.Z and removing none.

Its own script, not a clause in changelog-armed.sh: the input is a git
history rather than two files, no base ref is a SKIP rather than a failure,
and changelog-armed.sh is driven by test/release.sh against constructed
non-git trees that cannot express the failure at all.

CI checks out with fetch-depth: 0 and sets CHANGELOG_MONOTONIC_STRICT=1, so
an unreachable base ref goes red there instead of degrading to the skip a
local run is allowed.

Closes #122

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 13:54:44 +00:00
Daniel Marin
cc0a1a9a79
Merge pull request #118 from dan-claude-bot/fix/shellcheck-dotglob
fix: lint the release path — globstar does not descend into dot-directories
2026-07-20 14:53:46 +01:00
dan-claude-bot
c68c519558 fix: restore the 0.8.0 heading the changelog entry replaced
The Unreleased entry was written over `## 0.8.0 — 2026-07-19` instead of
above it, folding the shipped 0.8.0 section into Unreleased: the release
lost its extractable anchor, and the next cut would have republished its
notes as new work.

changelog-armed.sh passed on that tree — correctly, since it asks only
whether the top section agrees with VERSION, and Unreleased was still on
top. The gap it leaves is filed as #122.

Also quote the class check's failure list instead of relying on unquoted
word-splitting to print one path per line.

Refs #116
2026-07-20 13:26:35 +00:00
dan-claude-bot
0b7ad21eca 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-20 13:11:41 +00:00
Daniel Marin
4c34facefe
Merge pull request #125 from dan-claude-bot/feat/template-family-suffix
refactor(templates): the tenant seeds carry rig's -box family suffix
2026-07-20 14:09:30 +01:00
dan-claude-bot
9c78911563 fix(templates): the printed workload join follows rig's machine-role rename
The tenant half of rig#76 is what #123 tracked, but the machine half reaches
box in one place: the tailnet workload join box prints as the next step for a
staging-box guest is `rig bootstrap workload`, and that role is now
`workload-server`.

box never runs it -- it holds a pre-auth key, and that it stays operator-run
is the absence that keeps box creds-free end to end -- but box does PRINT it,
in three places that all had to move together: cmd_new's hint, the staging-box
seed's own comment, and the README. A next step an operator copy-pastes is as
wrong as a role box executes, and it fails later and further from the cause.

The suite's assertion moved with it, so it still pins what it was written to
pin: that the join is printed and never exec'd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:07:00 +00:00
dan-claude-bot
86dbb0449e 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
github-actions[bot]
15b67d938e chore: bump main to 0.8.1-dev — a dev install must not impersonate 0.8.0 2026-07-19 23:04:15 +00:00
45 changed files with 3178 additions and 2157 deletions

49
.ceremony/AGENTS.md Normal file
View file

@ -0,0 +1,49 @@
# AGENTS.md — start here
You are an agent working in a repo governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
the router: find your role below, read its file, then act. The role files
sit beside this one — in ceremony itself at the repo root, in a governed
repo under `.ceremony/` (a machine-managed mirror; never edit those files
in place — they are changed in heavy-duty/ceremony, through its own flow).
## Your role
You were told your role when you were pointed at this repo ("you are a
reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line |
|---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
are the shared state machine, and misusing one lies to every other agent on
the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process discussions or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of
```
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
```
Two rules bind every role:
- **Only triage mints issues.** Found work? Open or extend a discussion.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button.
## Repo specifics
What is true only of *this* repo — the review panel roster, the `scope:*`
label set, what a drill means, code conventions — lives in the repo's own
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.

76
.ceremony/BUILDER.md Normal file
View file

@ -0,0 +1,76 @@
# BUILDER.md — the builder role
You turn one issue into one PR. The issue is your contract: triage wrote it
so you can succeed without asking anyone anything — if you can't, that is a
triage bug, and the move is to say so on the issue, not to guess.
## Picking
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
never an `epic` (epics organize; their children are the work).
- Respect dependency order: inside an epic, take the earliest unblocked
unclaimed child. Between epics and strays, prefer the issue that unblocks
the most other work.
- **One issue at a time.** Finish or release your claim before taking
another.
## Claiming
- Assign yourself, swap `ready``claimed`, and comment that you are
starting. The claim is a promise of a draft PR soon — a claim with no PR
and no activity is what the staleness sweep reclaims.
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
push the branch if it holds anything useful, unassign, and restore
`ready`.
## Building
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
body. Drafts are invisible to the reviewer panel on purpose — the draft
phase is yours.
- **The issue's acceptance criteria are your definition of done.** Reproduce
them as a checklist in the PR body and check them honestly as you go. If
one turns out to be wrong or unreachable, say so on the issue and get it
amended by triage — do not silently ship less than the issue says.
- Every behavior change adds one line to `CHANGELOG.md` under
`## Unreleased` — insert **above** the heading below it, never over it
(the monotonic guard's whole reason to exist).
- Follow the repo's conventions file and match the code you touch. Tests are
not optional: the issue's test plan is the floor, not the ceiling.
- **Scope discipline: the PR does the issue — whole, and nothing else.**
Adjacent problems you discover go to a **discussion** (or a comment on the
relevant issue), where triage will do its job. You do not mint issues —
nobody but triage does — and you do not fix drive-by findings in the same
PR; a reviewer cannot converge on a moving, widening target.
## The review round
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
panel roster and any repo-specific flow notes live in that repo's own
CONTRIBUTING; everything below is the shared flow.)
1. Mark ready-for-review; request **the whole panel** (the roster is in the
repo's CONTRIBUTING).
2. **Wait for every verdict, then answer the round whole** — one reply
covering every point, then push the fixes, then re-request exactly the
reviewers who did not approve. Prefer verification over argument: when a
reviewer doubts behavior, add the test that settles it.
3. Never dismiss a review, never merge, never mark your own work as passed.
A blocking point you disagree with is answered with evidence or escalated
in the PR — a maintainer can be asked for a ruling; silence and
force-forward are not options.
## Handoff
When the round passes — every panel verdict approves the **current head**,
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
this is a release PR) — hand it to the human, in order:
1. post the round summary (what changed per round, what was verified);
2. request the human's review;
3. set `state:needs-human` yourself.
The label write is optimistic — the reconciler validates it, and takes it
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
human's. Address what comes back (`state:addressing`) and re-hand-off the
same way.

98
.ceremony/LABELS.md Normal file
View file

@ -0,0 +1,98 @@
# Labels
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by
the labels workflow's bootstrap dispatch (issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, doctrine-enforced today, machinery to follow —
issue #18). One rule joins everything: **states are machine-owned, intent
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
and the reconciler recomputes it from GitHub's own facts.
## PR state — who is the ball with? (exactly one per open PR)
| Label | Color | Waiting on |
|---|---|---|
| `state:building` | `#FBCA04` | the builder — PR is a draft |
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up |
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
*poke the reviewers*, in the second *the builder dropped the ball*. And
`state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one
hand-set state); the `labeled` event fires the sweep that validates the
write within seconds.
## PR blockers — what is in the way? (facts, as many as apply)
| Label | Color | Means |
|---|---|---|
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
States answer *whose ball*; blockers answer *what's in the way*. They are
separate axes because the single-label version kept lying — independent facts
projected onto one totally-ordered label meant one always won and the losers
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
strips it on sight).
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
| Label | Color | Means | Set by |
|---|---|---|---|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
The invariant a board scan relies on: every open issue is either
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
`blocked`. A `claimed` issue with no open PR and no activity is what the
staleness sweep will reclaim (issue #18); until that machinery exists,
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
actions/labeler, issues get theirs from triage. This repo's set:
| Label | Covers |
|---|---|
| `scope:release-flow` | the reusable release workflow, decide, the doors |
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
## Issue types
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
on a PR would say the same thing twice and drift.
## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on PR events
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
bootstrap — a `question` is a discussion, not an issue.

14
.ceremony/README.md Normal file
View file

@ -0,0 +1,14 @@
# .ceremony/ — the vendored doctrine mirror
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
these files here: they are byte-identical copies of
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
goes red. They are changed in heavy-duty/ceremony, through its own flow,
and arrive here when the pin moves.
The pin lives in `.github/workflows/release.yml` — the single
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
pin governs machinery and doctrine alike: bump it and re-sync this mirror
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
what is stale).

70
.ceremony/REVIEWER.md Normal file
View file

@ -0,0 +1,70 @@
# REVIEWER.md — the reviewer role
You are one voice on a panel. The panel's job is to converge — on an
approval the human can trust, or on a precise statement of what is wrong.
The machine reads only your **verdict**; humans read your reasons.
## The verdict doctrine
- **Every review ends in a verdict**: approve, or request changes. A
comment-only review is a non-verdict — it does not say whether the round
passed, the state machine treats it as not-approved, and the PR simply
stalls. If you have an opinion, you have a verdict; commenting without one
only wedges the flow.
- **The verdict carries blockingness only; the body carries the feedback.**
Non-blocking nits ride an **approval**, and the builder addresses them at
their discretion. Anything blocking — including a question whose answer
gates your approval — is **request changes**, saying exactly what
unblocks it.
- An approval you would not defend to the human is a defect. You are not
being asked to be agreeable; you are being asked to be right.
## What you review against
In order of authority:
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your
spec. Check every criterion; a PR that ships less than the issue says is
a request-changes even if the code is beautiful.
2. **The repo's load-bearing constraints** — the rules bought with
incidents (in ceremony itself: issue #1's constraint list; in a governed
repo: its own CONTRIBUTING plus ceremony's README). A change that
"simplifies away" a constraint gets request-changes with a link to the
incident that made the rule.
3. **The code itself** — correctness first, then tests (does the test plan's
floor exist? do the failure cases actually fail?), then conventions.
Changelog line present for behavior changes; comments carry why, not
what.
**Verify over opine.** Run what can be run; construct the failing input; a
test settles what a comment thread can't. A review that says "I ran X and
saw Y" outranks one that says "this looks like it might".
## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a discussion where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the
discussion, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge.
- **Approve a moving target.** Your approval is of a specific head. If the
builder pushes after your approval, GitHub stales it — that is correct,
and the builder owes a re-request, not an assumption.
## The round rhythm
- Review the **whole PR at the current head** each round, not just the diff
since your last comments — the fix for someone else's point can break
yours.
- The builder answers rounds whole and re-requests you; until re-requested,
the ball is not yours (`state:addressing` is the builder working — pile-on
reviews mid-address just churn the target).
- Convergence = every panel verdict approves the current head, no
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
the panel's job is done.
- If a round exposes a disagreement **within the panel**, argue it in the PR
with evidence until one side concedes or the builder escalates to the
maintainer for a ruling. Two reviewers pulling a builder in opposite
directions without resolution is a panel failure, not a builder failure.

95
.ceremony/TRIAGE.md Normal file
View file

@ -0,0 +1,95 @@
# TRIAGE.md — the triage role
You are the only door issues come through. Humans and agents open
**discussions**; you decide what becomes work. The quality of every
downstream stage — a builder succeeding without asking, a reviewer having a
spec to review against — is set here, by you, and nowhere else.
## Why this door exists
Discussions are allowed to be ambiguous; issues are not. An issue is a work
order a builder must be able to execute **without asking anyone anything**.
Keeping one accountable role between the two is what keeps the bar from
eroding — the moment anyone can mint an issue, the backlog fills with
"improve X" entries nobody can build, and builders start guessing. Guessing
is the failure this whole flow exists to prevent.
## Your inputs
- **Every open discussion** in the repo you serve.
- **Stray issues** — anything filed directly, by anyone. Label it
`needs-triage`, then either bring it up to contract (below) or convert its
substance back into a discussion and close it, saying why. Do not shame the
filer; do route the work correctly.
## For each discussion, converge on exactly one outcome
1. **Answer.** The question has an answer, the bug is not one, the idea is
already shipped or already tracked. Reply with the answer (link the code,
the doc, the existing issue), mark answered.
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
repo, its history, or its docs. Ask the 23 pointed questions whose
answers would let you write the issue — then stop and wait. Do not mint an
issue that carries the ambiguity forward; that just moves your job onto
the builder.
3. **Escalate.** The blocker is a *decision* only a human owns — scope,
money, product direction, breaking a public contract. Say precisely what
the decision is, list the options with your recommendation, and name the
decider. The discussion is where humans decide; wait there.
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
where it belongs if anywhere, close. A refusal with reasons is a good
outcome; a zombie discussion is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar.
## The issue contract
Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the discussion it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an
open question, the issue is not ready to exist — go back to outcome 2 or 3.
- **Tasks**: the steps, checkboxed, in order.
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
the builder's definition of done and the reviewer's review spec, verbatim.
- **Test plan**: what proves it, including the cases that must fail.
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
epic organizes it.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
The bar, stated once: **a competent builder who has read only this issue and
the repo can succeed.** The release-ceremony epic and its children
(heavy-duty/ceremony#1#16) are the house exemplars — that is the density
expected.
## Multi-issue work
When an acceptance produces more than one issue, mint an **epic** (`epic`
label): the approach, the decisions, the constraint list, and a
dependency-ordered task list of child issues. Children reference the epic;
the epic's checklist is the progress view. Builders never pick the epic
itself. Keep the checklist current — a stale epic misleads every scan.
## Backlog hygiene (yours until #18 automates it)
- **Dedup before minting** — search issues *and* closed issues; extend or
reopen before duplicating.
- **Flip `blocked` → `ready`** when the named dependency lands.
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity →
comment, unassign, restore `ready`.
- **Close obsolete issues** with the reason and a link to what obsoleted
them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie.
## What you never do
- Write code, review code, or build the thing yourself.
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
- Make the human's decisions (outcome 3 exists for those), or soften a
refusal into a vague issue to avoid saying no.
- Mint an issue to "discuss" something — that is a discussion.

2
.github/labeler.yml vendored
View file

@ -1,4 +1,4 @@
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope
# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's scope
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
"scope:cli":

7
.github/labels.conf vendored Normal file
View file

@ -0,0 +1,7 @@
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
scope:cli|C5DEF5|bin/box — the command surface
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
scope:templates|C5DEF5|templates/ — the box seeds
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md

View file

@ -1,145 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-armed.sh [<changelog>] [<version-file>] — assert that
# CHANGELOG.md is ARMED: that there is a heading for the next PR's entry to
# land under, and that it is the right one for the state this tree is in.
#
# The failure it exists to catch (#108, heavy-duty/rig#66) leaves no trace:
# the ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and
# nothing puts the heading back. A PR authored BEFORE the release wrote its
# entry under '## Unreleased'; that heading is gone by the time it merges, so
# git lands the entry under whatever heading now occupies that position — the
# just-shipped section — CLEANLY, with no conflict. The one signal an author
# would trust ("git told me to look") is absent exactly when the result is
# wrong, and the drift is only ever discovered by reading the file.
#
# The rule, keyed on VERSION, because the two states are genuinely different:
#
# VERSION ends in -dev -> the top section MUST be '## Unreleased'
# VERSION is bare -> the top section may be '## Unreleased' (armed,
# the ceremony's own re-arm) or the stamped
# section for exactly that VERSION — AND the
# section for that VERSION must exist and carry
# prose, because it is the one about to ship
#
# Keying on VERSION is the whole design, and the reason this is not simply
# "require '## Unreleased'". That unconditional form is what rig#44 and
# heavy-duty/cast#108 had to REVERT: it is false by construction on the
# ceremony PR's own tree, which makes the release unshippable through a green
# CI. Anyone tempted to simplify this back should read those two first.
#
# The consequence worth stating plainly: a ceremony PR that stamps and forgets
# to re-arm still passes here — its VERSION is bare, and a bare tree is
# allowed to be stamped. It goes red the moment the '-dev' bump lands on main,
# which release.yml does automatically in the same job as the publish. So the
# guard does not block the release; it refuses to let main SIT disarmed, which
# is the window a late PR can fall into.
#
# A file of its own (not inlined in ci.yml) so test/release.sh can drive it
# against constructed trees for both states — the same discipline as
# release-notes.sh.
changelog="${1:-CHANGELOG.md}"
version_file="${2:-VERSION}"
# release-notes.sh lives beside this script; the bare-VERSION branch runs it
# rather than re-implementing the extraction, so the guard and the publisher
# cannot disagree about what a section is or when one counts as empty.
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
[ -f "$changelog" ] || { echo "changelog-armed: no such file: $changelog" >&2; exit 1; }
[ -f "$version_file" ] || { echo "changelog-armed: no such file: $version_file" >&2; exit 1; }
ver="$(tr -d '[:space:]' < "$version_file")"
[ -n "$ver" ] || { echo "changelog-armed: $version_file is empty" >&2; exit 1; }
# The TOP section: the first '## ' heading in the file. Everything above it is
# the changelog's own preamble and belongs to no section.
top="$(grep -m1 '^## ' "$changelog" || true)"
[ -n "$top" ] || {
echo "changelog-armed: $changelog has no '## ' section at all — nothing for a PR entry to land under" >&2
exit 1
}
# '## 0.7.0 — 2026-07-19' -> '0.7.0'. Split on whitespace, same shape
# release-notes.sh matches on, so the two cannot disagree about what a
# section header is.
top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')"
case "$ver" in
*-dev)
if [ "$top_ver" != "Unreleased" ]; then
cat >&2 <<EOF
changelog-armed: VERSION is '$ver' (a development tree) but the top section of
$changelog is:
$top
A -dev tree MUST carry '## Unreleased' at the top. Without it, a PR that
wrote its entry under '## Unreleased' before the release merges CLEANLY into
the section above — the one that already shipped — and the changelog quietly
misattributes it (#108, heavy-duty/rig#66).
The fix is to re-arm: add an empty '## Unreleased' immediately above
'$top'. The release ceremony is supposed to do this in the same edit that
stamps the version — see CONTRIBUTING.md, "Releases".
EOF
exit 1
fi
;;
*)
# A bare VERSION is the ceremony tree and the merge commit that publishes
# it. Both arrangements are legal there: re-armed ('## Unreleased' back on
# top, above the section just stamped) or not yet re-armed (the stamped
# section still on top). What is NOT legal is a stamped top section naming
# some OTHER version — that is a ceremony that stamped the wrong number,
# and release.yml would publish a body that is not this release's.
if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]; then
cat >&2 <<EOF
changelog-armed: VERSION is '$ver' but the top section of $changelog is:
$top
A bare VERSION means this tree is a release. Its top section must be either
'## Unreleased' (re-armed after stamping) or the stamped section for '$ver'
itself. A stamped section naming a different version means the ceremony
stamped the wrong number, and the published release body would come from
the wrong section.
EOF
exit 1
fi
# The top heading is deliberately left UNCONSTRAINED above — both ceremony
# shapes must stay legal, which is the #44 / cast#108 lesson and is not
# negotiable. That asymmetry leaves a gap of its own, the HALF-ceremony
# tree: VERSION bumped to the release, a populated '## Unreleased' still on
# top, and no stamped section for the version anywhere. The test above is
# false on its first clause, short-circuits, and passes. Nothing else
# refuses until release.yml extracts the notes — which happens AFTER the
# merge, on main, and publishes a release with an empty body, the worst
# place for this to land. So make the same assert one step earlier by
# running the very script release.yml runs (heavy-duty/rig#67).
if ! bash "$here/release-notes.sh" "$ver" "$changelog" >/dev/null 2>&1; then
cat >&2 <<EOF
changelog-armed: VERSION is '$ver' but $changelog has no non-empty section for
'$ver'. The top section is:
$top
This is a HALF-DONE ceremony: the version was bumped but its section was
never stamped — the stamp is MISSING, not misnumbered. A bare VERSION means
this tree is a release, and the section it is about to publish has to exist
and have prose in it. Left alone, this passes CI, merges, and only then does
release.yml refuse to extract the notes — on main, after the fact, with the
release already half-shipped.
The fix is the ceremony's first edit (CONTRIBUTING.md, "Releases"): stamp
'## Unreleased' into '## $ver — DATE', then put an empty '## Unreleased'
back above it.
EOF
exit 1
fi
;;
esac
echo "changelog-armed: VERSION '$ver' agrees with the top section ($top_ver)"

View file

@ -1,228 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
# written by machinery, never by hand. Every run derives each open PR's
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
# reviews) and converges the labels to it, so a killed run or a hand-moved
# label heals on the next pass. Stale is judged from real activity — commits,
# comments, reviews — never from label churn, or the sweep would un-stale its
# own mark every tick.
#
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
# request-changes. Some live bots are comment-only and post agreement as a
# COMMENTED review — a non-verdict this machine refuses to guess about (body
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
# by requesting the human's review — an explicit request is a fact, and it is
# the one this machine trusts (see decide_state's top precedence). The
# machine auto-requests the human only in the no-judgment-needed case: three
# formal head-current approvals. Any approval that counts must be bound to
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
# stale approval must never promote unreviewed code to the human.
#
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
# sweep tolerates a missing label rather than recreating it.
#
# The state machine below is pure (globals in, state out) and covered by
# fixture tests in test/labels-reconcile.sh.
HUMAN="${HUMAN_REVIEWER:-danmt}"
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
STALE_AFTER=$((48 * 3600))
log() { printf 'labels: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
# ---------------------------------------------------------------------------
# The state machine. Pure functions over four globals, set per PR:
# DRAFT true|false
# HEAD_SHA the PR's current head commit
# REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
# ---------------------------------------------------------------------------
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
local review state commit
review="$(jq -c --arg u "$1" \
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
<<<"$REVIEWS_JSON")"
if [ -z "$review" ]; then echo MISSING; return; fi
state="$(jq -r '.state' <<<"$review")"
commit="$(jq -r '.commit_id' <<<"$review")"
case "$state" in
CHANGES_REQUESTED)
# blocks at ANY head — GitHub's own semantic: only a newer review
# from the same reviewer clears it
echo BLOCK ;;
APPROVED)
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
*)
# COMMENTED and anything else: a non-verdict. The machine does not
# read bodies — if the comment is really an agreement, the AUTHOR
# says so by requesting the human's review.
echo FEEDBACK ;;
esac
}
human_request_needed() { # 0 when needs-human requires a FRESH human request
# already requested → the handoff is live; head-current human approval →
# nothing left to ask. Anything else (never reviewed, an old comment, an
# approval of an older head) stalls the handoff unless we request —
# guarding on "has the human ever reviewed" wedged exactly that way.
if requested "$HUMAN"; then return 1; fi
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
return 0
}
decide_state() { # → the one state:* label this PR should carry
if [ "$DRAFT" = true ]; then echo state:building; return; fi
# an explicit human request outranks the bot rounds — it is the final
# gate, and a maintainer pulling a PR to themselves early counts too
if requested "$HUMAN"; then echo state:needs-human; return; fi
local b v verdicts=""
for b in "${BOTS[@]}"; do
if requested "$b"; then echo state:bots-reviewing; return; fi
done
for b in "${BOTS[@]}"; do
v="$(bot_verdict "$b")"
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
verdicts="$verdicts $v"
done
case "$verdicts" in
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
# STALE = a verdict for an older head → the agent owes a re-request.
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
esac
# the bots all approve — but if the human's standing word is
# changes-requested (and nobody re-requested them yet), the agent owes
# fixes, not the human a nag
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
echo state:addressing
else
echo state:needs-human
fi
}
# ---------------------------------------------------------------------------
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
# others — each PR reconciles in a subshell and a failure just logs.
# ---------------------------------------------------------------------------
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
done <<'EOF'
state:building|FBCA04|PR is a draft — the coding agent is still building
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first
release|0E8A16|Release flow and version/packaging work
scope:cli|C5DEF5|bin/box — the command surface
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
scope:templates|C5DEF5|templates/ — the box seeds
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
EOF
}
has_label() { grep -qxF "$1" <<<"$LABELS"; }
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity age
desired="$(decide_state)"
# encode the runbook's last step for the no-judgment case: three formal
# head-current approvals → the human is asked, once. The guard asks whether
# a FRESH human review is needed for THIS head — never "has the human ever
# reviewed", which wedged the handoff after any earlier human comment.
# Idempotent (a live request suppresses it); race-free via the shared
# concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
log "#$n: requested $HUMAN (round passed)"
fi
# ---- converge the state:* labels ----
remove=""
for s in "${STATES[@]}"; do
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
done
remove="${remove#,}"
if ! has_label "$desired" || [ -n "$remove" ]; then
args=(--add-label "$desired")
[ -n "$remove" ] && args+=(--remove-label "$remove")
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
fi
fi
# ---- stale: real activity only, and blocked is legitimately quiet ----
last_activity="$(
{
jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1
)"
age=$((NOW - $(date -d "$last_activity" +%s)))
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
log "#$n: unstale"
fi
elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)"
fi
}
main() {
REPO="${REPO:?set REPO to owner/name}"
NOW="$(date +%s)"
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
log "workflow_dispatch: bootstrapping the taxonomy"
bootstrap_labels
fi
local n
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
(
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
reconcile_pr "$n"
) || log "#$n: reconcile failed — continuing with the remaining PRs"
done
log "reconciled."
}
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi

View file

@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
# section of the changelog: every line between its '## <version> — <date>'
# header and the next '## '. This is what release.yml hands to
# 'gh release create', so the release notes are the curated prose we wrote,
# not the PR list GitHub would generate (#83). Fails loudly when the section
# is missing or empty — a tag without its changelog section is a release
# ritual skipped, and an empty release body would paper over it.
#
# A file of its own (not inlined in release.yml) so test/release.sh drives
# the same extraction against fixtures and the real CHANGELOG.md.
ver="${1:-}"
changelog="${2:-CHANGELOG.md}"
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
# $2 of a section header ('## 0.6.0 — 2026-07-18') is the bare version —
# compared WHOLE, so 0.6.0 can never match a 0.6.0-rc1 section (or vice
# versa), and no regex-escaping of dots. sed drops the blank padding under
# the header; the command substitution eats the trailing blanks.
notes="$(awk -v ver="$ver" '
/^## / { grab = ($2 == ver); next }
grab { print }
' "$changelog" | sed '/./,$!d')"
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#83)" >&2; exit 1; }
printf '%s\n' "$notes"

View file

@ -8,21 +8,56 @@ jobs:
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 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
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
@ -30,7 +65,48 @@ jobs:
# 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
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

View file

@ -1,56 +1,13 @@
name: labels
# The automation LABELS.md promises. Two halves:
# scope — path-derived scope:* labels on PRs (actions/labeler)
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
#
# pull_request_target, not pull_request: every PR here arrives from a fork,
# where pull_request (and pull_request_review) run with a READ-ONLY token and
# cannot label anything. _target is safe in this workflow because no PR code
# is ever checked out or executed — labeler reads changed paths via the API,
# and reconcile checks out the BASE branch only. Keep it that way.
#
# Review-submitted transitions (bots finishing a round) ride the cron: there
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
# the same cadence the reviewer bots poll at.
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
scope:
if: github.event_name == 'pull_request_target'
runs-on: ubuntu-latest
concurrency:
group: labels-scope-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- uses: actions/labeler@v5
with:
# additive only — a hand-applied scope must survive the machine
sync-labels: false
reconcile:
runs-on: ubuntu-latest
# ONE shared group: every reconcile sweeps every open PR, so cron and
# PR-event runs must serialize or two sweeps race the same PR's labels
# and both pass the request-the-human-once guard. GitHub keeps at most
# one queued run per group (older queued runs are superseded), which
# coalesces bursts instead of piling them up.
concurrency:
group: labels-reconcile
cancel-in-progress: false
steps:
- uses: actions/checkout@v4 # base branch only — never the PR's code
- name: reconcile state + stale
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: bash .github/scripts/labels-reconcile.sh
labels:
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0

View file

@ -1,213 +1,18 @@
name: release
# The release publisher — two doors into the same act (#83, #96):
#
# * The merge door (#96): merging the `release`-labeled PR into main IS the
# release. The label is the intent, the version transition is the
# interlock — VERSION at the merge commit must be non-`-dev` AND must have
# changed in this PR, so a mislabeled ordinary PR fails loudly and creates
# NOTHING. The job then tags the merge commit via the API and publishes,
# in the SAME job on purpose: a GITHUB_TOKEN-created tag does not trigger
# other workflows (GitHub's anti-recursion), so that tag can never re-enter
# the tag door below and double-publish — publishing here is the only
# chance, and the no-existing-tag/release assert covers a manual tag
# racing the merge.
#
# * The tag door (#83) stays as the documented manual fallback and backfill,
# on a bare X.Y.Z tag push (the 0.6.0 tag set the precedent — no 'v'
# prefix). The tag must name the tree's own VERSION (a mismatch fails
# loudly and creates NOTHING — a wrong release is worse than a missing
# one).
#
# Both doors publish the release body from that version's CHANGELOG.md
# section (.github/scripts/release-notes.sh, shared with test/release.sh) —
# the curated prose, not the generated PR list. No assets are uploaded: for a
# pure-bash tree, GitHub's source tarball for the tag IS the package, and
# install.sh downloads exactly that.
# Triggers and permissions MUST live here (a called workflow cannot define them):
on:
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
# `push:` silently replaces the first and kills a door (rig's review catch).
push:
# The merge door rides pushes to MAIN, not pull_request events, for one
# load-bearing reason the first review round caught (#97): a workflow
# run triggered by a pull_request from a public FORK gets a READ-ONLY
# GITHUB_TOKEN — `permissions:` cannot raise that ceiling — and every
# ceremony PR this org has ever merged is cross-repo from the bot fork.
# The asserts would pass and the tag create would 403, red on main,
# every release. A push to main is an in-repo event with the full write
# token, whoever authored the PR.
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
# never be skipped by a shape filter that didn't match
branches: [main]
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
# below, not be silently skipped by a pattern that didn't match.
tags: ["**"]
permissions:
contents: write # create the tag ref + gh release create + the bump push
# Two consumers (a declared permissions: block zeroes every unspecified
# scope): the decide step's label read (commits/<sha>/pulls) and the bump
# fallback's `gh pr create --label`.
pull-requests: write
# ...and the --label on that fallback PR rides the ISSUES API (labels.yml
# grants the same pair for the same reason).
issues: write
contents: write # tag ref create + release create + the bump push
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
issues: write # --label on that fallback PR rides the issues API
jobs:
# The merge door (#96), riding pushes to main (see the trigger comment:
# fork PRs get a read-only token on pull_request events). The hand-set
# `release` label (LABELS.md: automation never guesses intent) is read via
# the API off the merge commit's PR, inside the decide step below.
release-on-merge:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The pushed head plus its first parent (fetch-depth: 2): the
# first parent is main the instant before the PR landed, which the
# changed-in-this-PR assert compares against.
ref: ${{ github.sha }}
fetch-depth: 2
# The decide step — the version asserts fused, because the `release`
# label carries TWO legitimate meanings (LABELS.md: "release flow and
# version/packaging work"): the ceremony PR that ships a version, and
# ordinary work ON the release machinery — the PR that added this very
# job included. The version tells them apart, in four states:
# -dev, unchanged → work under the label: green NOTICE
# no-op, not a red run per infra PR
# -dev, changed → still a dev tree, so still work —
# the post-release bump PR above all
# (bare -> -dev after every release):
# green NOTICE no-op
# bare, unchanged, released → work merged in the post-release
# window (ceremony landed, the -dev
# bump has not): green NOTICE no-op
# bare, unchanged, UNreleased→ the label says ship but this PR did
# not mint the version: refuse to guess
# bare, changed → the ceremony: proceed
- name: 'decide: ceremony, or release-flow work under the label?'
id: decide
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
base="$(git show HEAD^1:VERSION)"
case "$ver" in
*-dev)
if [ "$base" = "$ver" ]; then
echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "NOTICE: VERSION changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0 ;;
esac
if [ "$base" = "$ver" ]; then
if gh release view "$ver" --json name >/dev/null 2>&1; then
echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish."
echo "ceremony=no" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "VERSION '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." >&2
exit 1
fi
# The version transitioned — now the LABEL, the operator's declared
# intent, read via the API because a push event carries no PR
# payload (and the PR lives on a fork — the trigger comment). No
# merged, release-labeled PR behind this commit = a transition
# nobody declared: refuse.
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
echo "VERSION transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#96), not a bare push — creating nothing." >&2
exit 1
fi
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
- name: release notes — the version's own CHANGELOG.md section
if: steps.decide.outputs.ceremony == 'yes'
# release-notes.sh fails loudly on a missing/empty section, which
# fails the release here — before anything is created.
run: |
bash .github/scripts/release-notes.sh "$(cat VERSION)" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$ver" --silent 2>/dev/null; then
echo "tag '$ver' already exists — a manual tag beat this run, or this is a re-run of a published release — creating nothing." >&2
exit 1
fi
if gh release view "$ver" --json name >/dev/null 2>&1; then
echo "release '$ver' already exists — creating nothing." >&2
exit 1
fi
- name: tag the merge commit, then publish — one job, on purpose
if: steps.decide.outputs.ceremony == 'yes'
# Same job as the asserts: the GITHUB_TOKEN-created tag triggers no
# workflows (GitHub's anti-recursion), so the tag door cannot fire
# off it — this step is the release's only chance to publish.
env:
GH_TOKEN: ${{ github.token }}
MERGE_SHA: ${{ github.sha }}
run: |
ver="$(cat VERSION)"
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA"
gh release create "$ver" --verify-tag --title "$ver" --notes-file "$RUNNER_TEMP/notes.md"
# The post-release bump, folded into the release act (#96 followup —
# operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed
# straight to main with this job's token. A GITHUB_TOKEN push fires
# no workflows (anti-recursion), so the bump triggers neither this
# door nor a red run; should branch protection ever refuse the direct
# push, the step opens the bump PR itself and says so, loudly,
# instead of leaving main armed to impersonate the release.
- name: bump main to the next -dev — the release re-arms main itself
if: steps.decide.outputs.ceremony == 'yes'
env:
GH_TOKEN: ${{ github.token }}
run: |
ver="$(cat VERSION)"
next="$(printf '%s' "$ver" | awk -F. '{ printf "%s.%s.%s-dev", $1, $2, $3 + 1 }')"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout -B main origin/main
printf '%s\n' "$next" > VERSION
git add VERSION
git commit -m "chore: bump main to $next — a dev install must not impersonate $ver"
if ! git push origin main; then
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next"
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
--title "chore: bump main to $next" \
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One file, one line." \
--label release
fi
# The tag door (#83) — the manual fallback and backfill, unchanged. Gated
# to the push event so a closed PR (the trigger above) never runs it
# against a branch ref.
release:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: the tag must name the tree's VERSION
run: |
ver="$(cat VERSION)"
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
exit 1
fi
- name: release notes — the version's own CHANGELOG.md section
# release-notes.sh fails loudly on a missing/empty section, which
# fails the release here — before anything is created.
run: |
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md"
- name: create the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
with:
version-source: file

7
AGENTS.md Normal file
View file

@ -0,0 +1,7 @@
# AGENTS.md — start at .ceremony/
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
beside it. Repo specifics (the review panel roster, the scope labels, what
a drill means here, code conventions) live in CONTRIBUTING.md.

View file

@ -5,649 +5,161 @@ which records not just what changed but what each drill run proved.
## Unreleased
### Fixed
- `box exec` preserves newlines and command argv across its login-user boundary (#169)
### Changed
- Release and repository governance now use the shared ceremony pinned at `0.1.0` (heavy-duty/ceremony#14)
### Added
- `kimi-box` template — the Moonshot Kimi CLI agent seed (#158; rig#109's tenant)
## 0.9.0 — 2026-07-21
### 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)
- CI refuses a release PR with no drill record at `drills/<version>.md`
### 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)
### 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)
## 0.8.0 — 2026-07-19
### Added
- **Merging the release PR IS the release — and the release re-arms main
itself** (#96) — the 0.7.0 ceremony ended in an absence: the release PR
merged with four approvals and nothing happened, correctly, because
publishing hung off a separate, manual, silent-when-forgotten tag push —
a failure shape with no error and no red X. The ship decision already
lives in the release PR (the one PR whose whole diff is "the version
leaves `-dev`"), so `release.yml` now fires on pushes to main
(fork-sourced ceremony PRs get a read-only token on `pull_request`
events), reading the transition from the push itself: `event.before` to
the pushed head. A decide step answers four states — release-flow *work*
merged under the `release` label (`-dev` endstates, the post-release
window) no-ops green with a NOTICE; the two genuinely ambiguous bare
states refuse loudly; a true transition then requires a merged,
`release`-labeled PR behind the commit (read via the API — the label is
the operator's declared intent) before anything is created. Then, in the
same job, it tags the merge commit via the API, publishes — and bumps
main to `X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR
fallback, so no follow-up bump PR exists on the paved road. Same-job on
purpose: a `GITHUB_TOKEN`-created tag triggers no workflows, which is
also what makes double-publish impossible. The tag-push path stays
unchanged as the documented manual fallback and backfill (it shipped
0.7.0 itself). `test/release.sh` grep-pins the gate, every decide
verdict, the single `on.push` key, and the same-job tag+publish+re-arm
in the same daemon-free, fail-closed style.
- 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) — the ceremony stamps `## Unreleased` into
`## X.Y.Z — DATE` by hand, and nothing put the heading back, so `main`
sat with no `## Unreleased` from the release until the next PR that
happened to re-create one. A PR authored *before* the release wrote its
entry under `## Unreleased`; with that heading gone, git lands the entry
under whatever now occupies the position — **the section that just
shipped** — and it merges **cleanly**. No conflict, no error, no red X:
the one signal an author would trust is absent exactly when the outcome
is wrong, and the changelog credits a released version with a change it
does not contain until a human reads the file. Confirmed in the sibling
repo (heavy-duty/rig#66); box has not drifted yet, and the reason is
luck rather than design — 0.6.0's ceremony (`77599ab`) added its heading
*without* removing `## Unreleased`, so main was never disarmed, while
0.7.0 did disarm it and left a window that nothing happened to cross.
Two halves land together. The ceremony step in `CONTRIBUTING.md` is now
explicitly **two edits**: stamp, then put an empty `## Unreleased` back
above the section just stamped — it belongs there and not in
`release.yml`, which only ever touches `VERSION`. And
`.github/scripts/changelog-armed.sh` enforces it in CI, keyed on
`VERSION` because the two states are genuinely different: a `-dev` tree
must carry `## Unreleased` on top, a bare-`VERSION` tree (the ceremony
PR, and the merge that publishes it) may carry either that or its own
stamped section. The keying is the whole design and not an
over-complication — box previously had **no** top-section guard at all,
and the obvious one, an unconditional `## Unreleased` requirement, is
false by construction on the ceremony PR's own tree, which is why rig#44
and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm
does not block the release; it turns `main` red on the very next push,
the automatic `-dev` bump the release itself makes. Leaving the bare
branch's top heading unconstrained is what keeps both ceremony shapes
legal, and a review round on the sibling fix (heavy-duty/cast#114) found
the gap that asymmetry leaves: a **half-ceremony** tree — `VERSION`
bumped, `## Unreleased` still populated on top, and the section for that
version never stamped — makes the wrong-number test false on its first
clause, short-circuits, and passes. Nothing then refuses until
`release.yml` extracts the notes, which is *after* the merge, on `main`,
with the release already half-shipped. So the bare branch now also
requires that the section it is about to publish exists and is non-empty,
and it asserts that by running `release-notes.sh` — the very script
`release.yml` runs — so the guard and the publisher cannot drift apart
over what a section is. The message is its own: a missing stamp is not a
misnumbered one, and an operator sent to correct a version number that is
already right will not find the real problem. Matches
heavy-duty/rig#67, so the three repos agree.
- **Ctrl-D at a confirmation prompt aborts out loud, instead of exiting
in silence** (#111) — `confirm()` and `uninstall_confirm()` both took
the operator's answer with a bare `read -r reply`. Every answer a
human can type routes through the `case` below it and ends at a
`return` or at `die "aborted."` — every answer except EOF. Ctrl-D
makes `read` return non-zero, `set -euo pipefail` ends the run on that
line, and the `case` is never reached: box exits 1 having printed
nothing at all after the question it just asked. It fails closed,
which is why this is a small fix and not an incident — nothing is
destroyed, the abort is real. The damage is that the tool goes mute at
the one moment it had the operator's full attention, and someone who
Ctrl-Ds out of `box rm work` cannot tell from the output whether the
box is still there. The cure is one token in each function,
`read -r reply || die "aborted."`, the same one heavy-duty/rig#43
applied to rig's credential prompts so the two repos read alike. The
bug predates everything it touches — `rm` has carried a confirm gate
for as long as the verb has existed — but #105 took the number of
verbs reaching that line from one to two, and both are irreversible,
which is the argument for closing it now rather than the next time
someone notices. The three answers a human can actually give (`y`,
`n`, and Ctrl-D) are now driven for real on a pty via util-linux
`script`: they were structurally untested before, because `[ -t 0 ]`
sends a terminal-less suite to the refusal branch and every existing
check stopped there — which is exactly how this survived four
releases. Review caught that the first pass fixed the bug where it was
reported and stopped there, while the same defect sat at two more
destructive gates in this repo: `host/revoke-user.sh:50`, the prompt
guarding `box revoke --purge` — the one whose own text says "this
cannot be undone" — and `host/teardown-host.sh:31`, guarding a full
host teardown. Both run under `set -euo pipefail`, both died mute on
EOF with their `aborted` line never reached; both now carry the guard
in their own script's wording. The three `drill/` prompts are
deliberately left alone — they run under `set -u` only, so EOF falls
through to the `*)` arm and already aborts out loud — and
`install.sh:65` was already guarded. What keeps the class closed is a
repo-wide sweep in `test/cli.sh`: every statement-initial `read` fed
from stdin, in any file that turns on errexit, must carry a `||`
guard, with `while read` loops and `<<<` herestrings excluded because
neither is a prompt. The sweep flags all four sites when their guards
are removed and nothing else across the tree's fifteen shell files —
the absence of exactly this check is why the `host/` pair was missed
in the first place.
- **`box restore` asks before it destroys — and the confirmation prompt is
now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly
discard user state, and only one of them asked. The table gave `restore`
the preconditions `box,arg2`: the instance is ours, a snapshot name is
present, go. So `box restore work stale-label` silently threw away
everything done in the box since that snapshot, with no prompt, no
`--force`, and no way to take it back — a warning in `--help` is not a
gate. It has been that way since the verb shipped, and it is about to
become routine rather than rare (heavy-duty/rig#62's pristine snapshot),
which is the wrong time to still be relying on the operator typing the
right label. The reason it stayed ungated is worth recording, because it
is the actual bug: `confirm` was already a precondition token, but the
dispatch line hardcoded the *words* — `confirm "delete $inst and all its
snapshots"` — so the one-token fix would have gated restore behind a
prompt offering to DELETE the box the operator was trying to rescue. A
gate that names the wrong act is worse than no gate; it is how people
learn to answer `y` without reading. So the prompt moved into the table
as a seventh field, each row saying what it is about to do in its own
words, and `restore` now asks to "roll `<box>` back to snapshot
`<label>` and discard everything in the box since it was taken" — naming
the label, because picking the wrong one is the whole risk. `rm`'s
wording is unchanged and pinned verbatim by a test, since rewording the
one verb that already worked would be a regression shipped as a
refactor. A row marked `confirm` with no words is now a hard internal
error rather than a blank question. `--force` and the no-TTY refusal come
free — `confirm()` already had both. The one automated caller had to
consent explicitly: `drill/multiuser.sh` drives restore unattended on real
Incus and now passes `--force`, which is the rehearsal proving the gate
rather than working around it — the CI run of this very PR failed there
first, which is the shape a gate is supposed to have. Coverage went from two
argument-validation checks that never reached dispatch to the destructive
path itself, driven against a fake incus: refusing leaves the call log
empty, `--force` produces exactly one `incus snapshot restore`. Not
changed, deliberately: `restore` still does not require the box stopped
(#105 makes that case separately and it deserves its own call), and
`--help` now says plainly that a rollback of a running box is
crash-consistent, because these snapshots are stateless.
- **`box-firewall` could hand a UFW host the no-UFW firewall, ~2% of the
time** (#102) — filed as an intermittent test flake (`test/cli.sh`'s
fresh-UFW block going four-assertions-red on an unmodified `main`,
measured here at 5 failing runs in 40), it was not one. The branch that
decides the host's entire firewall stance read
`ufw status | grep -q "Status: active"`, and `Status: active` is the FIRST
line ufw prints: `grep -q` matches it and exits immediately, closing the
pipe while ufw is still writing the rest of the table, so ufw dies of
SIGPIPE. `grep` returned 0, but under this script's `set -o pipefail` the
PIPELINE returns 141 — the `if` reads false and a host with UFW plainly
active takes the nft-fallback branch, never building the DNS carve-out its
persisted rules depend on. A pure scheduling race, isolated at ~2% per
invocation (`PIPESTATUS` = `141 0`; a draining reader flakes 0/2000, a
reader whose match is on the last line flakes 0/2000). Real ufw is a
slower, longer writer than the test shim, so production had no reason to
be safer. `ufw status` is now read ONCE into a variable and matched with
`[[ ]]` — no reader, no race — and the stale-rule scan reads that same
snapshot, so the branch decision and the converge loop can no longer
disagree. **`host/teardown-host.sh` carried the same live defect** and is
fixed with it: that file does set `pipefail` (line 12), so its UFW
crumb-removal branch could read a plainly-active UFW as inactive and skip
silently, leaving stale `boxnet`/`claudenet` rules on a host the operator
was told is clean — and its numbered-delete loop had the same early-exit
reader as its condition, so it could end while rules remained. Both now
read captures. The sibling calls in `drill/wipe.sh` and `drill/doctor.sh`
are the same shape but set only `set -u`, so the SIGPIPE is discarded
there and the branch holds — latent, not live, until either gains
`pipefail`.
- **A missing firewall log now diagnoses itself** (#102) — the four greps
reading `$WFW/*.log` used to fail together with empty output when the
driving run took the wrong branch, a signature that looks specific and
says nothing (#102 was filed reading it as "the log is not written";
the log existed, the mutations did not, and that distinction *was* the
diagnosis). `test/cli.sh` now asserts the precondition explicitly before
the content greps and, on failure, prints the contents of `$WFW`, the log
itself, and the stderr of the run that should have written it. It also
keeps `an agreeing UFW host deletes nothing` honest: that check asserts an
absence, which a run that did nothing at all passes for the wrong reason.
- **`box grant` provisions an `incus-admin` member instead of refusing them**
(#99) — the refusal read "they already have the admin tier; there is
nothing tighter to grant", which is true about *permission* and silent
about *provisioning*: the `incus` group is indeed a strict subset of what
`incus-admin` opens **at the daemon API**, but the `user-<uid>` project, the
boxnet narrowing, the snapshot and backup allowances, and the `box-net`
profile installed into that project are none of them permissions, and an
`incus-admin` member had none of them — `box_tier()` resolves them to
`admin`, so they worked in the shared default project next to root and every
other admin, with no world of their own and no supported way to get one.
`box grant` now runs the full convergence for them.
The group step is part of that convergence, not an exception to it: an
`incus-admin` member is added to `incus` like anyone else. The subset
argument holds for the API and **fails at the filesystem**, which is where
it matters here — the two sockets are two files with two owning groups
(Debian 13 / Incus 6.0.4, measured):
| socket | group | mode |
| --- | --- | --- |
| `/var/lib/incus/unix.socket` | `incus-admin` | 0660 |
| `/var/lib/incus/unix.socket.user` | `incus` | 0660 |
`incus-admin` opens the first and not the second, and only the second
provisions a `user-<uid>` project. Without the membership the provisioning
touch takes `EACCES`, the swallowing `|| true` hides it, no project appears,
and the grant dies blaming a perfectly healthy incus-user — the exact
incus-admin-only user #99 is about, left no better off. So the membership is
granted, and the grant says out loud why: it is the key to a file, not a new
privilege (`box_tier()` still reads them as `admin`, both-groups → `admin`).
The touch itself is **pinned at incus-user's socket**: the incus client picks
by writability (`client/connection.go` — the daemon socket when writable,
`unix.socket.user` only otherwise), so for an `incus-admin` member an
unpinned touch sails past incus-user and provisions nothing. The user-side
proof that closes the grant names their project for the same reason, since an
unqualified `profile show` would have answered from the shared default
project and proved nothing. The socket's existence is probed through `$SUDO`,
not a bare `[ -e ]``/var/lib/incus` is not traversable by a non-root
admin, so an unprivileged stat reports a present socket as absent, and this
probe exits on absent (the discipline `box revoke` already documents).
On success the grant prints the caveat the hard exit was gesturing at, in the
two forms it actually takes: the restrictions are a **default placement, not
a confinement** (admin membership still wins at the socket — the default
project and other users' instances stay one flag away), and until
`incus-admin` goes their own `box` commands keep landing in the default
project. Dropping `incus-admin` then lands them in their ready project with
**no re-grant** — a promise that is only true because they keep `incus`;
without it that drop would leave them in neither group, `box_tier()` `none`,
and a converged project they could not open. The failure path follows: the
membership this run added is rolled back and verified, while the backout
refuses to call that a lockout — `incus-admin` is untouched and still opens
every project.
`box revoke` mirrors it. A bare revoke of a granted `incus-admin` member now
takes the `incus` membership back and reports **`partial:`** — the socket key
`box grant` added is gone, their project is kept, and they are explicitly
**not** locked out. An `incus-admin` member who was never granted is still a
named **no-op** that makes no privileged call at all. `--purge` unmakes the
provisioning while refusing to call them "out". Every path names
`gpasswd -d <user> incus-admin` as the only thing that ends their access.
Unblocks rig's `users apply` (heavy-duty/rig#49), which had to call `box
grant` for a user who is both `incus-admin` by hand and role `box` in the
fleet file. Driven end to end in `test/cli.sh` under logging incus/sudo shims
— every assertion is made against what the run did, not what the source says
it would — and, because those shims model neither `INCUS_SOCKET` nor file
permissions and so cannot reproduce the `EACCES`, measured on real Incus in
CI by a new `drill/multiuser.sh` criterion (o): an `incus-admin`-only member
is granted, the membership lands, the project appears, `unix.socket.user`
opens as them, and dropping `incus-admin` leaves them in their own project
with no re-grant.
- 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) — `curl | bash` used to hand out whatever `main` was at
that second: the 0.6.0 release was a bookmark, not a package, and two
operators "on 0.6.0" could be running different trees. `install.sh` now
resolves the latest release tag by following GitHub's `releases/latest`
redirect (one HEAD request — no API, no token, no rate-limit pain) and
downloads that tag's tarball; a failed resolution refuses loudly, naming
`BOX_REF` as the way out — it never hangs and never silently falls back to
`main`. A set `BOX_REF` is tried as a tag first, then as a branch, so one
knob yields three channels: default = latest release, `BOX_REF=0.6.0` =
pinned, `BOX_REF=main` = dev. A new `release.yml` (on a bare `X.Y.Z` tag
push — the `0.6.0` tag set the no-`v` precedent) asserts the tag names the
tree's own `VERSION` (a mismatch fails loudly and creates nothing) and
publishes the GitHub release with that version's `CHANGELOG.md` section as
the body (`.github/scripts/release-notes.sh` — the curated prose, not the
generated PR list; no assets, the source tarball for the tag IS the
package). And `main`'s `VERSION` now carries `-dev` between releases
(this PR: `0.6.1-dev`): the versioned layout names install trees after
`VERSION`, so a `main` install without the bump would land in
`versions/0.6.0` and impersonate the released tree. `test/release.sh`
drives all of it offline — the extraction against fixtures and the real
changelog, the resolution and every channel against a shim curl.
- **`setup-host` auto-picks a free subnet — nested box-in-box with zero
flags** (#80, completing its fix #1: "refuse … or automatically select a
non-colliding subnet"). A bare `box setup-host` now decides the subnet
itself, in four deliberate cases: an explicit `BOX_SUBNET` is honored or
refused, never silently overridden (scripted hosts keep exact semantics);
an existing `boxnet` bridge is converged on as-is — the bridge IS the pin —
turning the old bare-re-run agree-gate refusal into plain convergence
(unless a foreigner *also* claims the bridge's subnet: that is #80's
poisoned state, and converging would rebuild on it, so it still refuses and
names the bridge move); a free `10.88.0.0/24` stays the default; and a
*claimed* default — the nested case: a drill or rehearsal running inside a
box, whose own uplink owns 10.88 — scans `10.89.0.0/24``10.127.0.0/24`
in order, takes the first free candidate, announces the pick and the
claimant loudly, and only refuses when every candidate is claimed. The
decision happens before any mutation, and everything downstream (the
bridge, `BOX_GW`, the ACL's gateway carve-out, the firewall, the doctor's
expectations) derives from it.
- **`setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another**
(#80) — run inside a box, `setup-host` used to build a nested `boxnet` on
the exact subnet and gateway of the guest's own uplink: the guest then held
its gateway's address as a *local* address, carried duplicate connected
routes for its uplink subnet, and suffered intermittent, self-recovering
egress blackouts that looked like flaky internet (measured live: ~2436 s
outages, roughly hourly, with the host clean throughout). `setup-host` now
scans the target subnet **before any mutation** — the default route's
gateway inside it, or any non-`boxnet` interface holding an address in it —
and refuses, naming the way out. A prior `boxnet` owning the subnet is the
legitimate converge path and does not trip it. `BOX_SUBNET=<a.b.c.0/24>`
(validated, alongside the existing `BOX_DNS`) moves the whole stack: the
bridge address, the ACL's gateway carve-out (now converged via
`network acl edit`, so a bridge moved off a colliding subnet no longer
strands box DNS behind a stale `/32`), the firewall (`box-firewall` reads
the gateway off the live bridge), and every drill/migrate probe that used
to hardcode `10.88`.
- **`box doctor` knows the #80 signature** — a default gateway held as a
LOCAL address, and duplicate connected routes for the uplink subnet, judged
from `ip route`/`ip addr` on the machine doctor runs on (both tiers, before
any daemon check — the nested daemon answering could be the impostor) and
probed *inside* every box it examines. The existing "egress broken but DNS
fine" split now names itself as #80's fingerprint (the impostor dnsmasq on
a captured gateway keeps resolving while IP egress dies), and the admin ACL
section verifies the gateway carve-out matches `boxnet`'s actual gateway.
The agent-context guard for the templates (suggested fix 4) lands in
heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).
- **The `staging` template** (#81, the re-cut of #69's layering) — a
server-class, creds-free seed: Debian 13, user `ops`, tmux, rig,
`BOX_REQUIRE_VM=1` (the VM is its trust boundary), `BOX_AUTOSTART=1` (a
server returns from a host reboot without an operator), and
`BOX_BOOTSTRAP_ROLE="staging"` — the server posture (docker, sshd
hardening) converges via `rig bootstrap staging` after mint. The tailnet
workload join holds a pre-auth key and therefore **stays operator-run**
(`box shell` → `sudo rig bootstrap workload`), printed as a next step —
box never sees the key.
- **`BOX_BOOTSTRAP_ROLE` template key + mint-time auto-run** (#81) — a
template names the **creds-free** rig tenant role box runs inside the guest
after cloud-init settles (`incus exec … rig bootstrap <role>`); the value
is a role *name* by allowlist (anything shell-shaped dies at parse time, on
the host). A failed role leaves the box up and names the re-run — the roles
are convergent by contract (rig#31). `blank` names no role and auto-runs
nothing.
- **The rig pin point: `RIG_REPO` / `RIG_REF`** (#81) — the tenant seeds
preinstall rig, inverting the rig→box install edge (rig#28), and the new
edge gets the same honest treatment rig#29 gave box's unpinned install:
`@RIG_REPO@`/`@RIG_REF@` tokens in the seed resolve at mint from the
environment (default `heavy-duty/rig` @ `main` — unpinned, tracking main,
until a release flow exists, rig#32/#83). The pin covers both the installer
fetched and the tree it installs, so a rig branch under review is testable
end to end; values are allowlist-validated before touching the YAML.
- **Server-posture template keys** (#81, carved from #69) — two optional
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
(exit 2): such a template's trust boundary is the VM. `BOX_AUTOSTART=1`
stamps `boot.autostart=true` at launch, per-instance like `limits.*`, so
the box returns from a host reboot without an operator; clones inherit it
via `incus copy`. Still no key for a network or a `security.*` flag, on
purpose.
- **Dynamic template test suite** (#81, carved from #69) — `test/cli.sh`
discovers `templates/*/` instead of hardcoding the list, so a new template
cannot ship unseen. Per template: `box.env` is driven through the real,
extracted `load_template` (unknown keys and missing `BOX_IMAGE`/`BOX_USER`
fail, fixtures proving both dies); `user-data.yaml` exists, declares
`#cloud-config`, parses as YAML, and installs tmux (#65). Grep guards pin
the `cmd_new` half: the `REQUIRE_VM` refusal orders after `pick_mode`, and
`boot.autostart` is stamped only under the `T_AUTOSTART` guard.
- **`box export` / `box import`** (#70) — a box's state that survives the box
_and_ the host, unblocking #66's humane upgrade flow (down, export, rm,
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into
one portable backup tarball (default `<box>-<UTC stamp>.tar.gz`), snapshots
included by default (`--instance-only` opts out); the box must be stopped
first (`box down`) so the artifact is a settled disk, not a moving one. The
file is **shouted about, not scrubbed** — it carries the box's whole disk
(agent logins, git credentials, SSH keys), and scrubbing a disk image is a
promise tarball surgery cannot keep, so box says what is inside instead,
every time. `box import <file> [--name <box>]` mints the box back and
re-stamps what is the _current host's_ truth, not the artifact's: the
`user.box=1` boundary tag (legacy `user.claudebox=1` honored), the
`box-net` placement (re-assigned if the artifact's differs — the
migrate-host move), and a fresh machine identity: the NIC's MAC (imports
restore `volatile.*` verbatim, and a re-import beside its sibling collided
at start with "MAC address already defined on another NIC" — measured
live; `incus copy` regenerates it, `incus import` does not) plus
`reset_identity` (the clone trust boundary: no DHCP collision with the box
it was exported from).
Import refuses any name an existing instance holds — the `resolve_box`
boundary, seen from the other side. Works on both tiers: `box grant` now
also converges `restricted.backups allow` (incus-user blocks backups by
default exactly like snapshots, and an export _is_ a backup
create+download — measured against incus 6.0's `permissions.go`); re-run
`box grant <user>` after upgrading, as documented. CI's `rehearsal` job now
proves the round-trip on a live Incus: mint → write a file → snapshot →
down → export → `rm` → import → the file and the snapshot survived, the
agent answers, the tag is present, and a colliding re-import is refused.
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
`INSTALLED_FROM`), with a `current` symlink tracking the default and
`$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New
verbs: `box versions` (lists installs, marks the current default and the
running tree), `box use <version>` (flips the default, converges the PATH
symlinks, and *asserts the effective result*`current` must resolve to
the asked-for version and the chain's `box --version` must answer it).
Re-running the installer with an installed version is a converging no-op
(`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs
side-by-side and flips `current` only when no boxes exist — under existing
boxes the flip is refused loudly, naming the boxes (#66: never change
versions under a user's boxes; `box use` keeps the same refusal). A
pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's
tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale
or dangling `$BINDIR/box` is healed instead of wedging the install; and the
installer warns when the *other* tier's install (/opt/box vs ~/.local)
coexists, since PATH order decides which wins.
- **A real uninstall**`box uninstall [<version>] [--all] [--purge-host]`
replaces the "rm -rf two paths" prose. One version: refuses the current one
(`box use` off it first). Everything: runs in the safe order — refuses
while boxes exist (naming them) unless `--purge-host` runs teardown-host
first — then removes every version, the `current` and PATH symlinks, and
the legacy claudebox crumbs (both name generations), and **ends with an
absence assert**: every removed path is re-checked, and any survivor makes
it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the
`revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1`
for automation and now points at `box uninstall` when done.
- **`BOX_INSTALL_SOURCE=<dir-or-tarball>`** — installs from a local tree,
bypassing the download. CI's rehearsal job now installs via install.sh
itself (proving the installer under review, not a `cp -r` mimic of it), and
ends with an **uninstall drill**: grant + `revoke --purge` a throwaway
user, `teardown-host`, `box uninstall --all`, then assert **zero residue**
— no networks, profiles, ACLs, nft tables, systemd units, files or
symlinks.
- **test/cli.sh drives real installs** — still dependency-free, non-root, no
daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a
fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge,
reinstall, side-by-side upgrade, the three #66 refusals (install flip,
`use`, `uninstall` — boxes named), flat-tree migration, symlink healing,
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
into *driven* tests instead of greps (154 checks).
- 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, rig converges** (#81, companion rig#31) —
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the
agent CLI installs, docker, node, the per-template agent-context heredocs)
**moves to rig's bootstrap roles**, where it is convergent, idempotent and
testable end to end instead of parse-only YAML. What remains per template
is a thin, creds-free seed: the tenant user, tmux (#65), and rig
preinstalled — nothing that joins a tailnet or admits credentials. The #80
agent-context guard ("never run `box setup-host` or the drill inside a
box") now lives once, in rig's roles, not copy-pasted per template. The
template test sweep grew the contract's teeth: per-template seed asserts
(user matches, rig pinned via both tokens) and fail-closed **absence
greps** over effective cloud-init lines — no agent CLI, no docker, no
tailscale/authkey/ssh, no `write_files` heredocs — so tenant content
cannot quietly grow back.
- 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 mint's launch
phase is narrated and time-boxed** (#93) — twice in the 2026-07-19
release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached),
the child `incus launch` under `box new` hung with *no server-side
operation*: `incus operation list` empty, the instance never created, the
daemon journal quiet — one wedge ran 56 minutes before being killed by
hand, and an immediate retry of the identical command succeeded in
minutes, both times. `box new` inherited that as an indefinite silent
hang, indistinguishable from a cold mint working. It now prints
`launching instance …` before the call, and the call rides
`timeout -k 5 $BOX_LAUNCH_TIMEOUT` (seconds, default 600 — generous: the
coldest measured mint is minutes, never an hour; the same scripting-knob
shape as `BOX_CPU`/`BOX_MEMORY`), with stdin pinned per the drill's own
trap list. On the budget firing it probes whether the instance was ever
registered and tells the two stories apart — the measured #93 wedge (no
server-side operation; an immediate retry has been observed to succeed)
vs a slow launch that overran the budget with the instance already
created — then best-effort deletes either way, so the retry advice is
clean in both worlds, and points at `box doctor` for the host. The
`--from` clone path is untouched: `incus copy` of a local instance is a
different operation and has never been observed to wedge this way.
- **UFW's gateway carve-out converges with the bridge, and the doctor can
see it** (the #86 review's blind spot) — `box-firewall` gated its whole
UFW block behind "a `DENY on boxnet` rule exists", pinning every UFW host
to the gateway of the *first* run: a bridge remapped off a colliding
subnet (#80's escape hatch) kept its stale `allow … to <old-gw> port 53`
and never gained the live gateway's, so box→gateway DNS died at box's own
deny — while the doctor's carve-out check read only the incus ACL (which
setup-host converges) and called the host clean. The UFW allows now
converge off the live bridge address on every run (stale DNS allows
deleted, the live set ensured — ufw skips existing rules, so a fresh host
gets the identical rule set and a re-run is a no-op), and `box doctor`
reads UFW's own table wherever UFW is active, flagging a DNS allow that
does not match `boxnet`'s gateway (and stale allows left beside a live
one). The no-UFW nft carve-out never had this failure mode: it is
interface-scoped, no gateway address to go stale.
- **The boot-time gateway fallback is gone — no rule beats a wrong one**
with the bridge not yet addressed when `box-firewall.service` ran,
`box-firewall` guessed `GW=10.88.0.1`; on a `BOX_SUBNET` host that hit
that window the UFW carve-out was built for the wrong gateway, a latent
DNS drop (#86 review). It now fails closed: an unaddressed bridge leaves
the persisted UFW rules exactly as they are (they survive boots on their
own, and nothing else in the script needs the gateway) and says so on
stderr; the next setup-host run or service restart converges them once
the bridge is addressed.
- **`revoke --purge` re-checks the incus-user state** — the purge removed
`/var/lib/incus/users/<uid>` without ever asserting its absence, the one
path its own absence block did not cover; and the stat now rides
`$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin,
so a bare `[ -d ]` answered "absent" for a directory that was there).
- **A wedged `$BINDIR/box` no longer blocks installing** — the old
no-op-if-installed check keyed off the symlink's existence OR the tree's,
so a stale symlink (or a half-removed tree) could fake "already installed"
forever. Installed-ness is now judged from `versions/<v>` itself; symlinks
are converged with `ln -sfn`, never trusted as the signal.
- 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
## 0.6.0 — 2026-07-18
### Added
- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin
runs `box grant <user>` and that user gets their own boxes on the same
hardened `boxnet`, seeing nobody else's; `box revoke <user>` takes it back
(`--purge` deletes their world, and asserts the absence). The tier rides
incus-user, whose defaults miss box's contract three measured ways (Debian
13 / Incus 6.0.4): a private _unhardened_ NAT bridge per user, snapshots
blocked, the `box-net` profile invisible — so grant is an idempotent
convergence: project narrowed to `boxnet` **and only boxnet** (listing the
private bridge too, the obvious fix, would keep an unhardened network one
`--network` flag away), snapshots allowed, the shipped profile installed
into their project. `box_tier()` (live credentials, argless `id -nG`)
drives the tier-aware surface: `expose` refuses honestly before any daemon
call, `setup-host` and `doctor` answer at the caller's tier. Rehearsed
end-to-end by `drill/multiuser.sh` (criteria an: confinement, lifecycle,
cross-user visibility, name collisions, the in-box isolation contract,
escape hatches, re-sync survival, revoke incl. the live-session case) —
54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review.
- **CI runs the multi-user rehearsal on a real Incus** — a second `rehearsal`
job stands up the full stack on the runner (setup-host, doctor, then
`multiuser.sh --container`), so every PR proves the tier's semantics
against a live daemon, not a mock. The VM trust boundary itself remains a
real-hardware ritual, like the full drill.
- **Global / root install** (#71) — run as root, box installs _once_ to
`/opt/box` (world-readable) with the `box` symlink on `/usr/local/bin`, so
every operator on a shared host runs the same tree. Per-user installs are
unchanged (`$HOME/.local`); `BOX_HOME`/`BOX_BIN` still override. A per-user
tree under `/root` is `0700` and unreadable to everyone else — the whole fleet
got `command not found` — so the root branch lands in a system location and
`chmod -R a+rX`'s it (read for files, +search on dirs), guarded on root. This
unblocks "rig installs box" (rig#24's `box` role).
- **CI + a test suite**`.github/workflows/ci.yml` (a `check` job: globstar
`shellcheck -x` over `bin/* **/*.sh`, then `bash test/cli.sh`) and `test/cli.sh`,
dependency-free and runnable by a non-root user with no Incus. It exercises the
`install.sh` DEST/BINDIR branch functionally (both tiers + `BOX_HOME`/`BOX_BIN`
overrides), the CLI contract, and grep-guards the daemon-gated invariants and
tmux in every template — the box was the repo with "no tests and no CI".
- 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** — the command table
dispatched `incus restore`, a subcommand that does not exist (Incus 6
spells it `incus snapshot restore`), so every restore died on "unknown
command". Found by #74's rehearsal exercising the full lifecycle as a
restricted user; fixed for every tier, and the rehearsal + a grep-guard in
`test/cli.sh` now hold it.
- **`box tmux` works on every template** (#65) — `box tmux` runs
`tmux new-session` _inside_ the box, but the templates did not install tmux, so
it failed with `tmux: command not found`. `tmux` is now in each template's
cloud-init package list (`blank`/`claude`/`codex`/`grok`).
- **`box setup-host` finishes in one run** (#63). When it had to add you to
`incus-admin` it stopped there and told you to re-login and re-run — an
`exit 0` that reported success having built none of the stack: no `boxnet`,
no ACL, no `box-net` profile, no firewall. It now re-execs itself under
`sg incus-admin` and completes in that one invocation. The membership check
was also asking the wrong question: `id -nG "$USER"` reads the group
database, which lists the group the moment `usermod` returns, so a
same-session re-run passed the check with credentials that still lacked the
group and died further down on a bare permission error from `incus`. Argless
`id -nG` asks the process what it actually holds.
- **`setup-host` works as root, with or without `sudo`** — every privileged
call was a hardcoded `sudo`, so on a minimal root image (no `sudo` package)
it died on `sudo: command not found` before doing anything. Privilege is now
resolved once: nothing at UID 0, `sudo` otherwise, and a clear error if
neither is possible. This is what made `install.sh`'s root path real rather
than nominal.
- **`setup-host` grants `incus-admin` to the human, not to root** — under
`sudo install.sh` it would have added `root` to the group: a no-op (UID 0
opens the socket regardless) that also left the actual user locked out of
their own boxes. It now derives the login user from `SUDO_USER`.
- **`box-firewall.service` now reports its state honestly** — the unit is
`Type=oneshot` and was missing `RemainAfterExit=yes`, so it went
`inactive (dead)` the instant it succeeded: a host whose isolation was
perfectly live read as one whose firewall unit had died. drill.sh sends you
to `systemctl status box-firewall` to diagnose exactly that, and
setup-host.sh's own comment already asserted the unit "is RemainAfterExit" —
it was not. Found by running the drill on a real host and mistrusting the
green: `nft list table bridge box` showed the drop live while the unit read
dead. `restart` was and remains correct either way.
- **`setup-host`'s apt calls can no longer hang** — a fresh cloud image has
`apt-daily`/`unattended-upgrades` holding the dpkg lock, and a plain
`apt-get install` waits on it silently and indefinitely. Now bounded
(`DPkg::Lock::Timeout=300`) and non-interactive, which matters because
`install.sh` runs it with nobody watching.
- `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
### Changed
- **`drill.sh` proves the new contract instead of masking it** — the drill ran
`setup-host` itself right after installing, so the stack existed by its own
hand and a run passed identically whether or not `install.sh` had done a
thing; a fresh run converged the stack three times, while the 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 touching the host, and
runs `setup-host` exactly once more — after the clean, which deliberately
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
hands sequencing back to the drill. Pre-setup tripwires now read _before_
`install.sh`, since that is what triggers setup now.
- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
prompts _"Install box?"_, then on a fresh host installs the tree and asks a
second question, _"Set up this machine as a box host now?"_, running the whole
isolation stack if you say yes (previously it only printed a warning and left
you a command, so the install reported success and `box new` died on a host
with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
- **`install.sh` never overwrites an existing install** — if box is already
installed it says so and changes nothing, so a stray re-run can no longer
clobber a working tree or rebuild the host stack under live boxes. Upgrading
is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
preserved first) and install fresh. This replaces the earlier version-diff
refusal with a simpler rule that dissolves the same class of errors. The
version-aware upgrade that migrates boxes instead is #67; a portable
`box export` so a box survives its own deletion is #70.
- `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
@ -658,61 +170,27 @@ installer retires — but nothing current carries the old name.
### Added
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes,
creds-free like every template. The template mechanic (image + user +
resources, never a network or a `security.*` key) now has three tenants
beside `blank`, and the drill mints all of them cold.
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only
door to a port inside a box, for seeing a dev server in your browser. The
listen side is always the host's `127.0.0.1` (no flag to widen it), the door
is per-port, `--list`/`--remove` manage it, and `box info` shows open
exposures — a box with a hole says so.
- **Inline resource overrides on `new`** — `--cpu <n> --memory <size>
--disk <size>` (#57). Resolution most-specific-first: flag > `BOX_CPU` /
`BOX_MEMORY` / `BOX_DISK` environment (the scripting form) > template
`box.env` > defaults. Values pass to Incus verbatim; resources are all a
flag can touch. `--from` refuses them — a clone carries its source's
resources.
- **Host lifecycle as verbs**`box setup-host`, `box teardown-host`, and
`box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
(`--box <n>` / `--all-boxes`, authed state preserved) and retires the legacy
bridge once empty (`--retire-legacy`).
- **The `.box/` recipe convention** — the agent-facing runbook folder a repo
can ship, renamed from `.claudebox/` (both spellings read).
- `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** — Incus defaults VMs to Secure Boot on,
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
shim signature" forever. Boxes now launch with `security.secureboot=false`;
the VM boundary, not boot attestation, is the box threat model.
- **`box expose` actually delivers packets** — a trilogy of drill-found
absences: the NAT proxy needs the box's boxnet lease pinned as a static
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not
the lease); a loopback-sourced packet needs `route_localnet` plus a
masquerade on the bridge to leave the host and be answerable; and the box's
replies need a `ct state established,related` accept ahead of the host
firewall's input drop, which was eating them statelessly. Boxes still
cannot initiate toward the host — a box-originated SYN is a NEW flow.
- **Firewall rules now converge on upgrade**`box-firewall.sh` rebuilds its
chains every run (add + flush + re-add) instead of skipping when they
exist, which had pinned every host to the rule set of the release that
first ran there.
- **Failed mints tell you why** — cloud-init failures print the box's own log
excerpts and leave the box up to inspect; a mint that never boots names the
likely cause (corrupt image, Secure Boot, GRUB hang) and ships a sanitized
console dump; the installer asserts it landed the ref it was asked for.
- **`grok` installs the binary it actually ships** — the installer was read,
not guessed at, and the CLI lands on the non-interactive PATH (same fix
class as codex).
- 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` (GitHub redirects the old one, `BOX_REPO` overrides).
- **The drill grew from 47 to 84 checks** — the expose door opened, exercised
and shut (with the contract re-probed around it), every template minted
cold, a faithful pre-0.4.0 box re-homed through `migrate-host`, and the
inline resource flags asserted (including their precedence over the
environment).
- 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

View file

@ -1,143 +1,83 @@
# Contributing
How change lands in this repo. The short version: PRs are born as drafts,
three reviewer bots take the first rounds, a human takes the last word — and
labels tell you where everything is without opening anything.
This repository is governed by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read
[`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first, then the role file it
selects. The files under `.ceremony/` are machine-managed and must never be
edited in place.
## The PR loop
Only triage mints issues. Everyone else opens or extends a discussion when
they find work outside an existing issue contract. Only humans merge.
1. **Fork and branch.** Contributors work from forks; upstream branches are
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
deserves one.
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
bots on purpose.
3. **When it's ready**: mark ready-for-review and request all three bots —
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
They poll roughly every 15 minutes.
4. **Rounds are answered whole.** Wait until all three have reviewed, then
answer the entire round in a **single reply**, push the fixes, and
re-request the bots that didn't approve. Prefer verification over
argument: a test settles what a comment thread can't.
5. **Reviews end in a verdict.** A reviewer — bot or human — either
**approves** or **requests changes**, never a bare comment. A
comment-only review is a non-verdict: it doesn't say whether the round
passed, and the state machine (and anyone scanning the board) has to
guess. The verdict carries *blockingness only*, the body carries the
feedback: non-blocking nits ride an **approval** and the author addresses
them at their discretion; anything blocking — including a question that
gates the verdict — is **request changes**, saying what unblocks it. The
reconciler treats a comment-only review as not-approved, so commenting
without a verdict only stalls the PR. The machine never reads review
bodies: when a comment-only reviewer's line is really an agreement, that
judgment belongs to the **author** — escalate by requesting the
maintainer's review (step 6), and the reconciler flips the label on that
request, because an explicit request is a fact it can trust.
6. **When the round passes, the author hands the PR to the maintainer** by
requesting their review — that request is what flips `state:needs-human`.
With three formal head-current approvals the labels workflow requests it
automatically; when part of the panel is comment-only, reading their
agreement is the author's judgment, so the author makes the request.
7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
## Review panel
The review panel is:
- `claude-bot-andresmgsl`
- `codex-bot-andresmgsl`
- `grok-bot-andresmgsl`
- `kimi-bot-andresmgsl`
Every PR needs a current-head verdict from the whole panel minus its author.
`dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain
invisible to the panel; when ready, request every eligible reviewer.
## Code and verification
- Bash executables use `set -euo pipefail`; test harnesses use `set -u`
because they assert failing commands.
- Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`;
CI also runs the Incus multi-user rehearsal.
- Match whole versions: `0.7.0` must never match `0.7.0-rc1`.
- Comments preserve the incident that bought a rule, including its issue
number.
## Changelog
Every behavior-changing PR adds one concise line under `## Unreleased`,
above the shipped heading below it. Cite the issue or PR. Never replace or
duplicate a shipped heading; the shared armed and monotonic guards enforce
both halves of this rule.
## Releases
A release is a PR, and merging it ships it
([#96](https://github.com/heavy-duty/box/issues/96), building on
[#83](https://github.com/heavy-duty/box/issues/83)):
The release ceremony, merge and tag doors, version stamps, guard semantics,
and recovery paths are defined by
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony/blob/0.1.0/README.md).
Box pins the shared machinery and doctrine at `0.1.0`.
1. **The release PR**`release: X.Y.Z`, labeled `release` — bumps `VERSION`
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
date (feature PRs land their changelog entry as part of the PR, so the
section is already written).
Box uses the `file` version backend and has no artifact hook: for this
pure-Bash tree, GitHubs source tarball for the tag is the package, and
`install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and
`drills/<version>.md` remain box-owned release inputs.
**Stamping is two edits, not one — the second is re-arming.** After
rewriting `## Unreleased` into `## X.Y.Z — DATE`, put an **empty
`## Unreleased` back at the top**, immediately above the section you just
stamped:
### What a box drill proves
```markdown
## Unreleased
The box drill is the 85-probe VM isolation contract: it exercises the trust
boundary on real hardware. The lighter Incus container rehearsal in CI proves
the tier mechanics but cannot substitute for that boundary measurement. The
record format and operating procedure live in [drills/README.md](drills/README.md).
## 0.7.1 — 2026-07-19
`drills/<version>.md` and [`drill/RUNS.md`](drill/RUNS.md) are deliberately
different artifacts. The former is per-release evidence read by the release
guard; the latter is the harnesss ongoing run log and lore. Updating one
never satisfies the purpose of the other.
### Fixed
...
```
The family drills are independent and may run in any order. Each pins the
same fixed candidate refs: rigs drill uses the candidate box ref, while
boxs drill mints with the candidate rig ref. Static refs dissolve the
box↔rig runtime recursion; no repository needs to release first.
Not cosmetic, and not deferrable to the next PR that happens to need it.
Between the stamp and the next re-creation of that heading, `main` has no
`## Unreleased`. A PR authored *before* the release wrote its entry under
that heading; with the heading gone, git lands the entry under whatever
now occupies the position — **the section that just shipped** — and it
merges **cleanly**, no conflict, no signal. The changelog then credits a
released version with a change it does not contain, and nothing but a
human reading the file will ever say so
([#108](https://github.com/heavy-duty/box/issues/108); confirmed in the
sibling repo as
[heavy-duty/rig#66](https://github.com/heavy-duty/rig/issues/66)).
A known gap remains from box#81: released box templates still default
`RIG_REF` to `main`, so a later mint may consume a rig revision other than
the one drilled. This conversion does not change that behavior or claim the
gap is closed.
CI enforces the arming rule with
[.github/scripts/changelog-armed.sh](.github/scripts/changelog-armed.sh),
keyed on `VERSION`: a `-dev` tree must carry `## Unreleased` on top; a
bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may
carry either `## Unreleased` or its own stamped section. That is why the
guard cannot simply demand `## Unreleased` unconditionally — the
unconditional form is false on the ceremony PR's own tree and makes the
release unshippable, which is why rig and cast both reverted it. The
practical consequence: forgetting to re-arm does **not** block the release
PR, it turns `main` red on the very next push — the automatic `-dev` bump
the release itself makes. Do it in the ceremony PR and main is never
disarmed at all.
## Scope labels
This PR is where the release ritual hangs:
the full drill on real hardware, recorded in
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
PR, a release still proves the boundary.
2. **The maintainer's merge IS the release.**
[release.yml](.github/workflows/release.yml) fires on the merged,
`release`-labeled PR and asserts before creating anything: `VERSION` at
the merge commit is non-`-dev` **and changed in this PR** (the `-dev`
interlock — a mislabeled ordinary PR fails loudly and creates nothing),
the version's `CHANGELOG.md` section extracts non-empty, and no tag or
release exists for it yet. Then, in the same job, it tags the merge
commit bare `X.Y.Z` (no `v` prefix, the `0.6.0` precedent) and publishes
the GitHub release with that section as the body. No assets — the source
tarball for the tag is the package, and `install.sh` downloads exactly
that.
*Manual fallback/backfill*: the tag-push path stays. Tagging the merge
commit bare `X.Y.Z` by hand and pushing the tag still publishes the same
way (release.yml asserts the tag names the tree's own `VERSION`) — for
backfills, or the day the merge path is red.
3. **The release re-arms main itself**: the same workflow run bumps
`VERSION` to `X.Y.(Z+1)-dev` and pushes the commit straight to main —
no follow-up PR (it opens one only if branch protection refuses the
direct push, and says so loudly). Not cosmetic — the versioned layout
names install trees after `VERSION`, so a `main` install without the
bump would land in `versions/X.Y.Z` and impersonate the release just
cut. On the *manual* tag path the bump stays yours: open the one-line
PR after publishing.
## Labels — who sets what
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
who sets each kind — most of it is machinery, and hand-moving a
machine-owned label just gets corrected on the next pass:
| Labels | Set by |
|---|---|
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. |
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
| `blocked`, `release` | you — automation never guesses intent. |
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
## Issues
Give issues the same care as PR titles: say the surface in the title, apply a
`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when
you open one, and `blocked` when it waits on something — that is what keeps
the board navigable as the issue count grows.
- `scope:cli``bin/box`, the command surface
- `scope:installer``install.sh`, versioned installs, upgrade/uninstall
- `scope:host` — host setup, teardown, firewall, and isolation stack
- `scope:tiers` — grant/revoke and multi-user boundaries
- `scope:templates` — template and profile seeds
- `scope:drill` — rehearsals, doctor, and run evidence

View file

@ -1,87 +0,0 @@
# Labels
How this repo uses GitHub labels. The taxonomy is shared across the
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
because it names this repo's actual surfaces.
## State — who is the ball with? (PRs, exactly one)
Every open PR carries exactly one `state:` label, and it answers the only
question a board scan actually asks: *who is this PR waiting on?* The states
mirror the review loop this repo runs — PRs open as drafts, three reviewer
bots pick up ready PRs with reviews requested, each round is answered in a
single reply, and a human takes the final review.
| Label | Color | Waiting on | Enters when | Leaves when |
|---|---|---|---|---|
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
first means *poke the bots*, staleness in the second means *the agent dropped
the ball*. Collapsing them loses exactly the information a sweep needs.
## Cross-cutting (PRs and issues)
| Label | Color | Meaning |
|---|---|---|
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
| Label | Covers |
|---|---|
| `scope:cli` | `bin/box` — the command surface itself |
| `scope:installer` | `install.sh`, the versioned install layout, upgrade/uninstall |
| `scope:host` | `host/` — setup-host, teardown, the firewall and isolation stack |
| `scope:tiers` | the restricted tier — grant/revoke, multi-user semantics |
| `scope:templates` | `templates/` — the box seeds |
| `scope:drill` | `drill/` — the rehearsals, doctor, RUNS.md |
## Issue types
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
would just say the same thing twice, drifting apart eventually.
## Maintenance
State labels are written by automation, never by hand. Every state above is
derivable from GitHub's own facts — the draft flag, requested reviewers,
review states, push timestamps — so the labels workflow
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
A hand-moved label is a lie waiting to happen; the workflow asserts the
effective state instead. `scope:` labels on PRs are applied from the changed
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
The same workflow bootstraps the taxonomy: a manual dispatch creates any
missing label idempotently. To create them by hand (needs push access):
```sh
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
gh label create "scope:cli" --color C5DEF5 --description "bin/box — the command surface" --force
gh label create "scope:installer" --color C5DEF5 --description "install.sh, versioned installs, upgrade/uninstall" --force
gh label create "scope:host" --color C5DEF5 --description "host/ — setup, teardown, firewall, isolation stack" --force
gh label create "scope:tiers" --color C5DEF5 --description "restricted tier — grant/revoke, multi-user" --force
gh label create "scope:templates" --color C5DEF5 --description "templates/ — the box seeds" --force
gh label create "scope:drill" --color C5DEF5 --description "drill/ — rehearsals, doctor, RUNS.md" --force
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
# that, so this block converges on re-run instead of erroring after first success.
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
gh label delete "$L" --yes 2>/dev/null || true
done
```

201
README.md
View file

@ -2,8 +2,8 @@
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
network-isolated Incus box from a **template**; the coding-agent templates
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
(OpenAI Codex), `grok-box` (xAI Grok), `kimi-box` (Moonshot Kimi) — **box mints, [rig](https://github.com/heavy-duty/rig)
converges**: the template is a thin seed, and the agent tooling lands via a
creds-free `rig bootstrap` role auto-run at mint
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
@ -217,13 +217,14 @@ VM boundary itself is proven on real hardware, like the rest of the drill).
## Quick start
```sh
box new --name work --template claude # a creds-free coding-agent box (~10 min cold)
box new --name work --template claude-box # a creds-free coding-agent box (~10 min cold)
box shell work # enter as the template's user
```
Pick whichever coding-agent template you like — `claude`, `codex`, `grok` — or
`blank` for none. Inside the box, authenticate as needed. The `claude` template
looks like this; the others follow the same shape with their own login step:
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
`grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
`claude-box` template looks like this; the others follow the same shape with
their own login step:
```sh
claude # then run /login — copy the URL (press c), open it
@ -239,12 +240,13 @@ No coding agent is special — each is one template among several, and adding
another is just another directory. What ships today:
| Template | What it becomes |
| --------- | ---------------------------------------------------------------------- |
| ------------- | ------------------------------------------------------------------ |
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
| `claude` | Claude Code, creds-free — where this project started |
| `codex` | OpenAI Codex CLI, creds-free |
| `grok` | xAI Grok CLI, creds-free |
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
| `claude-box` | Claude Code, creds-free — where this project started |
| `codex-box` | OpenAI Codex CLI, creds-free |
| `grok-box` | xAI Grok CLI, creds-free |
| `kimi-box` | Moonshot Kimi CLI, creds-free |
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
**Templates are thin seeds; rig does the becoming**
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
@ -255,8 +257,11 @@ pin tokens below). The seed is deliberately small — the tenant user, tmux,
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
joins a tailnet or admits credentials — and after cloud-init settles, box
auto-runs the template's **creds-free** tenant role inside the guest
(`rig bootstrap claude` / `codex` / `grok` / `staging`,
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI,
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `kimi-box` / `staging-box`,
[rig#31](https://github.com/heavy-duty/rig/issues/31); the roles carry a
family suffix — `-box` for box tenants, `-server` for fleet machines — and a
template is named for the role it converges,
[rig#76](https://github.com/heavy-duty/rig/issues/76)). The agent CLI,
docker, the server posture and the agent-context file all come from that
role — convergent and idempotent, so the same command re-run later converges
an *existing* box to a newer spec (`box shell <box>` →
@ -265,9 +270,9 @@ an *existing* box to a newer spec (`box shell <box>` →
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
from rig's roles, instead of copy-pasted per template.
**Anything that joins or admits stays operator-run.** The `staging` box's
tailnet workload join holds a pre-auth key, so box only prints it as the
next step — `box shell <name>`, then `sudo rig bootstrap workload` — and
**Anything that joins or admits stays operator-run.** The `staging-box`
tenant's tailnet workload join holds a pre-auth key, so box only prints it as the
next step — `box shell <name>`, then `sudo rig bootstrap workload-server` — and
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
split, kept).
@ -279,9 +284,9 @@ carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
environment:
```sh
box new --name work --template claude # heavy-duty/rig @ main
box new --name work --template claude-box # heavy-duty/rig @ main
RIG_REPO=you/rig RIG_REF=my-branch \
box new --name trial --template claude # a rig branch under review
box new --name trial --template claude-box # a rig branch under review
```
Both directions of that edge track `main` unpinned today — said honestly,
@ -328,6 +333,84 @@ refuses rather than assuming yes).
Forgotten what you called a checkpoint? `box info work` prints the box's
snapshot labels and the `--from` line to clone one.
### `pristine` — the one checkpoint box takes for you
Every fresh mint marks a snapshot called `pristine`
([#104](https://github.com/heavy-duty/box/issues/104)) at the one moment it
is true: **after cloud-init, before `rig bootstrap` converges the tenant
role.** At that instant the guest is pristine Debian plus box's thin seed
(the user, tmux, rig) and nothing else — the state
[heavy-duty/rig#62](https://github.com/heavy-duty/rig/issues/62) calls "back
to pristine Debian". It exists for a few seconds on every mint, so box
captures it rather than asking you to be quick.
```sh
box restore work pristine # undo the tenant role and everything since
```
That is a complete undo for every tenant role: everything `rig bootstrap
claude|codex|grok|staging` does — docker, node, the agent CLI, the
agent-context file, the role marker — is box-local and file-shaped, so a
filesystem rollback reaches all of it, without paying a ~10-minute re-mint.
Three things it deliberately does not do:
- **It is an undo, not a backup.** Snapshots die with their box: `box rm`
deletes a box _and_ every snapshot it has. `box export` is the only state
that outlives the box — see below.
- **It cannot reach off-box state.** A tailnet join, a GitHub runner
registration, a pushed commit: those are records held somewhere else, and
no filesystem rollback undoes them (rig#62 covers those separately).
- **A `--from` clone gets no `pristine` of its own.** A clone skips
cloud-init and rig entirely, so it has no pristine moment to capture, and
box will not label a source's worked-in state as one. Cloning a _box_
inherits the source's snapshots (a real `pristine` among them, if the
source had one); cloning a _snapshot_ starts with none. `box new` says
which of the two you got.
On a host whose storage pool uses the `dir` driver, a snapshot is a full
multi-GB copy rather than a near-free copy-on-write mark, so the mint
**skips** `pristine` and says so loudly — take it by hand with `box snapshot
<box> pristine` if you want it anyway. btrfs is what `box setup-host`
installs by default precisely so snapshots are cheap. `BOX_SNAPSHOT_PRISTINE=0`
skips the mark on any host.
### `bootstrapped` — the same undo, one step later
A mint whose template names a bootstrap role marks a second snapshot,
`bootstrapped` ([#130](https://github.com/heavy-duty/box/issues/130)), once
`rig bootstrap` has run and box has **watched it succeed**. That is the box
converged and not yet touched — the state you actually wanted back most of
the time.
```sh
box restore work bootstrapped # keep the tenant role, undo what you did to it
box restore work pristine # throw the tenant role away too
```
Same rules as `pristine`: default on, never fatal, skipped loudly on a `dir`
pool (with two marks that disk cost is twice the size, so a CoW-less host is
not asked to pay it), and `BOX_SNAPSHOT_BOOTSTRAPPED=0` skips it anywhere.
The same two caveats apply, unchanged: it **dies with the box** on `box rm`
(`box export` is the durable path), and it **cannot reach off-box state**
a tailnet device record, a runner registration (rig#62).
**Read the label in one direction only.** Its _presence_ means the mint-time
hook converged and nothing has touched the box since. Its _absence_ means
nothing at all, because box only marks a hook it watched:
- A **blank** box runs no hook, so there is no convergence to mark. It gets
none — `pristine` and `bootstrapped` would be the same disk state at twice
the cost, and a label claiming a convergence that never happened is worse
than no label.
- A box whose hook **failed** gets none either. box tells you to re-run the
role by hand through `box shell`, and a by-hand run happens in a shell box
does not watch — so box hands you `box snapshot <box> bootstrapped` to take
at the moment it is true, rather than inventing a fact.
- A `dir`-pool host and `BOX_SNAPSHOT_BOOTSTRAPPED=0` both skip it.
`box info <box>` is what actually tells you which labels a box has.
## Survive the host: `box export` / `box import`
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
@ -386,13 +469,17 @@ the door is per-port, punched and removable at runtime.
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
box templates # list the templates this install can mint
box list # list your boxes
box info <box> # one box: state, IP, exposures, snapshot labels
box info <box> # one box: state, IP, exposures, provenance, snapshots
box shell <box> # enter as the template's user
box exec <box> -- <cmd...> # run a command in the box
box tmux <box> [session] # attach/create a tmux session — survives disconnects
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
box restore <box> <snap> [--force]
# roll back to a snapshot — destructive, asks first
# 'pristine' is auto-marked at mint: back to
# pristine Debian + box's seed, before rig ran
# 'bootstrapped' too, if a rig hook converged:
# the role kept, everything since undone
box export <box> [<file>] [--instance-only]
# one portable file (snapshots incl.) — survives rm & host
box import <file> [--name <box>]
@ -423,6 +510,82 @@ clones an existing box or snapshot. VM mode (`--vm`, the default where
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
dev/test only.
## What minted this box: `box info`
A box outlives the release that minted it, the template that shaped it and the
image build it came from — and until
[#103](https://github.com/heavy-duty/box/issues/103) it recorded none of them.
There is no host-side per-box store; the Incus instance config _is_ the
database, so a fact not written at mint time is simply gone. `box new` now
stamps what it knew, and `box info` reads it back:
```
NAME work
STATE RUNNING
TYPE VM
IPV4 10.x.x.x
MINTED 2026-07-19T14:22:07Z by box 0.8.1
TEMPLATE claude (user claude, role claude)
IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b…
MODE vm (asked: auto)
RIG heavy-duty/rig@main
ORIGIN mint
```
The image line carries both halves on purpose: the template names an
_unpinned alias on a moving remote_, so what it resolved to at that mint is the
only reproducible fact. `box info --json` carries every key verbatim — they
ride `incus list --format json` in `config`.
**A clone re-stamps.** `incus copy` preserves `user.*` keys, so a clone inherits
its source's template and user for free — but inheriting the mint stamp would
not make it stale, it would make it **false**: the clone was not present at that
mint. `box new --from` therefore re-stamps the four keys that describe _this_
instance's coming into being (`ORIGIN clone of work/authed`, a fresh time, the
box version that cloned it) and leaves the lineage keys alone, because the
clone's disk genuinely did come from that image, template and role. `origin.from`
records one hop: a clone of a clone names its parent, not its grandparent.
**An import records the trip, and rewrites nothing**
([#131](https://github.com/heavy-duty/box/issues/131)). Everything `incus
import` restores is the _artifact's_ truth, so an imported box keeps its mint
stamp verbatim — the mint time, the box version, the image and the origin
belong to the originating host and survive the trip on purpose. What `box
import` adds is the one fact the artifact cannot carry: that the trip happened.
```
MINTED 2026-06-01T10:00:00Z by box 0.7.0
IMPORTED 2026-07-20T09:14:03Z by box 0.8.1 (the mint above predates it)
ORIGIN clone of work/authed
```
It is **not** `origin=import`, and the difference is the whole point. `origin`
answers how the instance came into _being_ — mint or clone — and overwriting it
would destroy that: the clone above would come back claiming to be an import,
with nothing left saying it was ever a clone and an `origin.from` naming a
lineage no key explains. The import is a _third_ fact, orthogonal to the first
two, so it takes its own keys and leaves every other one alone.
The `IMPORTED` line sits directly under `MINTED` because that adjacency is what
stops the mint time being misread as this host's. Note what it does not claim:
box has no record of _which_ host minted the box, and a box can be exported and
re-imported onto the same host (that is the upgrade flow above), so the line
states only the ordering — the one thing box actually knows.
**A box can make the trip more than once**, and both ends are kept: the first
import is pinned forever, the latest is refreshed on every arrival, and a count
says how many. Last-wins alone would erase the evidence of the earlier trips,
which is the same mistake `origin=import` makes one level up. (The shape
follows [heavy-duty/rig#61](https://github.com/heavy-duty/rig/issues/61)'s
manifest: a birth pair plus a latest pair.)
**Boxes minted before this stamp existed keep working**, under this verb and
every other — they render as a box with blanks and say `MINTED (not recorded)`
rather than erroring. `user.box.schema` names the stamp's _shape_ (an integer,
not the box version) so a box minted by a later release reads back on an older
box as "here is what I understand, and there is more I don't".
## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes

View file

@ -1 +1 @@
0.8.0
0.9.1-dev

616
bin/box
View file

@ -9,10 +9,27 @@ root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
cpu=""; memory=""; disk=""; instance_only=0
inst="" # the resolved Incus instance, set by the 'box' precondition
# Labels snapshot_mark actually CREATED this run, space-separated. Every path
# through snapshot_mark returns 0 on purpose — the never-fatal contract — so
# the exit status cannot tell a mark that was taken from one that was skipped
# (dir pool, knob=0) or refused (incus said no). Anything that offers the
# operator a mark must ask this, not assume the call happened.
marks=""
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
# The tree's own version, read in ONE place. 'box --version' says it out loud;
# the mint stamp (#103) writes it onto every instance box creates, so a box can
# still name the release that made it long after that release is history.
box_version() { cat "$root/VERSION" 2>/dev/null || echo unknown; }
version() { echo "box $(box_version) ($root)"; }
# The SHAPE of the mint stamp, not the box version — an integer that changes
# only when a key is removed or repurposed, never when one is added (a reader
# that does not know a key simply does not print it). Absent means pre-stamp:
# every box minted before #103 has no schema key at all, and must keep working
# under every verb, which is the same promise 'user.claudebox' carries.
BOX_STAMP_SCHEMA=1
# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
# what the kernel will present when incus opens the socket), never from the
@ -73,7 +90,7 @@ CMDS=(
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
"templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^"
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
"info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
@ -185,7 +202,7 @@ OPTIONS
EXAMPLES
# mint a claude box and log in inside it — the tool never handles your token
box new --name work --template claude
box new --name work --template claude-box
box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it
@ -242,13 +259,13 @@ help_cmd() {
case "$1" in
new) cat <<'EOF'
Mint a box. Without --from, launches a fresh box from a template (default:
blank — bare Debian 13, nobody home; --template claude gets Claude Code
blank — bare Debian 13, nobody home; --template claude-box gets Claude Code
installed, creds-free, ~10 min cold). With --from, clones an existing box or
one of its snapshots — login state, git creds and clones carry over,
isolation is preserved, and the clone knows its template's user without
being told.
A tenant template (claude, codex, grok, staging) is a THIN seed — the user,
A tenant template (claude-box, codex-box, grok-box, kimi-box, staging-box) is a THIN seed — the user,
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
role inside it ('rig bootstrap <role>', rig#31): that role installs the
agent CLI / server posture and the agent-context file. rig is preinstalled
@ -285,9 +302,40 @@ there is no flag for a network or a security key, on purpose.
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
a launch that overruns it fails loudly instead of hanging forever (#93).
Every fresh mint marks a snapshot called 'pristine' (#104) at the one moment
it is true: after cloud-init, before rig converges anything. That is the
guest as pristine Debian plus box's thin seed, and it is what
box restore <box> pristine
rolls back to — undoing the tenant role and everything done since, without
paying a re-mint. It is an UNDO, not a backup: 'box rm' deletes a box and
every snapshot it has, so 'box export' remains the only state that outlives
the box. A --from clone takes no 'pristine' of its own — it has no pristine
moment; it inherits the source's snapshots (copying a box) or starts with
none (copying a snapshot). On a 'dir'-backend host, where a snapshot is a
full copy rather than a CoW mark, the mint skips it and says so.
BOX_SNAPSHOT_PRISTINE=0 skips it anywhere.
A mint whose template names a bootstrap role marks a second snapshot,
'bootstrapped' (#130), once the rig hook has run and box has watched it
SUCCEED — the box converged and not yet touched. It is the cheaper undo:
'pristine' throws the tenant role away, 'bootstrapped' keeps it and throws
away only what you did afterwards. Same rules: never fatal, skipped on a
'dir' pool, and BOX_SNAPSHOT_BOOTSTRAPPED=0 skips it anywhere.
It is deliberately NOT unconditional the way 'pristine' is. A blank box
runs no hook, so there is no convergence to mark and it gets none — the
label marks an event, not a mint. And if the hook FAILS, box tells you to
re-run it by hand and takes no mark afterwards: a by-hand run happens in a
shell box does not watch, so box will not label a convergence it never saw.
It names 'box snapshot <box> bootstrapped' at that moment instead. So read
the label one way only: its PRESENCE means the hook converged untouched;
its ABSENCE means nothing at all.
box new --name scratch # blank, the default
box new --name work --template claude
box new --name lean --template claude --cpu 2 --memory 3GiB
box new --name work --template claude-box
box new --name lean --template claude-box --cpu 2 --memory 3GiB
box new --name feature --from work/authed
EOF
;;
@ -366,6 +414,19 @@ label defaults to manual-<epoch>; 'box info <box>' shows the labels you
have.
box snapshot work authed
Two labels are taken for you (see 'box help restore'): every fresh mint
marks 'pristine' after cloud-init and before rig's tenant role runs (#104),
and a mint with a bootstrap role marks 'bootstrapped' after box has watched
that role converge (#130). 'bootstrapped' is skipped where there was no hook
to watch — a blank box, or a hook box told you to re-run by hand — so its
absence proves nothing. Take it yourself in that case:
box snapshot work bootstrapped
Snapshots do not outlive their box: 'box rm' deletes a box and every
snapshot it has. They are an undo, not a backup — 'box export' is the
durable path.
EOF
;;
restore) cat <<'EOF'
@ -374,6 +435,41 @@ that snapshot is lost. 'box info <box>' lists the labels.
box restore work authed
box restore work authed --force
box restore work pristine
'pristine' is the one label box takes for you (#104): every fresh mint marks
it after cloud-init and before rig converges the tenant role, so it is the
guest as pristine Debian plus box's thin seed. Restoring it is the answer to
"I broke this box" for every tenant role — everything 'rig bootstrap
<tenant>' does is box-local and file-shaped, so a filesystem rollback undoes
all of it, without a ~10-minute re-mint.
What it does NOT undo is anything that left the box: a tailnet join, a
GitHub runner registration, a pushed commit. Those are off-box records a
filesystem rollback cannot reach (heavy-duty/rig#62). And 'pristine' is not
a backup — it dies with the box on 'box rm'; 'box export' is what survives.
A box minted with --from has no 'pristine' of its own unless it inherited
one from its source, because a clone has no pristine moment to capture.
'bootstrapped' is the other label box takes for you (#130), and it is the
one you probably want more often: it is the box AFTER the tenant role
converged and before you touched it.
box restore work bootstrapped # keep the role, undo what you did to it
box restore work pristine # throw the role away too
The same two caveats apply to it exactly as written above — it dies with the
box on 'box rm' ('box export' is what survives), and it cannot undo anything
that left the box, a tailnet join or a runner registration among them
(heavy-duty/rig#62).
Its absence proves NOTHING. box takes it only after a rig hook it ran and
watched succeed, so a blank box has none, a 'dir'-pool host has none, and a
box whose hook failed and was re-run by hand through 'box shell' has none
either — box will not label a convergence it did not watch. 'box info <box>'
tells you which labels a box actually has; take the mark yourself any time
with 'box snapshot <box> bootstrapped'.
Destructive, so it asks first — naming the snapshot it is rolling back to,
because the whole risk is picking the wrong label. --force (-f) skips the
@ -427,6 +523,17 @@ snapshots). What box then re-stamps is THIS host's truth:
differs, the same move migrate-host makes re-homing a legacy box
· a fresh machine identity (reset_identity, exactly like a clone), so its
DHCP lease can never collide with the box it was exported from
· the import EVENT — when the box landed here, and by which box version
The mint stamp is NOT rewritten. The artifact's mint time, box version, image,
template and origin (mint or clone) are the originating host's truth and
survive the trip intact — 'box info' shows them unchanged, with an IMPORTED
line under them saying the mint predates the arrival. In particular the import
does not overwrite 'origin': a clone that was exported and imported still
reads as a clone, and still names the box it was cloned from.
A box that makes the trip more than once keeps both ends: the first import is
pinned forever, the latest is refreshed each time, and a count says how many.
Auth state rides along by design — the artifact carries the box's whole disk,
logins included. That is the point (log in once, keep the file), and the same
@ -473,7 +580,7 @@ The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
a VM's forwarder connects to the box over the network. Inside an isolated box
that is safe: nothing but this proxy can reach the port.
box new --name web --template claude
box new --name web --template claude-box
box shell web # inside: run a dev server on 0.0.0.0:3000
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
EOF
@ -1002,8 +1109,16 @@ load_template() {
# environment must die on the host, never execute in the guest. bash's =~
# anchors to the whole string — a multi-line value cannot sneak one clean
# line past it the way a line-oriented grep would.
# The rig pin, resolved from the mint environment, in ONE place: render_userdata
# substitutes it into the seed, and the mint stamp (#103) records it onto the
# instance. Two spellings of the same default would eventually disagree, and a
# stamp that disagrees with the seed is worse than no stamp at all.
rig_repo() { printf '%s\n' "${RIG_REPO:-heavy-duty/rig}"; }
rig_ref() { printf '%s\n' "${RIG_REF:-main}"; }
render_userdata() {
local f="$1" repo="${RIG_REPO:-heavy-duty/rig}" ref="${RIG_REF:-main}" data
local f="$1" repo data ref
repo="$(rig_repo)"; ref="$(rig_ref)"
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|| die "RIG_REPO must look like owner/repo: $repo"
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
@ -1026,6 +1141,144 @@ cmd_templates() {
echo "mint one: box new --name <box> --template <template>"
}
# When this instance came into being. A timestamp in a CONVERGENT file would be
# churn — the same run writing a different byte every time — but a mint is not
# convergent: it happens exactly once, to exactly one instance, and is never
# re-run against it. UTC and ISO 8601 so it sorts as a string and means the same
# thing on every host that reads it back.
mint_time() { date -u +%Y-%m-%dT%H:%M:%SZ; }
# Which storage driver is under this box? The placement contract answers it:
# every box launches with --profile box-net, and that profile hardcodes the
# root disk's pool (profiles/box-net.yaml), so the pool is not a guess — it is
# read back from the profile that actually placed the instance, and only falls
# back to the name setup-host.sh creates. Two probes for the driver because
# the tiers see storage differently: 'incus storage show' is the admin read
# (the same line setup-host.sh:297 prints), 'incus storage list --format csv'
# is the one a restricted user's incus-user proxy is likelier to answer.
# Prints the driver, or nothing when neither probe answers.
storage_driver() {
local pool driver
pool="$(incus profile device get box-net root pool 2>/dev/null || true)"
[ -n "$pool" ] || pool=default
# '|| true' on all three probes, not just the first. A refusal here is an
# ANSWER ("this tier cannot read the pool"), never an error — this function
# must never be the thing that kills a mint. Today the two pipelines are
# safe only by accident: command substitution strips errexit, so a failing
# 'incus storage show' under pipefail falls through to the fallback instead
# of aborting. Add 'shopt -s inherit_errexit' to this file — exactly the
# robustness tweak #107 describes sailing through review — and that accident
# reverses into a fatal abort mid-mint, inside the function whose contract
# is NEVER fatal. Make the three probes read alike and depend on nothing.
driver="$(incus storage show "$pool" 2>/dev/null | awk '/^driver:/ {print $2; exit}' || true)"
[ -n "$driver" ] || driver="$(incus storage list --format csv 2>/dev/null \
| awk -F, -v p="$pool" '$1 == p {print $2; exit}' || true)"
printf '%s' "$driver"
}
# The shared policy behind every mark box takes for you (#104, #130):
# read the opt-out, read the storage driver, refuse to double the disk cost on
# a pool with no copy-on-write, and NEVER fail a mint over a checkpoint.
#
# It lives once on purpose. What generalises here is the MECHANISM, and the
# never-fatal contract inside it is exactly the property that must not be got
# subtly different in two places — one function, one place to review it, one
# place a future third mark inherits it from. What does NOT generalise is the
# PROSE: 'pristine' and 'bootstrapped' name different moments and are worth
# different sentences, so each wrapper below owns its own narration and passes
# it in. Two thin wrappers over one policy is the honest split; a single
# stringly-parameterised function taking five sentences would not be.
#
# The opt-out variable name is DERIVED from the label (BOX_SNAPSHOT_PRISTINE,
# BOX_SNAPSHOT_BOOTSTRAPPED) so the message can never drift from the knob the
# operator actually has to set.
#
# snapshot_mark <instance> <name> <label> <enabled> <what-this-state-is>
snapshot_mark() {
local instance="$1" name="$2" label="$3" enabled="$4" what="$5" driver knob
knob="BOX_SNAPSHOT_$(printf '%s' "$label" | tr '[:lower:]' '[:upper:]')"
if [ "$enabled" = 0 ]; then
echo "box: skipping the '$label' snapshot ($knob=0)."
return 0
fi
driver="$(storage_driver)"
# On btrfs (the deliberate default, host/setup-host.sh:261-298) this costs
# kilobytes until the guest diverges. On the documented 'dir' fallback
# (:294) there is no CoW at all: the snapshot is a full copy of the root,
# several GB and minutes, on EVERY mint. Skip it there — but loudly, and
# naming the by-hand command, because a silent skip teaches an operator to
# expect a mark that will not be there. With two marks the objection is
# twice the size, so the skip covers both: a 'dir' host is not asked to pay
# for one full root copy per mint, let alone two.
if [ "$driver" = dir ]; then
echo "box: NOT taking the '$label' snapshot — this host's storage pool is the 'dir'" >&2
echo "box: driver, which has no copy-on-write, so the mark would be a full multi-GB copy" >&2
echo "box: of the root disk on every mint rather than the near-free btrfs one." >&2
echo "box: take it by hand if you want it here: box snapshot $name $label" >&2
echo "box: or fix the host (btrfs is what 'box setup-host' installs by default)." >&2
return 0
fi
# Neither probe answered — an unusual host, or a tier that cannot read the
# pool. Proceed, and say what was assumed. The two mistakes are not
# symmetric: a snapshot taken on a 'dir' host wastes disk the operator can
# see in 'box info' and delete, while a snapshot NOT taken is the moment
# gone for good, which is the whole thing this exists to prevent.
[ -n "$driver" ] || echo "box: could not read the storage driver — taking '$label' anyway," \
"assuming copy-on-write ($knob=0 to skip)." >&2
echo "box: marking '$label' — $what"
if ! incus snapshot create "$instance" "$label" </dev/null; then
# An undo is not the mint's product: a mint that worked must not be
# failed by a checkpoint that didn't.
echo "box: WARNING — could not take the '$label' snapshot (incus said why above)." >&2
echo "box: the box is fine; it just has no $label mark. The mint continues." >&2
return 0
fi
marks="$marks $label"
echo "box: roll back to it any time with: box restore $name $label"
}
# Did THIS run actually create <label>? The only honest source for a message
# that offers a rollback: see 'marks' above for why the return status cannot
# answer it.
mark_taken() { case " $marks " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
# Take the 'pristine' snapshot, or say loudly why not. Default ON: the value
# of this mark only exists if it is already there on the bad day, and nobody
# takes it by hand at the one moment it is true. The escape hatch is the
# BOX_LAUNCH_TIMEOUT shape — an environment knob, not another flag on 'new'.
snapshot_pristine() {
snapshot_mark "$1" "$2" pristine "${BOX_SNAPSHOT_PRISTINE:-1}" \
"the guest after cloud-init, before rig converges anything."
}
# Take the 'bootstrapped' snapshot (#130), after the rig hook box RAN and
# WATCHED SUCCEED. Same default, same opt-out shape, same never-fatal
# contract as 'pristine' — it is the same policy, one function up.
#
# The label is deliberately CONDITIONAL where 'pristine' is unconditional, and
# the asymmetry is the point rather than an oversight. 'pristine' marks a
# MOMENT that every fresh mint has, so it is taken on every fresh mint.
# 'bootstrapped' marks an EVENT — "a tenant role converged, and nothing has
# happened since" — and a blank box has no such event. Minting the label there
# anyway would either duplicate 'pristine' byte for byte (nothing ran between
# them, so it is the same disk state at twice the cost, which is precisely the
# doubling the 'dir' skip above exists to refuse) or, worse, assert a
# convergence that never happened. So a hookless box gets nothing, and the
# label's presence is itself information: this box had a role and box saw it
# converge.
#
# Read only in that direction. PRESENCE means the mint-time hook succeeded and
# the box has not been touched since; ABSENCE means nothing at all — a blank
# template, a 'dir' pool, an opt-out, or a role converged by hand afterwards
# (see cmd_new's hook-failure path, which hands the operator the command
# because box cannot honestly take a mark for a run it did not watch). Nothing
# in box may treat a missing 'bootstrapped' as evidence a box is unconverged.
snapshot_bootstrapped() {
snapshot_mark "$1" "$2" bootstrapped \
"${BOX_SNAPSHOT_BOOTSTRAPPED:-1}" \
"the tenant role converged, and nothing has touched the box since."
}
cmd_new() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
require_stack
@ -1037,10 +1290,78 @@ cmd_new() {
case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
incus copy "$srcref" "$instance"
# 'incus copy' carries every user.* key forward (audit B2) — which is what
# makes a clone know its template and user for free, and is also why the
# mint stamp (#103) cannot simply ride along. A clone that inherited the
# stamp verbatim would claim to have been minted at the source's mint time,
# by the box version that minted the SOURCE, in a mint that never touched
# this instance. That is not a stale field, it is a false one.
#
# So re-stamp exactly the keys that describe THIS instance's coming into
# being, and leave the rest alone:
# · version / created / schema — the clone was made HERE, NOW, by THIS box
# · origin=clone, origin.from=<srcref> — how, and from what
# Deliberately NOT re-stamped, because they are lineage and stay true: the
# clone's disk really did come from that image, that template, that user and
# that rig role — reading them off the source is the whole point of a clone.
# ('incus copy' preserves the instance type too, so mode stays true as well.)
#
# 'mode.asked' is the one key that sits in NEITHER column, and so it is
# CLEARED rather than re-stamped or inherited. It is a mint-event fact —
# only the mint knew whether a container was asked for or fallen back into
# for want of /dev/kvm — and the asker was the SOURCE's operator. A clone
# refuses --vm/--container outright (nobody was asked anything here), so an
# inherited 'asked' makes 'box info' print a demand that was never made of
# this instance. There is no true value to re-stamp it with: the honest
# answer is absence, and absence is already how the whole block renders
# what it does not know — the MODE line simply does not print, while TYPE
# above still says VM or CT off the preserved instance type.
#
# origin.from records ONE hop. A clone of a clone names its parent and
# forgets its grandparent: the alternative is an unbounded chain in a config
# value, and the parent is the box an operator can actually go look at.
incus config set "$instance" \
user.box.schema="$BOX_STAMP_SCHEMA" \
user.box.version="$(box_version)" \
user.box.created="$(mint_time)" \
user.box.origin=clone \
user.box.origin.from="$srcref"
# Cleared, not set-to-empty: an empty value is still a key, and a reader
# that greps the config would find it. Tolerated failure because the source
# may predate the stamp and never have carried the key at all — a clone
# must not die over a key that was already absent.
incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true
incus start "$instance"
wait_agent "$instance"
reset_identity "$instance"
echo "box: cloned $srcref — isolation and auth state carry over from the source."
# A clone gets NO 'pristine' of its own (#104). It skips cloud-init and
# the rig hook entirely, so the pristine MOMENT never happens here —
# anything box marked at clone time would be "whatever the source was",
# converged and worked-in, wearing a label that promises pristine Debian.
# That is worse than no mark at all, so the mint-path snapshot lives
# strictly in the fresh-mint branch below and never runs here.
#
# What a clone gets instead is INHERITANCE, and it is already correct:
# 'incus copy <box>' carries the source's snapshots, so a source that has
# a 'pristine' hands the clone a real one — the true pristine state of
# the disk this clone descends from. Copying a SNAPSHOT ('--from box/snap')
# carries no snapshot list, so that clone starts with none. Both are
# honest; neither is invented. Say which one this is rather than leaving
# the operator to run 'box info' to find out.
# Capture first, THEN read (#124's class). Piping a multi-line incus
# writer straight into an early-exit reader lets grep close the pipe on
# the first match, SIGPIPE incus, and hand pipefail a 141 — which here
# would read as "no pristine" and narrate the WRONG inheritance shape on a
# clone that actually has one. Un-racy today at this writer's size, but
# the assignment costs nothing and does not depend on that staying true.
local snaps; snaps="$(incus snapshot list "$instance" --format csv 2>/dev/null || true)"
if printf '%s\n' "$snaps" | grep -q '^pristine,'; then
echo "box: it inherited the source's snapshots, 'pristine' among them (box restore $name pristine)."
else
echo "box: no 'pristine' mark here — a clone has no pristine moment to capture, and box"
echo "box: will not label the source's state as one. 'box info $name' lists what it did inherit."
fi
else
local t="${template:-blank}" m extra=()
load_template "$t"
@ -1077,6 +1398,47 @@ cmd_new() {
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
# knows what it is without ever consulting the template again.
#
# And the rest of what this line knows and used to drop on the floor (#103).
# There is no host-side per-box store — the Incus instance config IS the
# database — so a fact not written here is simply gone the moment the mint
# returns. The stamp describes the MINT, not the outcome: it lands before
# cloud-init and before rig, and nothing later edits it.
#
# schema the stamp's shape, so a future reader knows what it is holding
# version the box that minted it — 'box --version' is a fact about the
# binary in front of you, never about the box you are looking at
# image the alias asked for. It is an UNPINNED alias on a moving
# remote: two boxes minted a month apart from "the same
# template" are not the same box, and the alias alone cannot
# say so. What it resolved to is pinned after the launch below.
# mode what it minted as, and what was ASKED — a container that fell
# back for want of /dev/kvm and one the operator asked for read
# identically afterwards, and only the mint knew which
# role the rig role box auto-runs at the hook below
# rig.* WHICH rig converged it, stamped only for a seed that actually
# installs rig from the pin ('blank' seeds none, so it gets none)
# created when. See mint_time() for why a timestamp belongs here.
# origin mint. A clone re-stamps it (see the --from branch above).
#
# NOT stamped, on purpose: cpu/memory (limits.* already hold them, and a
# duplicate drifts the first time someone edits the limit by hand); disk
# (a VM's is the root device size, and a container's does not exist — its
# root rides the pool, so a stamped value would be fiction); and tier,
# which box_tier() derives from whoever is ASKING, not from the box.
local stamp=(
--config user.box.schema="$BOX_STAMP_SCHEMA"
--config user.box.version="$(box_version)"
--config user.box.image="$T_IMAGE"
--config user.box.mode="$m"
--config user.box.mode.asked="$mode"
--config user.box.created="$(mint_time)"
--config user.box.origin=mint
)
[ -z "$T_BOOTSTRAP_ROLE" ] || stamp+=(--config user.box.role="$T_BOOTSTRAP_ROLE")
if grep -q '@RIG_REPO@' "$root/templates/$t/user-data.yaml" 2>/dev/null; then
stamp+=(--config user.box.rig.repo="$(rig_repo)" --config user.box.rig.ref="$(rig_ref)")
fi
#
# The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
# release drill the child 'incus launch' wedged before the create was
# even accepted — 'incus operation list' empty, the instance never
@ -1095,6 +1457,7 @@ cmd_new() {
--config user.box=1 \
--config user.box.template="$t" \
--config user.box.user="$T_USER" \
"${stamp[@]}" \
--config limits.cpu="$T_CPU" \
--config limits.memory="$T_MEMORY" \
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
@ -1132,6 +1495,20 @@ cmd_new() {
# Not a wedge: incus refused and said why on stderr, right above.
die "incus launch failed (exit $rc)"
fi
# The one field the launch line could not know: 'user.box.image' above is
# the ALIAS, and an alias on a moving remote is not a reproducible fact.
# Incus resolves it during the launch and records what it landed on in
# volatile.base_image — read it back and pin it into the stamp, so an
# incident six months from now can ask "was this box built on the image
# that broke?" and get an answer instead of a template name.
#
# Best-effort BY CONSTRUCTION, and that is the whole design of this line:
# it runs only after a launch that already succeeded, and a box that exists
# and boots must never be failed over a provenance field. Every failure
# here is silent and leaves the alias standing as the honest partial answer
# — a stamp with no fingerprint, which is exactly how cmd_info renders it.
local fp; fp="$(incus config get "$instance" volatile.base_image 2>/dev/null || true)"
[ -z "$fp" ] || incus config set "$instance" user.box.image.fingerprint="$fp" >/dev/null 2>&1 || true
wait_agent "$instance"
echo "box: waiting for phase-1 (cloud-init)..."
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
@ -1164,6 +1541,19 @@ cmd_new() {
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
fi
# The pristine mark (#104, child of rig#62). RIGHT HERE — cloud-init has
# finished and rig has not started — the guest is pristine Debian plus
# box's seed, and nothing else. That state exists for a few seconds on
# every mint and has never been captured; rig#62's "back to pristine
# Debian" is exactly this state, so take it while it is true.
#
# Unconditional within a fresh mint, not gated on T_BOOTSTRAP_ROLE: a
# blank box has no rig hook but it does have the same pristine moment,
# and 'box restore <box> pristine' should mean one thing on every box.
#
# NEVER fatal. This is an undo, not the mint's product — a mint that
# worked must not be failed by a checkpoint that didn't.
snapshot_pristine "$instance" "$name"
# The tenant convergence (#81): the seed above is thin — the user, tmux,
# rig — and what the box BECOMES is rig's job. A template that names a
# bootstrap role gets it auto-run here, as root inside the guest, because
@ -1179,20 +1569,57 @@ cmd_new() {
echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
# The mark box cannot take for you (#130), said HERE because here is
# the only moment the operator is looking. 'bootstrapped' means "box
# ran the hook and watched it succeed" — a by-hand re-run happens in a
# shell box does not watch, so box cannot know when (or whether) it
# converged, and a mark taken on a guess would be a label asserting a
# convergence box never saw. That is the same lie #104 refused on the
# clone path. So: hand over the command instead of inventing the fact.
echo "box: no 'bootstrapped' mark was taken — box only marks a hook it watched succeed," >&2
echo " and a by-hand re-run happens in a shell it does not watch. Take it yourself once" >&2
echo " the role converges, at the moment it is true:" >&2
echo " box snapshot $name bootstrapped" >&2
# Only offer the rollback that EXISTS. Three mints reach this line
# with no pristine mark, all by this stack's own design: a 'dir' pool
# (skipped), BOX_SNAPSHOT_PRISTINE=0, and a refused create (warned,
# mint continued). On a 'dir' host that is EVERY hook failure, and an
# operator standing at the one moment this message exists for would
# copy-paste a restore that errors. Offering a mark the disk does not
# have is the same lie this PR refuses one message earlier, when it
# declines to invent a 'bootstrapped' it never watched.
if mark_taken pristine; then
echo "box: 'box restore $name pristine' is still there if you would rather start the role over." >&2
fi
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
fi
# The hook RAN and box WATCHED it succeed — so right here the box is
# converged and untouched, the state an operator re-mints ten minutes
# for when they break a tenant role but not the OS under it. Never
# fatal, same as 'pristine': a mint that worked is not failed by a
# checkpoint that didn't.
snapshot_bootstrapped "$instance" "$name"
fi
fi
# The login hint belongs to the claude template — read the EFFECTIVE
# template off the instance, so a clone of a claude box gets it too and a
# blank box is not told to run a binary it doesn't have.
# The login hint belongs to the claude-box template — read the EFFECTIVE
# template off the instance, so a clone of a claude-box box gets it too and
# a blank box is not told to run a binary it doesn't have.
#
# Both spellings match, and that is not an alias for the ROLE. The role
# names are a hard cut (rig#76) — 'rig bootstrap claude' is gone, so the
# seeds ask for 'claude-box' and nothing here softens that. What these arms
# read is user.box.template, a stamp left on an INSTANCE at its own mint
# time: every box minted before the rename carries the bare name forever,
# and a clone carries it forward. Refusing the old spelling here would not
# cut anything over, it would only drop the hint on boxes that predate the
# rename — the same reason user.claudebox is honored everywhere else.
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
if [ "$eff" = claude ]; then
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
elif [ "$eff" = staging ]; then
elif [ "$eff" = staging-box ] || [ "$eff" = staging ]; then
echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
echo " box shell $name # then: sudo rig bootstrap workload --hostname $name"
echo " box shell $name # then: sudo rig bootstrap workload-server --hostname $name"
else
echo "box: ready — 'box shell $name'."
fi
@ -1275,6 +1702,85 @@ cmd_list() {
fi
}
# One instance config key. 'incus config get' on an UNSET key prints empty and
# exits 0 (audit B4) — so the '|| true' here covers the daemon refusing, not the
# key being absent, and every caller below reads absence as an empty string.
box_cfg() { incus config get "$1" "$2" 2>/dev/null || true; }
# The mint stamp (#103), read back for 'box info'. Every key is optional and so
# is the whole block: a box minted before the stamp existed carries none of it
# and must render as a box with blanks — never as an error, and never as a box
# wearing a mint time it does not have. Legacy boxes are not a transitional
# case: a box outlives the release that minted it, which is exactly what the
# legacy 'user.claudebox' tag already says out loud at resolve_box.
box_provenance() {
local i="$1" schema created ver img fp m asked tpl u role rrepo rref origin from
local imp impby impl implby impn
schema="$(box_cfg "$i" user.box.schema)"
created="$(box_cfg "$i" user.box.created)"; ver="$(box_cfg "$i" user.box.version)"
img="$(box_cfg "$i" user.box.image)"; fp="$(box_cfg "$i" user.box.image.fingerprint)"
m="$(box_cfg "$i" user.box.mode)"; asked="$(box_cfg "$i" user.box.mode.asked)"
tpl="$(box_cfg "$i" user.box.template)"; u="$(box_cfg "$i" user.box.user)"
role="$(box_cfg "$i" user.box.role)"
rrepo="$(box_cfg "$i" user.box.rig.repo)"; rref="$(box_cfg "$i" user.box.rig.ref)"
origin="$(box_cfg "$i" user.box.origin)"; from="$(box_cfg "$i" user.box.origin.from)"
# The import event (#131) — a THIRD fact, read from its own keys. 'origin'
# above still says mint or clone, which is what it has always meant.
imp="$(box_cfg "$i" user.box.imported)"; impby="$(box_cfg "$i" user.box.imported.by)"
impl="$(box_cfg "$i" user.box.imported.last)"
implby="$(box_cfg "$i" user.box.imported.last.by)"
impn="$(box_cfg "$i" user.box.imported.count)"
# A pre-rename box has no metadata at all but is always a Claude box — the
# same mapping box_user() makes, for the same reason.
[ -n "$tpl" ] || [ "$(box_cfg "$i" user.claudebox)" != 1 ] || { tpl=claude; u="${u:-claude}"; }
echo
if [ -n "$created" ] || [ -n "$ver" ]; then
printf '%-11s%s\n' MINTED "${created:-(time not recorded)} by box ${ver:-unknown}"
else
printf '%-11s%s\n' MINTED "(not recorded — this box predates the mint stamp)"
fi
# Directly under MINTED, because adjacency is what defeats the misreading:
# the mint time above is the ARTIFACT's, carried across the trip on purpose,
# and a reader who meets it alone will take it for this host's.
#
# The qualifier states the one thing box actually knows — the ORDERING. It
# does not say "on another host": a box can be exported and re-imported onto
# the SAME host (that is the installer's own upgrade advice, #66), and
# nothing on the box records which host minted it. Inventing a location here
# would be precisely the fabricated fact the stamp exists to avoid.
if [ -n "$impl" ] || [ -n "$imp" ]; then
printf '%-11s%s\n' IMPORTED \
"${impl:-$imp} by box ${implby:-${impby:-unknown}} (the mint above predates it)"
# The birth of the import history, shown only when it is a DIFFERENT trip
# from the latest — on a single import both pairs hold the same values and
# a second line would be noise. Continuation indent, no second label.
if [ -n "$imp" ] && [ "$imp" != "${impl:-$imp}" ]; then
printf '%-11s%s\n' '' "import ${impn:-?} — the first was $imp by box ${impby:-unknown}"
fi
fi
if [ -n "$tpl" ]; then
local paren=""
[ -z "$u" ] || paren="user $u"
[ -z "$role" ] || paren="${paren:+$paren, }role $role"
printf '%-11s%s\n' TEMPLATE "$tpl${paren:+ ($paren)}"
fi
[ -z "$img" ] || printf '%-11s%s\n' IMAGE "$img${fp:+ @ ${fp:0:12}…}"
# The mode is only worth a line alongside what was ASKED: TYPE above already
# says VM or CT, but only the mint knew whether a container was chosen or
# fallen back into for want of /dev/kvm.
[ -z "$asked" ] || printf '%-11s%s\n' MODE "${m:-?} (asked: $asked)"
[ -z "$rrepo" ] || printf '%-11s%s\n' RIG "$rrepo@${rref:-?}"
[ -z "$origin" ] || printf '%-11s%s\n' ORIGIN "$origin${from:+ of $from}"
# A schema box does not recognise is NEWER than box, not broken: show what is
# understood and say so, rather than refusing to describe a box that a later
# release minted perfectly well. (A non-integer lands here too, which is the
# right side to fail on.)
if [ -n "$schema" ] && { ! [ "$schema" -eq "$schema" ] 2>/dev/null || [ "$schema" -gt "$BOX_STAMP_SCHEMA" ]; }; then
printf '%-11s%s\n' NOTE "stamp schema '$schema' is newer than this box ($(box_version)) reads ($BOX_STAMP_SCHEMA) — showing what it understands"
fi
}
cmd_info() {
local box="${args[0]}" row
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
@ -1296,6 +1802,12 @@ cmd_info() {
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
done < <(incus config device list "$inst" 2>/dev/null)
# What built this box, from what, when, with which box (#103). Nothing else
# on the host records it — the instance config IS the store — so a stamp
# nothing surfaces is a stamp nobody has. 'box info --json' carries the keys
# for free: 'incus list --format json' includes config verbatim.
box_provenance "$inst"
echo
case "${snaps:-0}" in
''|0)
@ -1333,13 +1845,18 @@ box_user() {
}
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
# sudo -i joins its command argv into one shell string. In that join, a
# backslash-newline becomes a shell continuation and silently deletes the
# newline from a multi-line `box exec` payload (#169). Keep the login
# environment explicitly, but let the inner shell exec the original argv.
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -H bash -lc 'cd ~ && exec "$@"' _ "${args[@]:1}"; }
# A shell is a child of the exec connection: drop the terminal and everything
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
# attaches when the session exists and creates it when it doesn't, so starting
# work and reattaching after a disconnect are the same command. 'shell' stays
# bare on purpose — two verbs, two contracts.
# bare on purpose — two verbs, two contracts. Unlike cmd_exec, tmux has no
# caller-supplied command payload; its session name is validated below.
cmd_tmux() {
local session="${args[1]:-main}"
case "$session" in
@ -1445,6 +1962,69 @@ cmd_import() {
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
[ "$tag" = 1 ] || incus config set "$target" user.box=1
# The import EVENT (#131). Everything above either restores the ARTIFACT's
# truth or re-establishes THIS host's boundary; none of it records that the
# trip happened. The mint stamp (#103) rides inside the artifact verbatim —
# which is correct, mint facts belong to the originating host — and that is
# exactly what creates the gap: an imported box is otherwise indistinguishable
# from one minted here at the artifact's mint time.
#
# Deliberately NOT 'origin=import'. 'origin' answers how this instance came
# into BEING — mint or clone — and overwriting it destroys that answer: a
# clone that was exported and imported would come back claiming to be an
# import, with nothing left saying it was ever a clone and an 'origin.from'
# naming a lineage no key explains any more. The import is a THIRD fact,
# orthogonal to the first two, so it takes its OWN keys, and every key the
# artifact carried — origin, origin.from, created, version, template, user,
# image, role, rig.* — is left exactly as it arrived.
#
# Birth pair + latest pair, the shape heavy-duty/rig#61 settled on for the
# same repeated-event question. It transfers: a box can make the trip more
# than once, and these keys ride inside the artifact on every subsequent one.
# · imported / imported.by FIRST-wins. Pinned once, never rewritten.
# · imported.last / .last.by LAST-wins. When it landed HERE.
# · imported.count how many trips — first and last alone
# cannot tell 2 apart from 5, and nothing
# else on the box can reconstruct it.
# Last-wins alone would repeat, one level down, the very mistake this key
# exists to avoid: the second import would erase the evidence of the first.
# First-wins alone cannot answer "when did this land on THIS host", which is
# the question an operator actually asks.
local first_at prev_n n now
first_at="$(incus config get "$target" user.box.imported 2>/dev/null || true)"
prev_n="$(incus config get "$target" user.box.imported.count 2>/dev/null || true)"
# A count that is absent (the first trip) or not an integer (a hand-edited
# config, a foreign user.box.imported.count) must never fail an import that
# has already happened — arithmetic on it under 'set -e' would. Same side the
# schema check in box_provenance() falls on: degrade, never die.
# Two holes, and they have to close together. `[ 08 -eq 08 ]` PASSES — test
# parses decimal — and then `$((08 + 1))` aborts with "value too great for
# base", because arithmetic reads a leading zero as octal. That abort lands
# after the physical 'incus import' and before the stamp, the placement fix
# and the start: precisely the window this guard exists to protect. So the
# digits-only case closes sign and garbage, and 10# forces base ten.
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
n=$((10#$prev_n + 1)); now="$(mint_time)"
local istamp=(
user.box.imported.last="$now"
user.box.imported.last.by="$(box_version)"
user.box.imported.count="$n"
)
# The birth of this box's import history: pinned on the first trip and
# carried by the artifact forever after, so a box on its third host still
# names the trip that started it.
[ -n "$first_at" ] || istamp+=(user.box.imported="$now" user.box.imported.by="$(box_version)")
# No 'user.box.schema' is written here, on either path. "Absent schema means
# pre-stamp" is load-bearing for the MINTED line, and stamping schema=1 onto
# a legacy artifact that carries no mint stamp would claim a shape it does
# not have. These keys need no schema to be read — every reader tolerates
# absence by construction. Nor does the schema BUMP: #103's contract is that
# it moves on removal or repurposing, never on addition, and this only adds.
#
# Before the start, like the clone re-stamp and for the same reason: an
# imported box is never observable — not for a second, not by a concurrent
# 'box info' — without the record of how it got here.
incus config set "$target" "${istamp[@]}"
# Placement: the artifact carries its profile list, but the isolation
# contract is THIS host's box-net profile. A box export already says
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets

View file

@ -1,8 +1,8 @@
# box design
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
with a coding agent installed** (`claude`, `codex`, `grok`, or `blank` for
none). It is infrastructure, not a project provisioner.
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
`kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
See issue #3 for the full reframe and rationale. This doc captures the durable
design decisions.
@ -43,6 +43,33 @@ snapshots, not a secrets store:
Log in once → snapshot → spin up authed boxes from it.
One checkpoint box takes itself: **`pristine`**, marked on every fresh mint
after cloud-init and before the `rig bootstrap` hook (#104, child of
heavy-duty/rig#62). That instant — pristine Debian plus box's thin seed, rig
installed but not yet run — is the state "back to pristine Debian" names, it
lasts a few seconds, and nobody is standing there to snapshot it. `box
restore <n> pristine` undoes a tenant role wholesale, because everything the
creds-free roles do is box-local and file-shaped. A `--from` clone takes no
`pristine`: it never has the moment, so it inherits its source's snapshots or
has none, and box refuses to label a worked-in state as pristine. On a `dir`
pool the mark would be a full copy rather than a CoW one, so the mint skips
it loudly.
A second, **`bootstrapped`**, is marked one step later (#130) — after the rig
hook has run and box has *watched it succeed*. Same policy, literally the same
function: default on, never fatal, skipped on `dir`, opt-out via
`BOX_SNAPSHOT_BOOTSTRAPPED=0`. Where the two differ is deliberate.
`pristine` marks a **moment** every fresh mint has, so it is unconditional;
`bootstrapped` marks an **event**, so it is not. A blank box has no hook and
therefore no event — marking it anyway would duplicate `pristine` byte for
byte at twice the disk cost, or assert a convergence that never happened. A
failed hook gets no mark either: box tells the operator to re-run the role
through `box shell`, which is a run box does not watch, so it hands over `box
snapshot <n> bootstrapped` instead of guessing. The consequence is a label
that is only ever read one way — **presence** means "converged and untouched";
**absence** means nothing, and nothing in box may treat a missing
`bootstrapped` as evidence a box is unconverged.
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
clone still lives on the same host. The off-host mechanism is `box export` /
`box import` (#70) — one portable backup tarball, snapshots included by
@ -50,7 +77,13 @@ default, that survives `rm`, a host teardown, an upgrade, a move. The split
of truths is the design: everything `incus import` restores is the artifact's
(disk, config, snapshots); everything box re-stamps on import is the current
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
machine identity via the same `reset_identity` a clone gets). Auth state
machine identity via the same `reset_identity` a clone gets, and the record
that the trip happened). That last one is #131, and it is deliberately *not*
`origin=import`: `origin` says how the instance came into **being** — mint or
clone — and the import is a third, orthogonal fact. Overwriting `origin` would
make an exported clone come back claiming to be an import, with its
`origin.from` lineage left unreadable, so the import gets its own keys and the
artifact's mint stamp survives the trip untouched. Auth state
rides along deliberately — and because scrubbing a disk image is a promise
tarball surgery cannot keep, export shouts that the file is a credential
instead of pretending to sanitize it.
@ -60,15 +93,17 @@ instead of pretending to sanitize it.
A template is a **thin, creds-free seed** — base image, the tenant user,
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
template's creds-free tenant role after cloud-init (`rig bootstrap claude` /
`codex` / `grok` / `staging`), which installs the agent CLI or server
posture. The split is deliberate: cloud-init is a first-boot one-shot —
template's creds-free tenant role after cloud-init (`rig bootstrap claude-box`
/ `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
`-box` for box tenants and `-server` for fleet machines, and the templates are
named for the roles they converge, rig#76), which installs the agent CLI or
server posture. The split is deliberate: cloud-init is a first-boot one-shot —
not convergent, not re-runnable, only parse-and-grep testable — while a rig
role is an idempotent script with effective-state asserts that can also
converge an *existing* box to a newer spec. Anything that joins a tailnet or
holds a key (staging's workload join) stays operator-run through
`box shell`; box prints it as a next step and never sees the key. The seed's
rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
holds a key (the staging-box tenant's workload join) stays operator-run
through `box shell`; box prints it as a next step and never sees the key. The
seed's rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
and box's template suite holds the line with fail-closed absence greps: no
agent CLI, no docker, no tailscale, no context-file heredocs in any

View file

@ -2,9 +2,9 @@
`box` mints trust-less, creds-free, isolated VMs with a coding agent already
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
`claude`, `codex`, and `grok` templates each ship a CLI agent. The tool knows
**nothing** about your project. There is no `install` step and no host-run
setup script.
`claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
host-run setup script.
A project makes itself easy to stand up inside a box by shipping an optional
`.box/` folder. This folder is **agent-facing documentation** — read and

View file

@ -71,8 +71,8 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
# Read from inside a box WITHOUT ever hanging the drill.
#
# Two traps, both hit for real:
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
# all). Fine for a person, needless machinery for a probe.
# · 'box exec' crosses a login-user shell boundary. Fine for a person,
# needless machinery for a probe.
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
# inheriting the exec session's stdout keeps the substitution open forever,
# and 'timeout' does not save you: it kills the wrapper, not the holder of
@ -378,7 +378,7 @@ if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
fi
inf "host setup complete"
# A real server has room for the claude template's resources (8GiB/4cpu), and
# A real server has room for the claude-box template's resources (8GiB/4cpu), and
# drilling the real numbers is worth more than drilling shrunken ones. Only
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
# template at mint — a profile edit no longer reaches them; the supported
@ -386,9 +386,9 @@ inf "host setup complete"
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
if [ "$ram" -lt 20 ]; then
export BOX_MEMORY=3GiB BOX_CPU=2
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)"
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude-box template's 8GiB/4cpu is what was NOT drilled)"
else
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
inf "host has ${ram}GiB RAM — drilling the claude-box template's resources (8GiB/4cpu) unchanged"
fi
KVM=0; [ -e /dev/kvm ] && KVM=1
@ -492,10 +492,10 @@ fi
# --- templates: the mint surface is itself a surface to test ----------------
tpl_missing=""
for t in blank claude codex grok; do
for t in blank claude-box codex-box grok-box kimi-box; do
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
done
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box, kimi-box" \
|| no "templates listing is missing:$tpl_missing"
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
@ -551,19 +551,22 @@ else
fi
# The generic mechanic (metadata, placement, user, isolation parity) is proven
# once by blank+claude and needs no per-template repeat. What a NEW template
# once by blank+claude-box and needs no per-template repeat. What a NEW template
# still has to prove is its own payload: the CLI installs, lands on the
# non-interactive exec PATH, and answers --version. One mint each.
# The box NAME stays the bare agent name — it is what the pre-flight banner
# announces and what teardown deletes — while the TEMPLATE carries rig#76's
# family suffix. They are two different namespaces and only one of them moved.
for t in codex grok; do
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t-box"; then
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
else
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)"
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude-box template's #15 bug)"
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
# Do not throw the evidence away — say WHAT the installer actually left.
# Do NOT throw the evidence away — say what the installer actually left
@ -583,10 +586,10 @@ for t in codex grok; do
fi
done
printf '\n minting a claude box (cold, ~10 min)…\n'
printf '\n minting a claude-box box (cold, ~10 min)…\n'
t0=$SECONDS
if mint_box /tmp/mint-drill.log --name drill --template claude; then
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
if mint_box /tmp/mint-drill.log --name drill --template claude-box; then
ok "box new --name drill --template claude-box ($((SECONDS - t0))s)"
else
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
@ -799,7 +802,7 @@ fi
# ===========================================================================
phase "E. box expose — a deliberate loopback door (#55)"
# ===========================================================================
# archive is a running claude box (node is installed). Start a DETACHED
# archive is a running claude-box box (node is installed). Start a DETACHED
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
# from the HOST's loopback. Then prove removing it closes the door, and that a
# NON-exposed port still obeys the ingress drop — the feature must not

View file

@ -202,6 +202,31 @@ fi
rm -f "$mintlog"
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
# #169: compare the new explicit login-user boundary with sudo -i while the
# latter is still safe (one fixed `env` argv), then exercise the exact
# silent-success multiline shape that sudo -i used to corrupt.
target_user="$(as_u "$U1" incus config get mine user.box.user)"
legacy_env="$(as_u "$U1" incus exec mine -- sudo -u "$target_user" -i env \
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
exec_env="$(as_u "$U1" box exec mine -- env \
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
[ "$exec_env" = "$legacy_env" ] \
&& ok "(b) box exec preserves cross-user login environment (USER/LOGNAME/SHELL/HOME/PWD/PATH)" \
|| {
no "(b) box exec changed the cross-user login environment (#169)"
diff -u <(printf '%s\n' "$legacy_env") <(printf '%s\n' "$exec_env") | sed 's/^/ /' || true
}
as_u "$U1" box exec mine -- bash -lc '
set -o errexit -o nounset -o pipefail
touch /tmp/box-169-step-one
touch /tmp/box-169-step-two
' >/dev/null 2>&1 \
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-one \
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-two \
&& ok "(b) box exec preserves multiline commands, including the silent-success set shape" \
|| no "(b) box exec corrupted a multiline command (#169)"
as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \
&& ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \
|| no "(g) mine has no boxnet address in box info"

View file

@ -117,10 +117,39 @@ for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086 # the table spec is two words by design
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
done
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
# Every ufw read is CAPTURED before it is matched, never piped into a reader
# that exits on its first match (#102, #107).
#
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
# and the pipeline yields 141. This file is 'set -u' with no pipefail, so
# that 141 is discarded, grep's 0 is the pipeline's result, and the branch
# held — the defect was latent here, never live. It was also one line from
# live: adding 'set -o pipefail' for unrelated robustness would silently turn
# this into #102, skipping every UFW removal on a host the operator was told
# is wiped, with no error and no red X to see. Captured and matched with
# [[ ]], it is correct under any future 'set' line.
#
# The numbered loop had the same defect for a different reason: its condition
# was also an early-exit reader, so it could end while rules remained, and it
# re-read un-captured to get the number. It now reads one capture per
# iteration and breaks on absence — the re-scan is still per-delete (numbers
# shift after each removal), just no longer racing.
ufw_status=""
if command -v ufw >/dev/null; then
# '|| true': ufw exits non-zero when it cannot read its config, and "no
# usable ufw here" is nothing-to-clean, not a failure to report.
ufw_status="$(sudo ufw status 2>/dev/null || true)"
fi
if [[ "$ufw_status" == *"Status: active"* ]]; then
for net in boxnet claudenet; do
while sudo ufw status numbered | grep -q "on $net"; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
while :; do
numbered="$(sudo ufw status numbered 2>/dev/null || true)"
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
[ -n "$line" ] || break
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
[ -n "$n" ] || break
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
done
done

57
drills/0.9.0.md Normal file
View file

@ -0,0 +1,57 @@
# box 0.9.0 — release drill
**WAIVED. No drill was run for this release.**
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
gate requires a *record*, not a passing result — so a skip is a line in a diff
somebody reviewed, rather than the silence that let 0.7.0, 0.8.0 and every
release before them ship unproven.
## Why
The drill harness is not in a state to produce a trustworthy run. box has the
only real harness in the family (`drill/drill.sh`, ~85 probes), but the
procedure around it — standing up the substrate, pinning the release
candidates, capturing the result — was undocumented and assembled by hand.
rig and cast have no harness at all. A run improvised under those conditions
produces numbers nobody should rely on, and `drills/` is worth nothing if it
fills with records like that.
Deferred deliberately, and tracked: #155.
## What this release therefore does NOT prove
Stated plainly so it is not rediscovered as a surprise:
- **The VM trust boundary.** `ci.yml` says it in its own words: *"What
container mode canNOT validate is the VM trust boundary itself; that stays a
real-hardware ritual."* That is exactly the delta a release drill adds, and
it is exactly what is missing here.
- **The isolation contract on real hardware** — egress, the sibling drop, DNS
enumeration, IPv6 off, inbound drop — as measured by `drill/drill.sh` against
real VMs rather than containers.
- **The combination users receive.** A released box still resolves `RIG_REF` to
`main` (#150), so even a passing drill would have proven a pair that drifts
the moment rig's main moves.
## What CI did prove on this tree
Not nothing, and worth recording so the gap is precise rather than total:
| Job | Result |
|---|---|
| `rehearsal` — multi-user tier on a real Incus | pass |
| `scope` | pass |
| `reconcile` | pass |
| `test/cli.sh` | 653 passed, 0 failed |
| `test/release.sh` | 173 passed, 0 failed |
| `test/labels-reconcile.sh` | 72 passed, 0 failed |
| `shellcheck -x bin/* **/*.sh` | clean |
The tier's semantics are covered. The boundary is not.
## The commitment
The next release carries a real drill record at `drills/<version>.md` — what
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
failed drill is a valid record; another waiver is not. Tracked in #155.

82
drills/README.md Normal file
View file

@ -0,0 +1,82 @@
# drills/ — release evidence, one file per version
This directory holds the **evidence that a release was proven on real
hardware**. One file per shipped version, named exactly for the version:
```
drills/0.9.0.md
drills/0.9.0-rc1.md
```
The name must match the contents of `VERSION` exactly.
[the pinned ceremony drill-recorded action](https://github.com/heavy-duty/ceremony/tree/0.1.0/actions/drill-recorded)
refuses any tree with a bare `VERSION` that has no such file, or whose file is
blank. A `-dev` tree passes with nothing to assert.
Because each version owns a file, `0.9.0` and `0.9.0-rc1` can never be
confused for one another — they are simply different paths. That used to take
careful whole-version field matching inside one shared file; now it is free.
## This is not `drill/RUNS.md`
Two different artifacts, and the distinction is load-bearing:
| | what it is |
|---|---|
| [`drill/RUNS.md`](../drill/RUNS.md) | the **harness's own history** — every run of `drill/drill.sh`, the traps table, the lore about what broke and why. It is not release-scoped and it is not going anywhere. |
| `drills/<version>.md` | **release evidence** — the record that *this version* was drilled before it shipped. Release-scoped, one file, gated by CI. |
Appending to `drill/RUNS.md` does not satisfy the release gate, and is not
meant to. Keep using it for what it has always been for.
## What a record should contain
- **What ran** — which drill, how many probes, `drill/drill.sh` invocation.
- **On what host** — the machine, the OS, the Incus version. "Real hardware"
is the claim; name the hardware.
- **The pinned candidate refs** — the exact `BOX_REF` / `RIG_REF` /
`CAST_REF` under test, and the other repos' commit SHAs. A drill that does
not say what it drilled proves nothing later.
- **The shared run ID**, so this record reconciles with the sibling repos'.
- **The numbers** — passed, failed, how long it took.
- **What failed**, plainly.
**A failed drill is still a valid record.** The gate wants *evidence*, not
success. A record saying "83/85, criterion (m) regressed, here is the issue"
is a good record. So is a maintainer's written waiver explaining why this
release shipped without a full drill. What the gate refuses is silence — #95,
#114 and #148 all shipped unproven because a skip left no trace.
## Worked example
The version below is a **placeholder that can never be a real release**.
Copy the shape, not the number.
```markdown
# Release drill — 9.9.9
- **Run ID:** `drill-9.9.9-20260721-01` (shared with rig, cast)
- **Host:** bare Debian 13, Ryzen 7 5800X / 64 GB, Incus 6.0.2
- **Date:** 2026-07-21
- **Candidate refs:**
- box `release/9.9.9` @ `abc1234`
- rig `release/4.4.4` @ `def5678` (minted with `RIG_REF=release/4.4.4`)
- cast `release/2.2.2` @ `9abcdef`
## What ran
`bash drill/drill.sh --ref release/9.9.9` — the full end-to-end: install the
stack, mint every template cold, snapshot and restore, uninstall to zero
residue. Then `drill/multiuser.sh` for the two-user grant matrix.
## Result
**84/85 passed, 1 failed.** 41 minutes wall clock.
- Failed: `multiuser.sh` criterion (m) — the raw instance kept a stale route
after teardown. Filed as #999. Judged not release-blocking: it affects
teardown residue on a host that is about to be wiped, not the trust
boundary itself.
- The VM boundary probes (the 85-probe isolation contract) passed clean,
which is the assertion this repo's drill exists to make.
```

View file

@ -64,7 +64,11 @@ legacy_boxes() {
}
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
# tool minted), so the new metadata is the claude template's.
# tool minted), so the new metadata is the claude-box template's. The stamp
# names the template as it is called TODAY, not as it was called when the box
# was minted: it is what 'box shell' and the mint hints read, so a re-homed
# box should look like a fresh claude-box mint, not like a fossil (rig#76's
# family suffix — the template is named for the role it converges).
rehome_one() {
local b="$1" st
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
@ -75,7 +79,7 @@ rehome_one() {
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
# valid legacy box (the old tag is untouched) AND now a new one.
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \
incus config set "$b" user.box=1 user.box.template=claude-box user.box.user=claude \
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
# 2. Stop, reassign the profile (this is the network move), restart. Incus

View file

@ -200,10 +200,23 @@ fi
# and a promise the header makes is a promise this block checks. The
# incus-user state directory too — it was purged for releases without being
# re-checked, which is exactly the gap this block exists to close.
# The trust store is read into a capture rather than piped into a reader that
# stops at its first match — #102's shape, and this file is `set -euo pipefail`
# already, so unlike drill/wipe.sh (#107) nothing but the writer's size is
# holding it. A reader that exits early SIGPIPEs incus mid-table and the
# pipeline yields 141; sitting left of `&&` that is also set -e-exempt, so it
# would read as "no leftover cert" on a host that still trusts the revoked
# user's certificate and the purge would report success. Fail-open, on the
# cleanup path whose entire job is to prove access is gone.
# Un-racy in practice today — the trust store is small and likely one write —
# so this is defensive, not a live defect. Captured so it cannot become one.
trust_csv="$(incus config trust list --format csv --columns nf 2>/dev/null || true)"
leftover=""
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
# Leading newline so the first CSV row anchors like the `^` this replaces.
[[ $'\n'"$trust_csv" == *$'\n'"incus-user-$uid,"* ]] \
&& leftover="$leftover cert:incus-user-$uid"
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
&& leftover="$leftover /var/lib/incus/users/$uid"

View file

@ -28,6 +28,18 @@ $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
if [ "$yes" -eq 1 ]; then
echo "(confirmed non-interactively: --yes/BOX_YES)"
else
# No terminal to ask on, and no consent given: refuse and say how to proceed,
# rather than fall into 'read', hit instant EOF and abort with nothing but
# "aborted" (#113). This must stay BELOW the --yes/BOX_YES arm above — the
# order is the contract: consent given non-interactively still runs headless
# (CI's uninstall drill and 'box uninstall --purge-host --force' depend on
# it), consent NOT given without a terminal is a usage error, exit 2, the
# same shape as host/revoke-user.sh and install.sh. It also lands before the
# first 'incus' call below, so the refusal needs no daemon.
if [ ! -t 0 ]; then
echo "teardown-host: refusing to run without a terminal to confirm on. --yes (or BOX_YES=1) means yes." >&2
exit 2
fi
# EOF (Ctrl-D) refuses, out loud: unguarded, errexit would end the run on
# this line and the 'aborted' below would never print (#111).
read -rp "Continue? [y/N] " a || { echo "aborted"; exit 1; }

View file

@ -143,12 +143,39 @@ flip_current() {
mv -Tf "$DEST/current.new.$$" "$DEST/current"
}
# Whether ANY version was installed before this run — computed BEFORE the
# migration below, which is the whole point. It gates the host-setup offer: a
# host that already ran box has made that decision (and may have live boxes the
# stack must not be rebuilt under, #66); after an upgrade, 'box setup-host'
# re-applies stack changes on purpose.
#
# Order is load-bearing (#115). The migration converts a pre-0.7.0 flat tree
# into versions/<flat_ver>, so computing this AFTER it made the test true by
# its own doing: a flat host looked "already installed", setup-host was
# skipped, and every host-side artifact stayed at the old version while
# 'box --version' reported the new one. A tree that needs migrating has by
# definition never been converged by THIS version's setup-host, so it must
# read as had_install=0. A genuinely versioned tree still reads 1 — the
# directory it is testing predates this run.
had_install=0
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
had_install=1
fi
# --- migrate a pre-0.7.0 flat install --------------------------------------
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
# it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an
# upgrade from 0.6.0 is seamless and the version comparison below sees the
# truth. The move is two renames inside one parent directory — no copying, no
# window with no install — and the operator's tree is preserved bit for bit.
#
# What the migration LEAVES is the operator's to decide (#117): the old tree
# becomes a first-class 'box versions' entry — a rollback target if the new
# version misbehaves, garbage otherwise. Deleting it here is the wrong default
# (it is the only thing to roll back TO, at exactly the moment that matters),
# so name it instead — and name it AGAIN in the closing summary, because a
# line ~250 lines of output above 'done' is a line the operator scrolled past.
migrated_from=""
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
# The flat tree's VERSION is data from disk, not from this installer — the
@ -164,15 +191,9 @@ if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
mkdir -p "$BINDIR"
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
fi
# Whether ANY version was installed before this run — read before we add one.
# It gates the host-setup offer below: a host that already ran box has made
# that decision (and may have live boxes the stack must not be rebuilt under,
# #66); after an upgrade, 'box setup-host' re-applies stack changes on purpose.
had_install=0
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
had_install=1
log " it is a normal version entry now — 'box versions' lists it. Keep it as a"
log " rollback target ('box use $flat_ver'), or reap it: box uninstall $flat_ver"
migrated_from="$flat_ver"
fi
# --- temp workspace --------------------------------------------------------
@ -386,6 +407,15 @@ esac
# 'box setup-host' re-applies stack changes deliberately, after an upgrade.
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
# up by hand); BOX_YES answers "yes".
#
# It runs $VDIR's script, NOT $DEST/current's. They are usually the same tree,
# but 'current' does not always flip: the #66 guard above keeps the default
# where it is when the host has existing boxes, so on such a host 'current'
# still names the OLD version. Going through it would converge the host with
# the old release's host-side scripts — reinstating exactly the stale artifacts
# #115 is about, in the one case where the operator's boxes make it costly.
# $VDIR is unambiguously the version this run installed, which is the version
# whose host contract we are being asked to satisfy.
setup_ok=""
setup_declined=""
if [ "$had_install" -eq 1 ]; then
@ -396,7 +426,7 @@ elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
setup_declined=1
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
warn "cannot set up the host: it needs root and sudo was not found."
warn " run this as root to finish: $DEST/current/host/setup-host.sh"
warn " run this as root to finish: $VDIR/host/setup-host.sh"
setup_declined=1
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
@ -404,7 +434,7 @@ elif confirm "Set up this machine as a box host now? (installs Incus + the isola
# it prompts on /dev/tty, so an interactive host can still authenticate.
# setup-host re-execs itself under sg incus-admin if it must add you to the
# group; that re-exec is a child here and completes the whole setup in one go.
if bash "$DEST/current/host/setup-host.sh" </dev/null; then
if bash "$VDIR/host/setup-host.sh" </dev/null; then
setup_ok=1
else
warn "host setup did not complete — box is installed, the host is not ready."
@ -422,3 +452,12 @@ elif [ -n "$setup_declined" ]; then
else
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
fi
# Re-state the migration where the operator is actually looking (#117). The
# line at the migration itself is true but ~250 lines of output ago; this is
# the last thing printed, and it is where an unexplained 'box versions' entry
# stops being a surprise.
if [ -n "$migrated_from" ]; then
log "note: your pre-0.7.0 install was migrated to versions/$migrated_from and 'box versions' now lists it."
log " keep it to roll back ('box use $migrated_from'), or remove it: box uninstall $migrated_from"
fi

View file

@ -1,7 +1,11 @@
# The claude template — a thin, creds-free seed (#81): Debian 13, the
# The claude-box template — a thin, creds-free seed (#81): Debian 13, the
# 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI,
# docker, node, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap claude' after mint (heavy-duty/rig#31).
# box auto-runs 'rig bootstrap claude-box' after mint (heavy-duty/rig#31).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'claude' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
@ -14,4 +18,4 @@ BOX_USER="claude"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="claude"
BOX_BOOTSTRAP_ROLE="claude-box"

View file

@ -2,7 +2,7 @@
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap claude' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap claude-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
@ -21,7 +21,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap claude'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap claude-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a

View file

@ -1,7 +1,11 @@
# The codex template — a thin, creds-free seed (#81): Debian 13, the
# The codex-box template — a thin, creds-free seed (#81): Debian 13, the
# 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI,
# docker, node, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap codex' after mint (heavy-duty/rig#31).
# box auto-runs 'rig bootstrap codex-box' after mint (heavy-duty/rig#31).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'codex' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
@ -14,4 +18,4 @@ BOX_USER="codex"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="codex"
BOX_BOOTSTRAP_ROLE="codex-box"

View file

@ -2,7 +2,7 @@
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap codex' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap codex-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
@ -21,7 +21,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap codex'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap codex-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a

View file

@ -1,7 +1,11 @@
# The grok template — a thin, creds-free seed (#81): Debian 13, the
# The grok-box template — a thin, creds-free seed (#81): Debian 13, the
# 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI,
# docker, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap grok' after mint (heavy-duty/rig#31).
# box auto-runs 'rig bootstrap grok-box' after mint (heavy-duty/rig#31).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'grok' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
@ -14,4 +18,4 @@ BOX_USER="grok"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="grok"
BOX_BOOTSTRAP_ROLE="grok-box"

View file

@ -2,7 +2,7 @@
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap grok' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap grok-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
@ -21,7 +21,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap grok'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap grok-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a

View file

@ -0,0 +1,22 @@
# The kimi-box template — a thin, creds-free seed (#81): Debian 13, the
# 'kimi' user, tmux and rig. What the box BECOMES — the Moonshot Kimi CLI,
# docker, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap kimi-box' after mint (heavy-duty/rig#31,
# the role itself: heavy-duty/rig#109).
# The template is named for the role it converges, suffix and all
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
# fleet machines, '-box' for box tenants — and a seed that named the bare
# 'kimi' would ask a post-rename rig for a role that no longer exists.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates (the duplication is
# deliberate and by hand) — and it is the tenant user the rig role converges
# (rig dies loudly if the seed did not create it).
BOX_DESCRIPTION="Moonshot Kimi CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="kimi"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="kimi-box"

View file

@ -0,0 +1,33 @@
#cloud-config
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap kimi-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
users:
- name: kimi
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
package_update: true
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
# curl + ca-certificates: the rig installer below rides them, and a bare
# cloud image is not guaranteed to ship either.
packages:
- tmux
- curl
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap kimi-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a
# release flow). The pin covers both the installer fetched AND the tree
# it installs, so a branch under review is testable end to end.
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
# environment, and the rig installer (set -u) reads $HOME for its DEST —
# measured live: the mint died with "HOME: unbound variable" without it.
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash

View file

@ -1,9 +1,14 @@
# The staging template — a thin, creds-free, server-class seed (#81, the
# The staging-box template — a thin, creds-free, server-class seed (#81, the
# re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The
# server posture — docker, sshd hardening — is rig's job: box auto-runs
# 'rig bootstrap staging' after mint (heavy-duty/rig#31). The tailnet
# 'rig bootstrap staging-box' after mint (heavy-duty/rig#31). The template is
# named for the role it converges, suffix and all (heavy-duty/rig#76): rig's
# roles carry a family suffix — '-server' for fleet machines, '-box' for box
# tenants — and a seed that named the bare 'staging' would ask a post-rename
# rig for a role that no longer exists. Server-CLASS, not a fleet machine:
# this is still a box tenant, so it takes '-box', not '-server'. The tailnet
# workload join holds a key and therefore STAYS operator-run:
# box shell <name> # then: sudo rig bootstrap workload --hostname <name>
# box shell <name> # then: sudo rig bootstrap workload-server --hostname <name>
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
@ -21,4 +26,4 @@ BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_REQUIRE_VM="1"
BOX_AUTOSTART="1"
BOX_BOOTSTRAP_ROLE="staging"
BOX_BOOTSTRAP_ROLE="staging-box"

View file

@ -2,9 +2,9 @@
# A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65),
# and rig — nothing that joins a tailnet or admits credentials, no docker,
# no sshd config, no keys. The server posture comes from
# 'rig bootstrap staging' (heavy-duty/rig#31), which box auto-runs after
# 'rig bootstrap staging-box' (heavy-duty/rig#31), which box auto-runs after
# mint; the tailnet workload join holds a pre-auth key and stays
# operator-run ('box shell' → 'sudo rig bootstrap workload'), exactly as
# operator-run ('box shell' → 'sudo rig bootstrap workload-server'), exactly as
# #69 designed it — box never sees the key.
users:
- name: ops
@ -22,7 +22,7 @@ packages:
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap staging'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# 'rig bootstrap staging-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a

File diff suppressed because it is too large Load diff

View file

@ -1,150 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Fixture tests for the labels-reconcile state machine: a comment is a
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
# human), a stale approval does not promote unreviewed code, and an explicit
# human request outranks everything.
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
cd "$(dirname "$0")/.."
# shellcheck source=.github/scripts/labels-reconcile.sh
. .github/scripts/labels-reconcile.sh
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
# machine's inputs, consumed inside the sourced decide_state — not unused.
# shellcheck disable=SC2034
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
pass=0 fail=0
expect() { # $1 = description, $2 = want, $3 = got
if [ "$2" = "$3" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
fi
}
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
}
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
# -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)"
# -- fresh ready PR with bots requested ---------------------------------------
DRAFT=false REQUESTED="$BOT1
$BOT2
$BOT3" REVIEWS_JSON='[]'
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a bot that never reviewed keeps the round open ---------------------------
REQUESTED="" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)")"
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
# -- a comment is a non-verdict, agreement body or not: the author escalates --
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
# ...and the author's escalation — requesting the human — flips it
REQUESTED="$HUMAN"
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
REQUESTED=""
# -- three formal approvals need no author judgment ---------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
# -- changes requested blocks, at any head ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
# -- a stale approval must not promote unreviewed code ------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED old1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
# -- a re-requested bot reopens the round even with an old approval on file ---
REQUESTED="$BOT1"
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
REQUESTED=""
# -- only the LATEST review per bot counts ------------------------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
"$(rev "$BOT1" APPROVED head1 "" t2)" \
"$(rev "$BOT2" APPROVED head1 "" t3)" \
"$(rev "$BOT3" APPROVED head1 "" t4)")"
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
# -- an explicit human request outranks the bot rounds ------------------------
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
expect "human requested outranks bots" state:needs-human "$(decide_state)"
REQUESTED=""
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)" \
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
# ...and re-requesting the human hands it back to them
REQUESTED="$HUMAN"
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
REQUESTED=""
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a stale human APPROVAL likewise needs a re-request for the new head
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
# ...a HEAD-CURRENT human approval needs nothing more
REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
# ...and a live request suppresses re-requesting
REQUESTED="$HUMAN"
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
REQUESTED=""
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]

View file

@ -1,22 +1,10 @@
#!/usr/bin/env bash
# The release flow (#83), proven offline. Run: bash test/release.sh
#
# Three surfaces: the changelog-section extraction release.yml publishes
# (.github/scripts/release-notes.sh, driven against fixtures AND the real
# CHANGELOG.md so the header format cannot drift under it), the
# latest-release tag resolution install.sh defaults to (the extracted
# function, driven against a shim curl serving canned redirects), and the
# three install channels — REAL install.sh runs against throwaway roots,
# with the shim curl standing in for GitHub. Nothing here touches the
# network; the same discipline as test/cli.sh. Deliberately no `set -e` —
# the harness asserts on failing commands.
# Box-specific release-channel coverage. Shared release/guard machinery lives
# in heavy-duty/ceremony and is tested there; this file drives real install.sh.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0 FAIL=0
# check <desc> <want_exit> <want_substr> <cmd...>
# 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
@ -34,264 +22,10 @@ check() {
echo "ok: $desc"; PASS=$((PASS + 1))
}
NOTES="$ROOT/.github/scripts/release-notes.sh"
WORK="$(mktemp -d)"
# ---------------------------------------------------------------------------
# release-notes.sh — the extraction, against a fixture changelog that carries
# every boundary: an Unreleased section that must never leak into a release,
# two adjacent versions, a version that prefixes another (0.7.0 vs
# 0.7.0-rc1), and a stamped-but-empty section that must refuse.
# ---------------------------------------------------------------------------
check "release-notes: runnable bash" 0 "" bash -n "$NOTES"
FIX="$WORK/CHANGELOG.md"
cat > "$FIX" <<'EOF'
# Changelog
Intro prose that belongs to no section.
## Unreleased
- **Not yet released** — must never appear in a release body.
## 0.7.0 — 2026-07-20
### Added
- **The seven-oh entry** — prose for 0.7.0, and only 0.7.0.
## 0.7.0-rc1 — 2026-07-19
- **The rc entry** — must not ride along with 0.7.0.
## 0.6.0 — 2026-07-18
- **The six-oh entry** — the previous release's prose.
## 0.5.0 — 2026-07-15
EOF
check "extract: prints the asked-for version's prose" 0 "The seven-oh entry" bash "$NOTES" 0.7.0 "$FIX"
check "extract: keeps the section's own subheaders" 0 "### Added" bash "$NOTES" 0.7.0 "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: stops at the NEXT section" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "rc entry"' _ "$NOTES" "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: never leaks Unreleased into a release" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "Not yet released"' _ "$NOTES" "$FIX"
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
check "extract: never prints the header itself" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "^## "' _ "$NOTES" "$FIX"
check "extract: the version is matched WHOLE (rc1 is its own section)" \
0 "The rc entry" bash "$NOTES" 0.7.0-rc1 "$FIX"
check "extract: an adjacent older version still resolves" 0 "six-oh" bash "$NOTES" 0.6.0 "$FIX"
check "extract: a missing version refuses by name" 1 "no section for '9.9.9'" bash "$NOTES" 9.9.9 "$FIX"
check "extract: ...and names the ritual that was skipped" 1 "#83" bash "$NOTES" 9.9.9 "$FIX"
check "extract: a stamped-but-EMPTY section refuses" 1 "no section for '0.5.0'" bash "$NOTES" 0.5.0 "$FIX"
check "extract: no version argument is a usage error" 2 "usage:" bash "$NOTES"
check "extract: a missing changelog refuses by path" 1 "no such file" bash "$NOTES" 1.0.0 "$WORK/nope.md"
# The REAL changelog: released sections must keep extracting, or release.yml
# breaks the day it runs — this is the guard against header-format drift.
check "extract: the real 0.6.0 section extracts" 0 "restricted tier" bash "$NOTES" 0.6.0 "$ROOT/CHANGELOG.md"
check "extract: the real 0.5.0 section extracts" 0 "" bash "$NOTES" 0.5.0 "$ROOT/CHANGELOG.md"
# ---------------------------------------------------------------------------
# release.yml — a daemon-free run cannot push a tag, so the wiring is
# grepped, fail-closed (the house discipline): the VERSION assertion, the
# shared extraction script, and that the tag is verified before creation.
# ---------------------------------------------------------------------------
RY="$ROOT/.github/workflows/release.yml"
check "release.yml: exists" 0 "" test -f "$RY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "release.yml: asserts tag == VERSION before creating anything" 0 "" \
grep -qF 'GITHUB_REF_NAME" != "$ver"' "$RY"
check "release.yml: the mismatch creates NOTHING (exit 1)" 0 "" \
grep -qF 'creating nothing' "$RY"
check "release.yml: the body comes from the shared extraction script" 0 "" \
grep -qF '.github/scripts/release-notes.sh' "$RY"
check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \
grep -qF -- '--verify-tag' "$RY"
# ---------------------------------------------------------------------------
# release.yml, the merge door (#96) — merging the release-labeled PR IS the
# release. Same daemon-free discipline: the gate, the four asserts, and the
# same-job tag+publish are grep-pinned, fail-closed.
# ---------------------------------------------------------------------------
check "release.yml: the tag-push trigger is still present (manual fallback)" 0 "" \
grep -qF 'tags: ["**"]' "$RY"
# The merge door rides pushes to MAIN, not pull_request events: a fork PR
# run gets a read-only GITHUB_TOKEN (permissions: cannot raise it), and
# every ceremony PR this org merges is cross-repo from the bot fork — the
# tag create would 403 after green asserts (#97 round 1). The label — the
# operator's intent — is read via the API off the merge commit's PR.
check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \
grep -qF 'branches: [main]' "$RY"
check "release.yml: the doors split on the ref — tags to the tag door..." 0 "" \
grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY"
check "release.yml: ...main to the merge door" 0 "" \
grep -qF "github.ref == 'refs/heads/main'" "$RY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "release.yml: the release label is read via the API off the merge commit" 0 "" \
grep -qF 'commits/$GITHUB_SHA/pulls' "$RY"
check "release.yml: a transition without a labeled PR refuses" 0 "" \
grep -qF "no merged, release-labeled PR is behind this commit" "$RY"
check "release.yml: assert — VERSION at the merge commit is non--dev" 0 "" \
grep -qF '*-dev)' "$RY"
check "release.yml: assert — VERSION changed IN THIS PR (first parent vs merge)" 0 "" \
grep -qF 'git show HEAD^1:VERSION' "$RY"
check "release.yml: assert — no existing tag for the version" 0 "" \
grep -qF 'git/ref/tags/' "$RY"
check "release.yml: assert — no existing release for the version" 0 "" \
grep -qF 'gh release view' "$RY"
check "release.yml: BOTH doors extract notes via the shared script" 0 "2" \
grep -cF 'bash .github/scripts/release-notes.sh' "$RY"
check "release.yml: every failing assert creates NOTHING (both doors)" 0 "5" \
grep -cF 'creating nothing' "$RY"
check "release.yml: the merge door creates the tag ref via the API..." 0 "" \
grep -qF 'ref=refs/tags/' "$RY"
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "release.yml: ...at the MERGE commit" 0 "" \
grep -qF 'sha=$MERGE_SHA' "$RY"
check "release.yml: BOTH doors publish bound to an existing tag (--verify-tag)" 0 "2" \
grep -cF -- '--verify-tag' "$RY"
check "release.yml: tag + publish share one job (the anti-recursion shape)" 0 "" \
grep -qF 'anti-recursion' "$RY"
# The decide step tells the label's two meanings apart (LABELS.md gives
# `release` to release-flow WORK as well as to the ceremony PR — the PR
# that added the merge door included): work under the label no-ops GREEN —
# in the -dev steady state and in the post-release window (bare, unchanged,
# already released) — while every half-ceremony refuses. Pin each verdict
# and the gating output.
check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \
grep -qF "release-flow work under the release label, not a ceremony" "$RY"
check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \
grep -qF "a dev tree is by definition not a release" "$RY"
check "release.yml: decide — post-release-window work no-ops green" 0 "" \
grep -qF "release-flow work merged in the post-release window" "$RY"
check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \
grep -qF "Refusing to guess" "$RY"
check "release.yml: decide gates every later merge-door step on ceremony=yes" 0 "4" \
grep -cF "if: steps.decide.outputs.ceremony == 'yes'" "$RY"
# The release re-arms main itself: the post-release -dev bump is arithmetic,
# not judgment, so it rides the same job — direct push, PR fallback.
check "release.yml: the release bumps main to the next -dev itself" 0 "" \
grep -qF "bump main to the next -dev" "$RY"
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
grep -qF "opening the bump PR instead" "$RY"
# ---------------------------------------------------------------------------
# changelog-armed.sh (#108) — the changelog has a heading for the NEXT entry.
#
# The drift this catches produces no conflict and no error: the ceremony
# stamps '## Unreleased' away, and a PR authored before the release merges its
# entry cleanly into the section that just shipped. box has no top-section
# guard at all today — the two checks above pin only that the 0.6.0 and 0.5.0
# sections still extract, which a disarmed main passes happily.
#
# BOTH states are constructed as real trees and the real script is run against
# them, because the failure mode of the naive fix is precisely a state
# mismatch: an unconditional '## Unreleased' requirement is green on main and
# false on the ceremony PR's own tree, which is why rig#44 and
# heavy-duty/cast#108 both had to revert one. A test that only drives the
# -dev state would have shipped that bug again.
# ---------------------------------------------------------------------------
ARMED="$ROOT/.github/scripts/changelog-armed.sh"
check "changelog-armed: runnable bash" 0 "" bash -n "$ARMED"
# tree <dir> <version> <changelog-body...> — a two-file tree to run against
tree() {
local d="$WORK/$1" v="$2"; shift 2
mkdir -p "$d"
printf '%s\n' "$v" > "$d/VERSION"
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
echo "$d"
}
armed() { bash "$ARMED" "$1/CHANGELOG.md" "$1/VERSION"; }
# --- the -dev steady state: armed is the only legal shape ------------------
T="$(tree dev-armed 0.7.1-dev '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
check "armed: a -dev tree with '## Unreleased' on top passes" 0 "agrees" armed "$T"
T="$(tree dev-disarmed 0.7.1-dev '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
check "armed: a -dev tree WITHOUT it fails — the #108 drift, caught" 1 "MUST carry" armed "$T"
check "armed: ...and the failure says how to fix it (re-arm)" 1 "re-arm" armed "$T"
check "armed: ...naming the issue and its origin" 1 "heavy-duty/rig#66" armed "$T"
# --- the ceremony PR: bare VERSION, BOTH arrangements legal ----------------
# This is the pair that the reverted guards got wrong. Neither may fail, or
# the release PR cannot go green and the ceremony is unshippable.
T="$(tree rel-stamped 0.7.1 '## 0.7.1 — 2026-07-19' '' '- **This release**')"
check "armed: a bare VERSION with its OWN stamped section on top passes" 0 "agrees" armed "$T"
T="$(tree rel-rearmed 0.7.1 '## Unreleased' '' '## 0.7.1 — 2026-07-19' '' '- **This release**')"
check "armed: ...and so does the RE-ARMED ceremony tree (the shape #108 asks for)" \
0 "agrees" armed "$T"
# The one bare-VERSION arrangement that is wrong: a stamp naming another
# version. release.yml would publish a body that is not this release's.
T="$(tree rel-wrong 0.7.1 '## 0.7.0 — 2026-07-19' '' '- **Some other release**')"
check "armed: a bare VERSION under someone ELSE's stamped section fails" 1 "wrong number" armed "$T"
# --- the HALF-ceremony: the gap the two bare-VERSION clauses leave ---------
# VERSION bumped to the release, '## Unreleased' still populated on top, and
# the section for that version never stamped at all. The wrong-number test
# above is false on its FIRST clause here and short-circuits, so before
# heavy-duty/rig#67's rule this tree passed the guard and was refused instead
# by release.yml — at publish time, after the merge, on main, with the release
# already half-shipped. Caught here one step earlier, by running the same
# extraction release.yml runs.
T="$(tree rel-half 0.8.0 '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
check "armed: a bare VERSION whose section was never stamped fails (half-ceremony)" \
1 "no non-empty section" armed "$T"
check "armed: ...and names the stamp as MISSING, not misnumbered" \
1 "MISSING, not misnumbered" armed "$T"
# The wording is the whole point of the separate branch: an operator sent to
# fix a version number that is already correct will not find the real problem.
not_wrong_number() { ! armed "$1" 2>&1 | grep -qF 'wrong number'; }
check "armed: ...and not as the wrong-number case, which has a different fix" \
0 "" not_wrong_number "$T"
# A section that exists but carries no prose is the same failure: release.yml
# would publish an empty body, which is what release-notes.sh already refuses.
T="$(tree rel-empty 0.7.1 '## 0.7.1 — 2026-07-19' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
check "armed: a bare VERSION whose section is stamped but EMPTY fails" \
1 "no non-empty section" armed "$T"
# --- degenerate trees refuse rather than pass by accident ------------------
T="$(tree no-sections 0.7.1-dev 'Prose and no headings at all.')"
check "armed: a changelog with no '## ' section at all fails" 1 "no '## ' section at all" armed "$T"
check "armed: a missing changelog refuses by path" 1 "no such file" \
bash "$ARMED" "$WORK/nope.md" "$ROOT/VERSION"
check "armed: a missing VERSION refuses by path" 1 "no such file" \
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/nope-version"
mkdir -p "$WORK/empty-ver"; : > "$WORK/empty-ver/VERSION"
check "armed: an empty VERSION refuses" 1 "is empty" \
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/empty-ver/VERSION"
# --- and the tree under test, which is the assertion that actually fires ---
check "armed: THIS tree's VERSION and CHANGELOG.md agree" 0 "agrees" \
bash "$ARMED" "$ROOT/CHANGELOG.md" "$ROOT/VERSION"
# The guard is only a guard if CI runs it, and the ceremony is only re-armed
# if the ceremony step says so. Fail-closed pins on both, since a guard nobody
# invokes and a step nobody wrote are the two ways this reverts silently.
check "ci.yml: runs the changelog-armed guard" 0 "" \
grep -qF 'changelog-armed.sh' "$ROOT/.github/workflows/ci.yml"
check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \
grep -qF 'Stamping is two edits, not one' "$ROOT/CONTRIBUTING.md"
check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \
grep -qF 'changelog-armed.sh' "$ROOT/CONTRIBUTING.md"
# ---------------------------------------------------------------------------
# latest_release_tag — extracted from install.sh (the source-the-pure-function
# trick) and driven against a shim curl. The shim serves the ONE seam the
# function uses: -w '%{redirect_url}' on the releases/latest probe.
# ---------------------------------------------------------------------------
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
cat > "$SHIMDIR/curl" <<'SHIM'
#!/usr/bin/env bash
# Fake curl for the release drills: answers the releases/latest probe with
# $FAKE_REDIRECT on stdout (the -w '%{redirect_url}' seam) — or fails with
# $FAKE_CURL_RC (network down) — and serves downloads (-o <file>) by copying
# $FAKE_TARBALL when the URL is $FAKE_SERVE_URL, else exit 22 (curl's own
# 404-under--f code). Every URL is appended to $FAKE_CURL_LOG so a test can
# assert exactly what was asked for, and in what order.
url="" out=""
while [ $# -gt 0 ]; do
case "$1" in
@ -317,105 +51,77 @@ chmod +x "$SHIMDIR/curl"
TAGFN="$(mktemp)"
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN"
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN"
check "latest_release_tag: extracted from install.sh" 0 "releases/latest" cat "$TAGFN"
check "latest_release_tag: extracted function is valid bash" 0 "" bash -n "$TAGFN"
ltag() { # ltag <redirect_url> [curl_rc]
ltag() {
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
}
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \
check "resolve: a pre-release tag survives verbatim" 0 "0.7.0-rc1" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \
check "resolve: a repo with no releases fails" 1 "" \
ltag "https://github.com/heavy-duty/box/releases"
check "resolve: no redirect at all fails" 1 "" ltag ""
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \
check "resolve: no redirect fails" 1 "" ltag ""
check "resolve: a curl failure fails" 1 "" \
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
rm -f "$TAGFN"
# ---------------------------------------------------------------------------
# The three channels, driven through REAL install.sh runs (#83): default =
# latest release, BOX_REF=<tag> = pinned, BOX_REF=<branch> = dev. The shim
# curl serves a fabricated release tarball shaped exactly like GitHub's (one
# top-level directory), and its log proves WHICH URLs the installer asked
# for. FAKE_TARBALL carries VERSION 9.9.9 so nothing collides with the tree
# under test.
# ---------------------------------------------------------------------------
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
echo "9.9.9" > "$SRC/VERSION"
printf '9.9.9\n' > "$SRC/VERSION"
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
ninst() { # ninst <box_home> <box_bin> [VAR=val ...] — install.sh, shim network
ninst() {
local h="$1" b="$2"; shift 2
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
}
# --- channel 1: the default is the latest RELEASE ---------------------------
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \
check "default channel: installs the latest release" 0 "latest release: 9.9.9" \
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
check "default channel: the download is the TAG tarball" 0 "" \
check "default channel: downloads the tag tarball" 0 "" \
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
check "default channel: it never asked for a branch" 1 "" \
grep -q "refs/heads" "$L1"
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1"
check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \
cat "$H1/versions/9.9.9/INSTALLED_FROM"
check "default channel: the install answers through the chain" 0 "box 9.9.9" \
check "default channel: installed binary answers" 0 "box 9.9.9" \
env HOME="$FAKEHOME" "$B1/box" --version
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
check "pinned channel: BOX_REF=<tag> installs that tag" 0 "done" \
check "pinned channel: installs the requested tag" 0 "done" \
ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \
check "pinned channel: skips latest-release resolution" 1 "" \
grep -q "releases/latest" "$L2"
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \
check "dev channel: falls back from tag to branch" 0 "trying it as a branch" \
ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz"
check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \
head -1 "$L3"
check "dev channel: then the branch" 0 "" \
check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3"
check "dev channel: then downloads the branch" 0 "" \
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
# --- the failure is LOUD, never a silent fall-through to main ---------------
H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log"
check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \
check "resolution failure: names the latest-release probe" 1 "could not resolve the latest release" \
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \
check "resolution failure: names BOX_REF as the override" 1 "BOX_REF" \
ninst "$H4" "$B4" FAKE_CURL_RC=6
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \
grep -q "archive/" "$L4"
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions"
check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \
check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4"
check "resolution failure: installs nothing" 1 "" test -e "$H4/versions"
check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \
ninst "$H4" "$B4" BOX_REF=no-such-ref
# ---------------------------------------------------------------------------
# The -dev convention (#83): main's VERSION carries -dev between releases, so
# a dev install lands beside releases in versions/ instead of impersonating
# one — and the docs keep the promises this PR makes.
# ---------------------------------------------------------------------------
check "CONTRIBUTING documents the post-release -dev bump" 0 "" \
grep -q -- '-dev' "$ROOT/CONTRIBUTING.md"
check "CONTRIBUTING documents the release ritual (tag == VERSION)" 0 "" \
grep -qi 'release' "$ROOT/CONTRIBUTING.md"
check "README documents the default (latest release) channel" 0 "" \
grep -qF 'latest release' "$ROOT/README.md"
check "README documents the pinned channel" 0 "" \
grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
check "README documents the dev channel" 0 "" \
grep -qF 'BOX_REF=main' "$ROOT/README.md"
check "README documents the latest-release channel" 0 "" grep -qF 'latest release' "$ROOT/README.md"
check "README documents the pinned channel" 0 "" grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
check "README documents the dev channel" 0 "" grep -qF 'BOX_REF=main' "$ROOT/README.md"
echo "---"
echo "$PASS passed, $FAIL failed"