Skip to content

0.8.0

Choose a tag to compare

@github-actions github-actions released this 19 Jul 23:04
· 381 commits to main since this release
c4f62e5

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