• 0.8.0 c4f62e5d87

    0.8.0 Stable

    github-actions[bot] released this 2026-07-19 23:04:02 +00:00 | 79 commits to main since this release

    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.

    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 wordsconfirm "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.

    Downloads