diff --git a/CHANGELOG.md b/CHANGELOG.md index 4209cd5..adffdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,1228 +7,144 @@ which records not just what changed but what each drill run proved. ### Added -- **`box import` records that the trip happened — without rewriting who the - box was** (#131) — an imported box kept the artifact's mint stamp verbatim - (#103), which is 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. It now stamps `user.box.imported` - / `user.box.imported.by` (the **first** trip, pinned once and never - rewritten), `user.box.imported.last` / `user.box.imported.last.by` (the - **latest**, refreshed on every arrival) and `user.box.imported.count` — a - birth pair plus a latest pair, the shape heavy-duty/rig#61 settled on for - the same repeated-event question, because last-wins alone would erase the - evidence of every earlier trip. This is deliberately **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 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 is left exactly as it arrived. `box info` - prints an `IMPORTED` line 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 (`the mint above predates it`) and never claims another - host, since 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 a breaking change — and is not written by the - import at all, so a legacy artifact with no stamp still reads as - `MINTED (not recorded)` rather than acquiring a shape it does not have. -- **A minted box records how it was minted, and `box info` reads it back** - (#103) — `cmd_new` knew a great deal at the moment it launched and wrote - three `user.*` keys, dropping the rest on the floor: the box version that - minted it, the base image (an *unpinned alias on a moving remote* — two - boxes minted a month apart from "the same template" are not the same box, - and nothing on either said so), 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 simply 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* (an integer, not the box version: it moves only when a key - is removed or repurposed, never when one is added). The alias's resolved - fingerprint is pinned in a second call after the launch, read from - `volatile.base_image` — best-effort by construction, because a box that - exists and boots must never be failed over a provenance field. Deliberately - not stamped: cpu/memory (`limits.*` hold them, and a duplicate drifts the - first time someone edits a limit by hand), disk (a VM's *is* the root - device size and a container's does not exist — its root rides the pool), - and the tier, which `box_tier()` derives from whoever is *asking*. -- **A clone re-stamps its own provenance instead of inheriting a lie** - (#103) — `incus copy` carries every `user.*` key forward (drill audit item - B2), which is what makes a clone know its template and user for free and is - exactly why the mint stamp could not ride along untouched: an inherited - stamp does not go stale, it goes **false**, claiming a mint time the clone - was not present for and a box version that never saw it. `box new --from` - now re-stamps the four keys that describe *this* instance's coming into - being — schema, version, created, `origin=clone` with `origin.from=` — - on the copied instance *before* it is started, so a clone is never - observable wearing its source's provenance. The lineage keys (template, - user, image, role, rig pin) are left alone on purpose: the clone's disk - genuinely did come from them, and re-deriving them from the cloning - process's own template lookup would be the actual lie. `origin.from` - records **one hop** — a clone of a clone names its parent and forgets its - grandparent, because the alternative is an unbounded chain in a config - value and the parent is the box an operator can go look at. - One key sits in **neither** column and is therefore *cleared*: - `user.box.mode.asked`. It records whether a container was asked for or - fallen back into, which makes it a mint-*event* fact — and the asker was - the **source's** operator. A clone refuses `--vm`/`--container` outright, - so nobody was asked anything about this instance, and there is no true - value to re-stamp it with; inheriting it made `box info` print - `MODE vm (asked: auto)` on a clone, describing a demand never made of it. - The clear lands with the re-stamp, before the start, and the read side - needs no special case: the `MODE` line is gated on `asked`, so absence - renders as silence while `TYPE` still reports VM or CT off the preserved - instance type. -- **`box info` grew a provenance block** (#103) — it printed - `NAME / STATE / TYPE / IPV4`, exposures and snapshots, and surfaced *none* - of the `user.box.*` keys, including the two that already existed. A stamp - nothing can read is not a stamp. Every key is read with absence tolerated: - `incus config get` on an unset key prints empty and exits 0 (audit item - B4), so "no stamp" and "the daemon said no" arrive identically, and both - must render as a box with blanks. **Boxes minted before this stamp existed - keep working under every verb** — the README's standing promise, and the - same one `user.claudebox` carries — showing `MINTED (not recorded — this - box predates the mint stamp)` rather than an invented time or an error; a - pre-rename box still reads as the claude template. A schema this box does - not recognise is treated as *newer than me*: it shows what it understands - and says so, because a box outlives the release that minted it and refusing - to describe one a later release minted perfectly well is the wrong answer. - `box info --json` needed no code — the keys ride `incus list --format json` - in `config`. `test/cli.sh` drives both halves against a fake incus that - logs the arguments box builds, including the absence assertions that keep - cpu/memory/disk/tier out of the namespace and the lineage keys out of the - clone's re-stamp. - -- **Every fresh mint marks a `pristine` snapshot, before rig converges - anything** (#104, child of heavy-duty/rig#62) — `cmd_new` runs launch → - `wait_agent` → `cloud-init status --wait` → the rig bootstrap hook, and in - the gap between the last two the guest **is** pristine Debian plus box's - thin seed. That is exactly the state rig#62's "back to pristine Debian" - names; it existed for a few seconds on every mint box has ever taken, and - nothing captured it. Now `box restore pristine` is the whole answer - for every creds-free tenant role, because 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. - Default on and unconditional within a fresh mint (a blank box has no rig - hook but has the same moment, and the label must mean one thing on every - box), never fatal (a mint that worked is not failed by a checkpoint that - didn't), and **never taken on a `--from` clone**: a clone skips cloud-init - and rig entirely, so it has no pristine moment, and a mark taken there - would be the source's worked-in state wearing a label that promises - pristine Debian. A clone inherits its source's snapshots (copying a box) or - starts with none (copying a snapshot), and `box new` says which. On a host - whose pool uses the `dir` driver — the documented fallback in - `host/setup-host.sh`, where there is no copy-on-write and the mark would be - a full multi-GB copy of the root on every mint — the mint skips it loudly - and names the by-hand command, rather than silently doubling every mint's - disk cost; the driver is read back from the `box-net` profile's own root - pool, so it is the pool that actually placed the instance. Snapshots do not - outlive their box (`box rm` takes every one with it), so the help text, - README and design doc say plainly that this is an **undo, not a backup** — - `box export` remains the durable path — and that it cannot reach off-box - state such as a tailnet join or a runner registration. `BOX_SNAPSHOT_PRISTINE=0` - opts out, in the `BOX_LAUNCH_TIMEOUT` shape rather than a new flag. - The never-fatal contract is now structural rather than incidental: all three - storage probes carry `|| true`, so a restricted tier's refusal stays an - answer even if `inherit_errexit` is ever switched on in `bin/box` (#107's - class), and the clone's inheritance read captures before it greps rather - than piping a multi-line `incus` writer into an early-exit reader (#124's - class), which under `pipefail` could have narrated the wrong inheritance - shape on a clone that does carry a `pristine`. - -- **A mint that converges a tenant role marks a `bootstrapped` snapshot after - the hook succeeds** (#130, the half #104 deferred and #128 argued out) — - `pristine` throws the tenant role away; `bootstrapped` keeps it and throws - away only what you did afterwards, which is the undo an operator reaches for - far more often and which otherwise costs a ~10-minute re-mint. Same policy - as `pristine`, sharing one function with it (`snapshot_mark`) so the - never-fatal contract exists in exactly one place: default on, - `BOX_SNAPSHOT_BOOTSTRAPPED=0` opts out, a `dir` pool skips it loudly — with - two marks that disk objection is twice the size, so a CoW-less host is not - asked to pay for one full root copy per mint, let alone two — and a failed - snapshot warns without failing a good mint. **Deliberately conditional where - `pristine` is unconditional**: `pristine` marks a *moment* every fresh mint - has, `bootstrapped` marks an *event* — a rig hook box ran and watched - succeed — and a blank box has no such event, so it gets no mark rather than - a byte-identical duplicate of `pristine` at twice the cost. When the hook - **fails**, box takes no mark either and says so where the operator is - looking: the failure message now hands over `box snapshot - bootstrapped` to take after the by-hand re-run, because box will not label a - convergence it did not watch. So the label is documented as one-directional - on every surface — its **presence** means the hook converged and nothing has - touched the box since; its **absence** means nothing at all (a blank - template, a `dir` pool, an opt-out, or a hand-converged box). Same durability - caveats as `pristine`, restated rather than referenced: it dies with the box - on `box rm` (`box export` is the durable path), and no filesystem rollback - reaches off-box state such as a tailnet device record or a runner - registration (heavy-duty/rig#62). +- `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) ### Changed -- **`state:needs-human` no longer waits on the cron to become true** (#141) - — the labels workflow now also wakes on `pull_request_target: labeled` and - `unlabeled`, and the author sets `state:needs-human` themselves when handing - a PR to the maintainer. - - A review landing was never a trigger. There is no - `pull_request_review_target`, and on fork PRs — which is all of them here — - `pull_request_review` runs with a read-only token and cannot label anything. - So the exact moment the label became true, the third bot approving, fired - nothing at all: the label waited for the `*/15` cron, or for somebody to - touch an unrelated PR. And that cron does not run at its declared rate — - GitHub deprioritises short intervals hard enough that, measured across the - three repos over a two-hour window on 2026-07-20, each got **one** scheduled - run against the eight `*/15` implies. heavy-duty/rig#94 took its third - approval and sat on `state:bots-reviewing` for hours; box and cast happened - to catch a tick and flipped correctly, on byte-identical workflow files. The - lag was worst on the quietest repo, which depends on the cron most and - receives it least. - - The two halves fix each other: the author's own label write is what fires - the sweep that validates it. That makes it an optimistic write rather than a - transfer of ownership — seconds later the reconciler either confirms the - label or corrects it, and the cron falls back to being a genuine last - resort, for the round an agent forgets to hand off. It cannot loop, because - the reconciler's own writes use `GITHUB_TOKEN` and GitHub does not create - workflow runs from `GITHUB_TOKEN`-triggered events, while agent writes use a - PAT and do. `labels-reconcile.sh` is unchanged: it already recomputes every - open PR from scratch on every run, which is exactly what makes the - optimistic write safe. The `scope` job is skipped on the two new actions, - where no path can have changed and labeler has nothing to derive. - - Landed in all three repos together (heavy-duty/rig#97, - heavy-duty/cast#132) — `labels.yml` and the label taxonomy are shared. - -- **PR labels split into two axes: `state:*` (whose ball) and `blocker:*` - (what is in the way)** — `state:needs-rebase` is retired, replaced by - `blocker:conflict`, `blocker:ci-red` and `blocker:unrequested`. One rule - joins them: `state:needs-human` requires zero blockers. - - The single-label design forced independent facts through one totally-ordered - value, and the ordering was where every bug lived. Mergeability, check - status and the review round move independently — a PR can be conflicted - *and* red *and* stalled at once — so a total order has to pick a winner and - silently drop the rest. `state:needs-rebase` was the clearest casualty: it - fired on both a conflict and a failing check, which need opposite work, and - told an agent to rebase when what it owed was a bug fix. On this repo's own - board, #120 was conflicted **and** red and could only say one of them. - - Blockers are a set, so there is no precedence between them to get wrong. - What is left on the ordered axis is purely about reviews, which is the one - place an ordering is genuinely meaningful. - - `state:bots-reviewing` also tightens to mean strictly *a request is live and - an answer is coming*. A ready PR nobody was asked to review read "waiting on - the reviewers" for the 48 hours it took the stale sweep to notice; it is now - `state:addressing` + `blocker:unrequested`, because the agent owes the ask. - Drafts are exempt (the bots ignore drafts by design), as is an explicit - human request — a maintainer claiming a PR early is deliberate. - - The reconciler strips `state:needs-rebase` on sight, so the retirement heals - the board rather than stranding a label nothing recomputes. It also never - *names* a label the repo does not have: `gh issue edit` rejects the whole - call on one unknown name, so on a repo whose taxonomy predates this change - an unbootstrapped `blocker:*` would otherwise take the state convergence - down with it, on exactly the PRs the change exists to fix. Adds are filtered - against the repo's real label set and the shortfall is logged — and a - taxonomy gap skips only the label edit, never the `merge-next` clearing or - the stale sweep, which do not depend on the `state:*` set. - Fixtures 51 → 72. - -- **The tenant templates carry rig's family suffix: `claude` → `claude-box`, - `codex` → `codex-box`, `grok` → `grok-box`, `staging` → `staging-box`** - (#123, following heavy-duty/rig#76) — 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 itself — `-server` for fleet machines, `-box` for box tenants — - and box's answer is that a template keeps being named for the role it - converges. Templates are the only surface that spells a rig role out loud - (`BOX_BOOTSTRAP_ROLE`, auto-run at mint since #81), so a template whose - directory says one thing and whose role key says another is a trap with a - 15-minute fuse: it mints clean and dies at convergence. `blank` keeps its - name — it seeds no tenant role, sets no `BOX_BOOTSTRAP_ROLE`, and - therefore has nothing to agree with. 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`, because that is the user - the rig role converges and the one `box shell` lands in. `test/cli.sh` - pins the pair per tenant rather than each half alone — a future rename - that moves one and forgets the other mints a box whose role dies looking - for a user nobody created. - - **This lands after rig's rename, not before, and the ordering is not a - preference.** The seeds install rig from `RIG_REPO`/`RIG_REF`, which - default to `heavy-duty/rig@main` and are unpinned until rig#32's releases - — so a box minted from these templates asks whatever `main` happens to be - for `rig bootstrap claude-box`. Against a pre-rename rig that role does - not exist, `cmd_new` refuses to call the box ready, and the operator is - handed a failed mint for a change neither repo has finished making. Merged - in the other order the window closes instead of opening: rig's roles are a - hard cut with no aliases, so the day rig's rename lands, every unmerged - box seed naming a bare role is the broken one. - - One deliberate asymmetry: the mint-time hints in `cmd_new` match both the - new and the old spelling of `user.box.template`. That is not an alias for - the role — nothing here softens the cut, and `rig bootstrap claude` is - gone. It reads a stamp left on an *instance* at its own mint time, which - every box minted before today carries forever and every clone carries - forward; refusing the old spelling would cut nothing over and only drop - the login hint on boxes that predate the rename, 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. - - The **machine**-role half of rig's rename reaches box too, in one place: - the tailnet workload join box prints as a next step for a `staging-box` - guest is now `sudo rig bootstrap workload-server`. box never runs it — it - holds a pre-auth key, and that it stays operator-run is the absence - keeping 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. +- `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 -- **`changelog-monotonic.sh` no longer lets a duplicate heading through on the - paths where it cannot see the base** (#143) — the uniqueness half is a - property of HEAD alone, but it sat downstream of the base-ref, merge-base and - base-blob conditions, so each of those degradations returned success on a tree - with a duplicate in plain sight. - - The base-blob case was the worst of the three because it was not a skip at - all: a branch that *introduces* `CHANGELOG.md` exited 0 through a bare - `exit 0`, on a message that was true about deletion and silent about the - duplicate in front of it. `STRICT=1` could not reach it — STRICT guards the - two `skip()` calls, and that path is not one of them. Off CI the two skips had - the same shape, so a shallow clone or an unpacked tarball would not look at a - duplicate the author was about to push. - - This inverted the value of the two halves. Deletion is the failure that needs - a diff to see; duplication is the one `release-notes.sh` actually mis-renders, - re-arming its grab on the second heading and absorbing whatever sits between - the copies (#118). The half with the live extraction bug behind it was the - half with the most ways to silently not run. - - Fixed by moving, not rewriting: uniqueness now runs directly after the file - exists, before any git access. The skip messages say *containment* skipped and - that uniqueness already passed, so a skip no longer claims nothing was - checked — and the success line got the same treatment, because dropping the - gate made `merge_base == HEAD` a routine path rather than a degradation. On a - push to main containment compares the file against itself and asserts nothing, - so the line now reports containment *vacuous* and names uniqueness as the half - that ran, instead of claiming N headings were verified present by a comparison - that could not have detected their absence. - - The guard is also no longer gated to `pull_request` — deletion is vacuous on - a push to main, but duplication is vacuous on no tree, so a - duplicate reaching main by any other route went unasserted. That gate could - not simply be dropped: `github.base_ref` is empty on a push, and a bare - `origin/` under `STRICT=1` is a hard failure on every push to main, so the - base ref falls back to `github.ref_name`. - - Found by `claude-bot-andresmgsl` reviewing the ports in heavy-duty/rig#99 and - heavy-duty/cast#134, which inherited the ordering from here. - -- **A failed rollup read no longer reads as "nothing is failing"** — when - `gh pr view` returned nothing, the fallback left the `statusCheckRollup` - *key* absent, and `checks_state` collapsed that into the same `NONE` as a PR - that genuinely has no checks. `NONE` blocks nothing, so a transient API - failure presented as mergeable-by-a-human: the same - unknown-certified-as-green shape as #136, surviving in the one place that - fix never looked. - - `checks_state` now distinguishes the two — `UNREADABLE` for an absent key, - `NONE` for a present-but-empty array — and the sweep leaves an `UNREADABLE` - PR exactly as it is rather than relabelling on facts it did not read. It is - deliberately **not** a blocker: blocking on it would flap the entire board - on one bad API call, and the next tick is fifteen minutes away. Fixtures - 64 → 66. - -- **`state:needs-human` no longer appears on PRs a human cannot merge** (#136) - — `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 on this repo, 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 said *merge me* over a tree no - reviewer had seen, and unlike the conflict case, nothing on the page - contradicted it. - - The rule the label now keeps is that **`state:needs-human` means a human - could merge this right now**, so anything making that false outranks the - request that put it there. A `CONFLICTING` branch or a failing check is the - agent's to fix: new `state:needs-rebase`. Approvals staled by a push mean - nobody reviewed this tree: `state:addressing`, because the agent owes a - re-request. 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). Precedence is applied to the round as a whole, after every - verdict is collected: deciding inside the loop let the order of `BOTS` pick - the answer, so a round that was *both* unfinished and staled returned on the - `MISSING` before any later bot's `STALE` was read — and came out - `needs-human` over a head nobody had reviewed, the original bug wearing a - different hat. - - Whether a check blocks is judged by listing the outcomes that *don't* — - `SUCCESS`, `NEUTRAL`, `SKIPPED`, and the pending set — rather than the - outcomes that do. The rollup mixes two closed enums (`CheckRun.conclusion` - and `StatusContext.state`), and an outcome the list forgets is one the label - cannot certify as mergeable: `ERROR`, `CANCELLED` and `STALE` all read as - green under an allow-list of failures. The costs are not symmetric — a false - failure parks the PR on the agent, who looks; a false success invites a human - to merge a tree that will not merge. Superseded runs are dropped first, each - context collapsing to its newest entry: a re-run does not evict the run it - replaced, so this PR's own tip carried a `CANCELLED` `scope` beside the - `SUCCESS` `scope` that superseded it, and judging every entry would have - stranded every re-run PR in `needs-rebase`. - - A run is dated by **when it began**, which took two corrections to get right - and both restored #136 in the meantime. Dating on completion fails because a - run still in flight does not omit its completion — `gh` marshals the Go zero - time as the *string* `"0001-01-01T00:00:00Z"`, which `//` will not fall - through — so the live re-run sorted to the bottom and the run it superseded - was judged instead. Taking the *newest* stamp a run carries fails for a - subtler reason: it resolves to `completedAt` for a finished run and - `startedAt` for a live one, which are different quantities, so it never - ordered runs at all. A run cancelled by the concurrency group drains *after* - its replacement starts — 13 seconds on this PR's own `aa5a6ba` — so the dead - predecessor routinely out-dated the live run replacing it, and a green - predecessor in that window read `SUCCESS` with a re-run still in flight. - Start time has neither failure: a replacement always begins after the run it - replaces, whatever order they finish in. An entry carrying no usable stamp - sorts last rather than first, so an undateable in-flight run is never - discarded in favour of a stale success — every ambiguity resolves toward - "not settled". - - `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. 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, which - is precisely the staleness that made `needs-human` untrustworthy. Both live - shapes, the mixed round, the whole check-outcome enum, and the in-flight - re-run superseding both a green and a cancelled predecessor — in both - directions, since a run that *finished* after an earlier in-flight entry - settles the context, and across the drain window where the predecessor - completes last — are pinned in `test/labels-reconcile.sh` - (19 fixtures → 51). - -- **CI's shellcheck sweep never lints `.github/scripts/*.sh`** (#116) — - `globstar` makes `**` descend into subdirectories, but a glob still does - not *match* a dot-prefixed name, so `**/` never entered `.github/`. The - three scripts that escaped are the release path: `changelog-armed.sh` (the - #108/#110 guard that gates every PR, and had never been linted), - `release-notes.sh` (which produces the published release body), and - `labels-reconcile.sh` (the label state machine) — while the step's own - comment promised that "a script in a new subdirectory is linted without - anyone remembering to edit this list". Latent, not broken: all three pass - shellcheck as-is, so this lands as a no-op on current code and the fix is - that a regression in them would now be caught. `dotglob` alongside - `globstar` closes it, measured rather than assumed — it adds exactly those - three and nothing else, a checkout's `.git` carrying no `*.sh` (its hooks - ship as `*.sample`). Paired with a 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 the gap cannot reopen silently the next time a dot-directory or - a shopt subtlety hides one. `eof_guard_sweep` itself carried the identical - blind spot — it rebuilds the same glob — and is widened the same way. - -- **A PR can no longer delete a shipped changelog section and stay green** - (#122) — caught in review of #118, where an entry added under - `## Unreleased` *replaced* the line `## 0.8.0 — 2026-07-19` instead of - being inserted above it. The whole shipped 0.8.0 record was absorbed into - `## Unreleased`, git merged it cleanly — a one-line edit, no conflict, no - signal — and `changelog-armed.sh` was green on that exact tree, correctly: - it asks only whether the TOP section agrees with `VERSION`, and - `## Unreleased` was still on top. The damage would have surfaced at the - next release, when `release-notes.sh` could no longer find the section it - extracts by heading, or worse, republished the absorbed prose as new. - `.github/scripts/changelog-monotonic.sh` asserts the complementary - invariant on every PR: release headings are **append-only**, so the set of - `## X.Y.Z` headings on a branch must be a **superset** of the set at its - merge base. A separate script rather than a clause in `changelog-armed.sh` - because "a heading disappeared" is a property of a DIFF, not of a tree — - and because `changelog-armed.sh` is driven against constructed non-git - fixtures that could not express it. The ceremony's stamp passes by - construction (it adds `X.Y.Z`, removes none), and no base ref to compare - against is a loud SKIP locally but a hard failure in CI, which sets - `CHANGELOG_MONOTONIC_STRICT=1` and checks out with `fetch-depth: 0` so the - guard can never quietly stop guarding. - - Review of this PR found the guard's first cut incomplete, and the gap is the - shape the incident *actually* had. Containment catches a **deleted** heading; - it cannot catch a **duplicated** one, because 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`, and multiset comparison does not close it for - the same reason. So the guard now also asserts that version headings are - **unique on HEAD**, alongside containment rather than instead of it. Nothing - legitimate repeats one: the ceremony stamps a new version, and `Unreleased` - fails the version shape. Both trees are pinned in `test/release.sh` — 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. - -- **An upgrade over a pre-0.7.0 flat `/opt/box` no longer skips host setup** - (#115) — found on the first real host the 0.8.0 drill touched. The - installer migrates a flat pre-0.7.0 tree into `versions/`, and - `had_install` was computed *after* that migration — so it observed a - `versions/` directory the migration had just created, concluded the host - was already installed, and skipped `host/setup-host.sh`. The result was - silent and self-concealing: `box --version` reported 0.8.0 while every - host-side artifact stayed as the old release left it, so the very - operator who upgraded *for* the #102 `box-firewall` SIGPIPE fix was the - one who did not receive it, with the version string asserting otherwise. - `had_install` is now computed **before** the migration block, which is - the honest question — a tree that needs migrating has by definition never - been converged by this version's `setup-host`. Hosts already on the - versioned layout are unaffected: they still read `had_install=1`, for the - right reason. The consequence is deliberate: an unattended (`BOX_YES=1`) - upgrade on a flat-tree host now *runs* `setup-host`, which the #66 note - cautions about — accepted, because `setup-host` converges and is - idempotent, and shipping a release whose host half is silently missing is - the worse failure. -- **Host setup runs the version it just installed, not whatever `current` - points at** (#115) — a second defect in the same block, reachable only - once the fix above lets `setup-host` run at all. The `#66` guard holds - the default where it is when the host has existing boxes, so on such a - host `current` still names the OLD version; running - `$DEST/current/host/setup-host.sh` would then converge the host with the - *previous* release's host scripts, reinstating exactly the staleness #115 - is about, in the one case where the operator's live boxes make it - costly. It now runs the installed version's own tree directly. -- **The pre-0.7.0 migration says what it left behind** (#117) — the - migration named itself, but not the *lifecycle*: the old tree becomes a - first-class `box versions` entry the operator never installed, and which - is indistinguishable from one they deliberately kept as a rollback - target. The migration line now names both ways out — keep it to roll back - (`box use `) or reap it (`box uninstall `) — and the closing `done` - summary re-states it, because the migration line itself scrolls past some - 250 lines before the install ends. Deleting it automatically stays the - wrong default: it is the only thing to roll back *to*, at exactly the - moment that matters. No behaviour change. - -- **`teardown-host.sh` refuses a terminal-less run instead of aborting mute** - (#113) — the most destructive script in the tree was the one that would not - tell a non-interactive caller how to proceed. With no `--yes`/`BOX_YES` and - no TTY it fell straight into `read`, took the instant EOF and exited 1 - saying only `aborted` — a refusal that names neither the cause nor the - override. It now checks `[ -t 0 ]` and refuses with - `--yes (or BOX_YES=1) means yes.`, exit **2** — "you invoked this wrong", - the same contract and the same code as `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 — CI's uninstall drill and - `box uninstall --purge-host --force` forward `--yes` and are unaffected — - and *above* the first `incus` call, so the refusal costs no daemon and - `test/cli.sh` drives it for real rather than grepping for it. - -- **`drill/wipe.sh` no longer carries #102's SIGPIPE shape, and the pin now - sweeps the class** (#107) — the file piped `ufw status` straight into - `grep -q "Status: active"`. `Status: active` is ufw's FIRST line, so the - reader matches, closes the pipe, ufw dies of SIGPIPE, and the pipeline - yields 141. This was **correct today and only by accident**: `wipe.sh` is - `set -u` with no `pipefail`, so the 141 was discarded and grep's 0 carried - the branch. It was also one line from wrong — adding `set -o pipefail` for - unrelated robustness, the kind of tweak that reads as an obvious - improvement, 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). Now it captures `ufw_status` once and matches with `[[ ]]`, and - the numbered-delete loop — whose condition was itself an early-exit reader, - plus an un-captured re-read to get the number — reads one capture per - iteration and breaks on absence. That is #106's pattern transplanted - verbatim from `host/teardown-host.sh`, so both files now read alike. - The `test/cli.sh` pin is **generalized from the single site to the class**: - it sweeps every `host/*.sh` and `drill/*.sh` for the racing shape and names - 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 very comment documenting why it - exists. (`drill/doctor.sh` was checked and needs nothing: it already reads - into `ufw_out` and is safe by construction, not by absent `pipefail`.) - -- **The racing-reader sweep guards the class, not one spelling — and now - names a second writer** (#124) — the sweep #107 added matched - `ufw status[^|]*\| *grep`, which caught 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 identical 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. The reader half is deliberately not - narrowed to the early-exit spellings — separating `grep -q` from `grep -c` - by regex is precision that rots, and every `ufw status` site in the tree - already captures first, so banning the pipe outright costs nothing. - The writer half gains `incus config trust list`, because - **`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 sitting 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 reported the - purge complete: fail-open, on the path whose whole job is to prove access - is gone. Now captured into `trust_csv` and matched with `[[ ]]` (leading - newline so the first CSV row still anchors like the `^` it replaces — - verified equivalent to the old `grep` across nine anchoring cases). Writers - are enumerated rather than generalised: ~150 legitimate `| grep` sites - exist under `host/` and `drill/`, nearly all re-reading an already-captured - string, so the sweep claims only what it can check and grows one named - writer at a time. The `id -nG | tr | grep -qx` shapes in grant/revoke/setup - are deliberately untouched — single tiny writes, not realistically racy. +- `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 `` back to snapshot - `