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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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
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
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>
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>
The permission-starvation blocker found on the cast twin (claude-bot,
cast#112 round 4) is identical here: a declared permissions: block zeroes
every unspecified scope, so the decide step's label read and the bump
fallback's gh pr create could only 403 — every genuine ceremony would end
red at the label check. pull-requests: write added with the consumers
named. CONTRIBUTING step 3 and the changelog entry now tell the shipped
story: push-to-main door, event.before interlock, self-re-arm with the
manual path's bump staying the operator's. Re-runs wording nit taken.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator decision: the post-release bump PR is ceremony debris — a
derivable one-liner with no judgment for a review to add. After tag +
publish, the same job computes X.Y.(Z+1)-dev and pushes it to main
directly (a GITHUB_TOKEN push fires no workflows: no recursion, no red
run); if branch protection refuses, the step opens the bump PR itself,
loudly. #98 is the last hand-made bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blocker (claude + grok): a pull_request run from a public fork
gets a read-only GITHUB_TOKEN — permissions: cannot raise it — and every
ceremony PR this org merges is cross-repo from the bot fork, so the tag
create would 403 after green asserts, red on main per release. The door
now triggers on push to main (in-repo event, full token); the decide step
keeps the first-parent version interlock, and the release label — still
the operator's declared intent — is read via the API off the merge
commit's PR. A transition with no labeled PR behind it refuses. The doors
split on the pushed ref: tags to the tag door, main to the merge door.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four-state table called '-dev but changed' half a ceremony and
refused — but that state IS the mandatory post-release bump PR
(bare -> X.Y.(Z+1)-dev after every release), which would have put a red
run on main once per release, forever. A tree that ends -dev is by
definition not a release: every such merge is work, and no-ops green
with a NOTICE. The red verdicts now guard only bare endstates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LABELS.md gives 'release' to release-flow WORK as well as to the ceremony
PR — the PR that added the merge door included. The old assert pair turned
every such merge into a red run on main. The fused decide step reads
VERSION against the merge commit's first parent and answers all states:
-dev unchanged = work, green NOTICE no-op; bare unchanged but already
released = work in the post-release window, same no-op; -dev-but-changed
and bare-unchanged-never-released = half-ceremonies, refused loudly;
bare-and-changed = the ceremony. Later steps gate on its output. Five new
pins in test/release.sh cover each verdict and the gating.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0.7.0 ceremony exposed the gap: the release PR merged with four
approvals and nothing happened, correctly, because publishing hung off a
separate, manual, silent-when-forgotten tag push — the worst failure
shape, no error and no red X. The ship decision already lives in the
release PR, so the merge now IS the release.
release.yml grows a second door: pull_request closed on main, gated on
merged == true AND the hand-set release label (read from the event
payload — no extra permission). Four asserts, in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in this PR (merge vs first parent — the -dev interlock that
kills a mislabeled ordinary PR); the version's CHANGELOG.md section
extracts non-empty via the existing release-notes.sh; and no tag or
release exists yet. Then, in the same job, it creates the tag ref at
the merge commit via the API and publishes with gh release create
--verify-tag. Same-job on purpose: a GITHUB_TOKEN-created tag triggers
no workflows (GitHub's anti-recursion), so the tag door can never fire
off it and double-publish, and the no-existing assert covers a manual
tag racing the merge. The tag-push path stays step-for-step identical
as the documented manual fallback and backfill, gated to the push event
so a closed PR never runs it against a branch ref.
CONTRIBUTING.md's Releases section now reads "the maintainer's merge IS
the release", with the manual tag ritual kept as the fallback.
test/release.sh grep-pins the merged+labeled gate, all four asserts,
the same-job tag+publish, and that the tag-push trigger survives — in
the same daemon-free, fail-closed style.
Fixes#96
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A shape-filtered trigger silently skips the tags it doesn't match — a
habitual v0.7.0 would mint nothing and say nothing. rig's release.yml
(heavy-duty/rig#40) already triggers on '**' and lets the tag==VERSION
assert be the loud gate; align box to that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/release.sh, wired into CI beside the other suites and never
touching the network: release-notes.sh is driven against a fixture
changelog carrying every boundary (Unreleased must not leak, the
version is matched whole so 0.7.0 never grabs 0.7.0-rc1, a missing
or empty section refuses) AND against the real CHANGELOG.md, so the
header format cannot drift under release.yml. latest_release_tag is
extracted from install.sh and driven against a shim curl serving
canned redirects — including the no-releases redirect and a network
failure. And the three channels are REAL install.sh runs against
throwaway roots with the shim standing in for GitHub: the default
resolves and downloads the tag tarball (never a branch), a pin skips
the probe, BOX_REF=main falls back tag → branch in that order, and a
failed resolution refuses loudly having downloaded nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On a bare X.Y.Z tag push (the 0.6.0 tag set the no-'v' precedent),
release.yml asserts the tag names the tree's own VERSION — a mismatch
fails loudly and creates NOTHING — then publishes the GitHub release
with that version's CHANGELOG.md section as the body: the curated
prose, not the generated PR list. No assets, because for a pure-bash
tree GitHub's source tarball for the tag IS the package.
The extraction lives in .github/scripts/release-notes.sh, a file of
its own so test/release.sh drives the same code against fixtures and
the real changelog — it refuses a missing or empty section, so a tag
whose release ritual was skipped fails before anything is created.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codex's late #85/#98 round-3 finding, valid post-merge: the needs-human
auto-request fired only when the human had NEVER reviewed, so any earlier
human comment or stale approval left a fully-approved PR labeled
needs-human with nobody actually requested — a wedged handoff.
human_request_needed() now asks whether a fresh head-current human review
is missing (live request or head-current approval → nothing to ask;
anything else → request). Five new fixtures cover the wedge, the stale
approval, the satisfied handoff, and request suppression (19 total).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Maintainer direction: body-parsing agreement was a guess, and the machine
must not guess. COMMENTED is now unconditionally a non-verdict; the judgment
that a comment-only reviewer's round passed belongs to the PR AUTHOR, who
escalates by requesting the human's review — an explicit request is a fact,
and it is the machine's top-precedence input. Auto-request survives only for
the no-judgment case: three formal head-current approvals. CONTRIBUTING and
LABELS.md state the handoff; fixtures updated (14 transitions, including
author-escalation and the three-formal-approvals path).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 blockers, all three reviewers concurring:
- COMMENTED agreement now counts: agreement_signal recognizes the live bots'
durable markers (Verdict: Approve / I agree with everything / leading ✅) —
the gate to needs-human can actually close. Formal verdicts remain the
contract (CONTRIBUTING), this is the documented transitional workaround.
- Every counting verdict is bound to the head SHA; a stale approval parks the
PR in addressing (agent owes re-request) instead of promoting unreviewed
code. CHANGES_REQUESTED blocks at any head, per GitHub's own semantic.
- reconcile serializes under ONE job-level concurrency group; scope stays
per-PR. No more cron-vs-event race on the request-the-human-once guard.
- Sweep resilience: per-PR subshell (one failure logs and continues), label
edits warn instead of wedging; the self-heal claim now matches reality
(dispatch-only bootstrap).
- The state machine is extracted pure (globals in, state out) and sourceable:
test/labels-reconcile.sh proves 14 fixture transitions — comment-only
agreement, stale approval, comment-without-verdict, human precedence and
human-block — wired into CI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
but the clone still lives on the same host. Nothing a box held could outlive
a teardown — which made #66's upgrade refusal honest but lossy. This adds
the way out and the way back:
- box export <box> [<file>] [--instance-only]: wraps 'incus export' into one
portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
included by default. Requires the box stopped (require_stopped grew an
honest reason parameter: export is down by OUR decision, not incus's).
Credentials are SHOUTED, not scrubbed — the artifact carries the box's
whole disk, and scrubbing a disk image is a promise tarball surgery
cannot keep.
- box import <file> [--name <box>]: reads the artifact's name from
backup/index.yaml up front, refuses any name an existing instance holds
(the resolve_box boundary from the other side), pre-flights the stack
(require_stack, factored out of cmd_new), imports, then re-stamps the
HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the
box-net placement (profile assign, the migrate-host move), fresh volatile
MACs (imports restore volatile.* verbatim — a re-import beside its
sibling collided at start with 'MAC address already defined on another
NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a
clone.
- restricted tier: box grant now converges restricted.backups allow —
export rides the backup API, which incus-user's restricted projects block
by default exactly like snapshots (incus 6.0 permissions.go,
AllowBackupCreation). Import is plain instance creation and needs no key.
- tests: driven usage errors + fail-closed grep/line-order guards for every
daemon-gated invariant; CI's rehearsal job now runs a live round-trip
(mint, write, snapshot, down, export, rm, import, assert the file, the
snapshot, the tag, the agent, and the collision refusal).
The whole flow was verified against a live Incus 6.0.4 daemon: running-box
refusal, export, overwrite guard, rm, import with and without --name,
re-home onto box-net, sibling re-import with distinct MACs and machine-ids,
pre-export file and snapshot present in both.
Closes#70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The machinery LABELS.md promised. labels.yml runs the reconciler on a
15-minute cron plus PR events (pull_request_target — every PR here is from a
fork, where pull_request gets a read-only token; no PR code is ever checked
out). The script derives each open PR's state:* from GitHub's own facts and
converges labels statelessly; stale is judged from real activity (commits,
comments, reviews), never label churn, so the sweep cannot un-stale its own
mark. actions/labeler applies scope:* from changed paths. CONTRIBUTING.md is
the guideline: the PR loop, and who sets which labels. Rehearsed with
DRY_RUN=1 against the live repo; shellcheck-clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
box had no CI and no unit tests — only the live-host drill. Mirror rig's CI:
one `check` job = globstar `shellcheck -x` over bin/* and **/*.sh, then
`bash test/cli.sh`. The suite is dependency-free and runs non-root with no
Incus: the full CLI contract; install.sh's DEST/BINDIR branch driven
functionally against a shim `id` (both tiers + the BOX_HOME/BOX_BIN overrides);
the root-only a+rX and #66's confirm/no-op flow grep-guarded; tmux asserted in
every template. Pre-existing repo shellcheck findings (bin/box SC2034/SC2015/
SC2020, and file-level SC2015 idioms in doctor.sh/wipe.sh/migrate-host.sh) were
resolved — real fixes where behaviour allows, reasoned disables otherwise — so
the new CI is green over the whole repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>