Commit graph

268 commits

Author SHA1 Message Date
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
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
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
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
Daniel Marin
c4f62e5d87
Merge pull request #114 from dan-claude-bot/release/0.8.0
release: 0.8.0
2026-07-20 00:04:02 +01:00
Claude
8661067468 release: 0.8.0
Stamp the ceremony: VERSION 0.7.1-dev -> 0.8.0 (bare), and rewrite
'## Unreleased' into '## 0.8.0 — 2026-07-19' with an empty '## Unreleased'
put back above it — the re-arm half of the two-edit stamp (#108), so main is
never disarmed between this merge and the automatic -dev bump.

0.8.0 rather than 0.7.1: the section carries an '### Added' (#96, the merge
door), and #105 changes 'box restore' to ask before it destroys — which
breaks non-interactive callers that did not pass --force.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:33:05 +00:00
Daniel Marin
2c4f83814f
Merge pull request #112 from dan-claude-bot/fix/confirm-eof-abort
fix: Ctrl-D at a confirm prompt aborts out loud, not in silence (#111)
2026-07-19 22:27:57 +01:00
dan-claude-bot
bb51aeeca9 fix: close the silent-EOF class at the two host/ gates it also reached (#111)
The first pass fixed #111 where it was reported — confirm() and
uninstall_confirm() in bin/box — and stopped there, while the same defect
sat at two more destructive gates in this repo:

  · host/revoke-user.sh:50 guards 'box revoke --purge', the prompt whose
    own text says "this cannot be undone";
  · host/teardown-host.sh:31 guards a full host teardown.

Both run under 'set -euo pipefail', so an unguarded 'read' returning
non-zero on EOF ends the run before the 'case' that names the abort —
exit 1, in silence, at the moment the script asked. Each now carries the
guard in its own script's wording rather than importing bin/box's die().

The three drill/ prompts are deliberately untouched: they run under
'set -u' only, so EOF falls through to the '*)' arm and already aborts
out loud. 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 — 'while read' loops and '<<<'
herestrings excluded, since neither is a prompt. Removing any of the four
guards makes it fail and name the file:line; it flags nothing else across
the tree's fifteen shell files. The absence of exactly this check is why
the host/ pair was missed the first time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:08:25 +00:00
dan-claude-bot
e27ab239f1 fix: Ctrl-D at a confirm prompt aborts out loud, not in silence (#111)
confirm() and uninstall_confirm() read the operator's answer with a bare
'read -r reply'. Ctrl-D makes read return non-zero, set -euo pipefail ends
the run on that line, and the case below — the only thing that ever says
"aborted." — is never reached. box exits 1 having printed nothing after
the question it just asked.

The cure is one token in each, 'read -r reply || die "aborted."', the same
one heavy-duty/rig#43 applied to rig's credential prompts.

The three answers a human can give (y, n, Ctrl-D) are now driven on a real
pty via util-linux 'script'. They were structurally untested before —
'[ -t 0 ]' sends a terminal-less suite to the refusal branch, so every
existing check stopped there, which is how this survived four releases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:08:25 +00:00
Daniel Marin
41bba74e10
Merge pull request #110 from dan-claude-bot/fix/changelog-rearm
fix: the release ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108)
2026-07-19 22:07:11 +01:00
dan-claude-bot
67331ebecd fix: the armed guard also refuses a half-done ceremony (#108, cast#114 review)
The bare-VERSION branch left the top heading unconstrained on purpose — both
ceremony shapes have to stay legal, which is the rig#44 / cast#108 lesson. A
review round on the sibling fix found the gap that asymmetry leaves: a tree
with VERSION bumped, '## Unreleased' still populated on top, and no stamped
section for that version makes the wrong-number test false on its first
clause, short-circuits, and passes. release.yml then refuses at publish time
— after the merge, on main, with the release already half-shipped.

So the bare branch now also requires the section it is about to publish to
exist and be non-empty, asserted by running release-notes.sh itself so the
guard and the publisher cannot drift over what a section is. The message is
distinct from the wrong-number case: a missing stamp is not a misnumbered
one. Matches heavy-duty/rig#67.

test/release.sh constructs the half-ceremony tree and the stamped-but-empty
tree and drives the real script at both — all three new assertions fail
against the previous guard — while the re-armed and un-re-armed ceremony
trees stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:43:52 +00:00
dan-claude-bot
1a9d96168d fix: the ceremony re-arms CHANGELOG.md, and CI keeps main armed (#108)
Stamping '## Unreleased' away left main with no heading for a PR
authored before the release to land in, so its entry merged cleanly
into the section that just shipped. The ceremony now re-arms, and
changelog-armed.sh enforces it keyed on VERSION -- so the ceremony
PR's own bare-VERSION tree stays legal (rig#44 / cast#108).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:30:41 +00:00
Daniel Marin
9ea50d21e2
Merge pull request #109 from dan-claude-bot/fix/restore-confirm
fix: box restore asks before it destroys, in its own words (#105)
2026-07-19 21:25:59 +01:00
dan-claude-bot
531803a8c9 test: pin the no-TTY refusal wording the rehearsal hit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:47:05 +00:00
dan-claude-bot
b38518ee54 fix: the unattended rehearsal consents explicitly to the restore gate
CI's multi-user rehearsal drives 'box restore' with no terminal, so the
new gate correctly refused it. --force there is the drill proving the
gate is real. README says restore asks, and that scripts must pass it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:46:06 +00:00
dan-claude-bot
12713c5012 fix: box restore asks before it destroys, in its own words (#105)
The 'confirm' precondition existed but the dispatch hardcoded rm's
wording, so giving restore the token would have asked the operator to
confirm deleting the box they were rescuing. The prompt is now a
per-row field; restore names the snapshot and the loss.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:40:39 +00:00
Daniel Marin
90ca56cd3f
Merge pull request #106 from dan-claude-bot/fix/ufw-test-flake
fix: the fresh-UFW test block no longer flakes on a missing log
2026-07-19 19:54:46 +01:00
dan-claude-bot
57db9a56bb fix: teardown-host carried the same SIGPIPE race — it sets pipefail (#106 review)
The original diff claimed all three sibling `ufw status | grep -q` sites were
safe because none set pipefail. That is true of drill/wipe.sh and
drill/doctor.sh (both `set -u`) and FALSE of host/teardown-host.sh, whose
line 12 is `set -euo pipefail`. Its line 60 was the identical pipeline, so
the same race could read a plainly-active UFW as inactive and skip the whole
crumb-removal block — leaving stale boxnet/claudenet rules on a host the
operator was told is clean. Its numbered-delete loop had the same early-exit
reader as its condition, so it could also end while rules remained.

Both now read captures, matching box-firewall.sh's fix. The changelog claim
is corrected rather than deleted: this repo's changelog is the record of what
was proven, and shipping a disproven safety claim about a live defect is
worse than the defect, because it tells the next reader not to look.

Pinned in both directions, with comment lines stripped before matching — the
fix's own commentary quotes the racing shape to explain it, and a pin that
cannot tell prose from code fails on the comment documenting why it exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:18:36 +00:00
dan-claude-bot
dc09d54cbe fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `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 the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.

`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.

Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.

Closes #102

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:17:42 +00:00
Daniel Marin
9649659aff
Merge pull request #101 from dan-claude-bot/fix/grant-incus-admin
fix: box grant provisions incus-admin members instead of refusing them
2026-07-19 19:05:34 +01:00
dan-claude-bot
7067fb03b0 docs: box help matches what grant/revoke now mutate (#101 review)
Round 2 changed the incus-admin path from a skipped group step to a real
`usermod -aG incus`, but `bin/box` help still described the superseded
design — telling operators the group step is a no-op that will not happen
(it does) and that a bare revoke has no membership to drop (it drops one).

The help is the pre-run contract: it is what an operator reads to decide
whether to run the command at all, so prose that denies a mutation the
command performs is the same class of defect the rest of this PR exists to
remove. Both sections now describe the current behavior, and `help revoke`
carries the consequence the operator would otherwise discover: once `incus`
is gone, a later `gpasswd -d <user> incus-admin` lands them in NEITHER
group, so grant's "no re-grant needed" holds only while they hold `incus`.

Pinned in both directions — the current sentence must be present and the
superseded one absent — so the prose cannot drift from the scripts again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:33:37 +00:00
dan-claude-bot
8598cf96fe fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:

  /var/lib/incus/unix.socket       group incus-admin  0660
  /var/lib/incus/unix.socket.user  group incus        0660

incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.

The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:

- the backout rolls that membership back and verified, while refusing to call
  the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
  of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
  a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
  discipline about /var/lib/incus lying to a non-root admin

Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.

Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:33:37 +00:00
dan-claude-bot
4a06c3ac00 fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.

box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.

On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.

Unblocks heavy-duty/rig#49.

Closes #99

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:33:37 +00:00
Daniel Marin
f2f57cca7f
Merge pull request #97 from dan-claude-bot/feat/release-on-merge
feat: merging a release-labeled PR is the release
2026-07-19 18:12:28 +01:00