Compare commits
79 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33794ce70 | ||
|
|
7e05328a6d | ||
|
|
7247768465 | ||
|
|
f8ca59c22e | ||
|
|
e4d9005aca | ||
|
|
3bae42157e | ||
|
|
b0d4aff964 | ||
|
|
1c3d873bf2 | ||
|
|
c93e230396 | ||
|
|
a4684f7afa | ||
|
|
bf74744bda | ||
|
|
a17903f07c | ||
|
|
86af660818 | ||
|
|
2c3b329b8f | ||
|
|
991acd2ba6 | ||
|
|
0b5bedd6e0 | ||
|
|
1ffd20d35b | ||
|
|
f63fa952d4 | ||
|
|
115b9c026e | ||
|
|
877d9ded55 | ||
|
|
0678b22e01 | ||
|
|
92dc3a2bdc | ||
|
|
67d5890e2e | ||
|
|
683217ee42 | ||
|
|
f7058b2814 | ||
|
|
9fcd2e4a7f | ||
|
|
82c1888381 | ||
|
|
4c595eb945 | ||
|
|
050c90966e | ||
|
|
91349acac1 | ||
|
|
64127b34e6 | ||
|
|
b2afea580e | ||
|
|
24a05c3020 | ||
|
|
a091c6e496 | ||
|
|
f84e474549 | ||
|
|
8f1a281c20 | ||
|
|
256ded2e92 | ||
|
|
4fe91ef562 | ||
|
|
c3823e9a3f | ||
|
|
aed2f5bf1f | ||
|
|
be633aa5f8 | ||
|
|
aec22277cd | ||
|
|
239428bf88 | ||
|
|
9b06e29b47 | ||
|
|
1e3fb473c3 | ||
|
|
4b658b6fb0 | ||
|
|
94d830cdb3 | ||
|
|
b7954e3f9b | ||
|
|
94f6ed0047 | ||
|
|
243ccc3aa5 | ||
|
|
3155258cf5 | ||
|
|
81c44c8b02 | ||
|
|
b953d8b3c1 | ||
|
|
e6854ef62c | ||
|
|
f956a85a1c | ||
|
|
27cfc5b8c1 | ||
|
|
074aea31d8 | ||
|
|
9bf669dabc | ||
|
|
2d49a5ad42 | ||
|
|
e408b7a0ed | ||
|
|
b9527d3cc1 | ||
|
|
cbc8f4f7a8 | ||
|
|
e40d2df5f1 | ||
|
|
724f103908 | ||
|
|
aa5a6baed6 | ||
|
|
ecce72f025 | ||
|
|
b3fa94a7d4 | ||
|
|
a9e52325f4 | ||
|
|
b1968dabb7 | ||
|
|
add1c2a142 | ||
|
|
cb72c71c33 | ||
|
|
ce57070c14 | ||
|
|
cc0a1a9a79 | ||
|
|
c68c519558 | ||
|
|
0b7ad21eca | ||
|
|
4c34facefe | ||
|
|
9c78911563 | ||
|
|
86dbb0449e | ||
|
|
15b67d938e |
45 changed files with 3178 additions and 2157 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# AGENTS.md — start here
|
||||
|
||||
You are an agent working in a repo governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
|
||||
the router: find your role below, read its file, then act. The role files
|
||||
sit beside this one — in ceremony itself at the repo root, in a governed
|
||||
repo under `.ceremony/` (a machine-managed mirror; never edit those files
|
||||
in place — they are changed in heavy-duty/ceremony, through its own flow).
|
||||
|
||||
## Your role
|
||||
|
||||
You were told your role when you were pointed at this repo ("you are a
|
||||
reviewer here"). That one word is your whole onboarding:
|
||||
|
||||
| you are the… | read | your job in one line |
|
||||
|---|---|---|
|
||||
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||
|
||||
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
|
||||
are the shared state machine, and misusing one lies to every other agent on
|
||||
the board.
|
||||
|
||||
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||
asked to implement an issue → builder; asked to process discussions or the
|
||||
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||
pushing fixes, breaks the separation the pipeline depends on.
|
||||
|
||||
## The pipeline you are part of
|
||||
|
||||
```
|
||||
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||
```
|
||||
|
||||
Two rules bind every role:
|
||||
|
||||
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||
a merge button.
|
||||
|
||||
## Repo specifics
|
||||
|
||||
What is true only of *this* repo — the review panel roster, the `scope:*`
|
||||
label set, what a drill means, code conventions — lives in the repo's own
|
||||
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
|
||||
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.
|
||||
76
.ceremony/BUILDER.md
Normal file
76
.ceremony/BUILDER.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# BUILDER.md — the builder role
|
||||
|
||||
You turn one issue into one PR. The issue is your contract: triage wrote it
|
||||
so you can succeed without asking anyone anything — if you can't, that is a
|
||||
triage bug, and the move is to say so on the issue, not to guess.
|
||||
|
||||
## Picking
|
||||
|
||||
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`,
|
||||
never an `epic` (epics organize; their children are the work).
|
||||
- Respect dependency order: inside an epic, take the earliest unblocked
|
||||
unclaimed child. Between epics and strays, prefer the issue that unblocks
|
||||
the most other work.
|
||||
- **One issue at a time.** Finish or release your claim before taking
|
||||
another.
|
||||
|
||||
## Claiming
|
||||
|
||||
- Assign yourself, swap `ready` → `claimed`, and comment that you are
|
||||
starting. The claim is a promise of a draft PR soon — a claim with no PR
|
||||
and no activity is what the staleness sweep reclaims.
|
||||
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to,
|
||||
push the branch if it holds anything useful, unassign, and restore
|
||||
`ready`.
|
||||
|
||||
## Building
|
||||
|
||||
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the
|
||||
body. Drafts are invisible to the reviewer panel on purpose — the draft
|
||||
phase is yours.
|
||||
- **The issue's acceptance criteria are your definition of done.** Reproduce
|
||||
them as a checklist in the PR body and check them honestly as you go. If
|
||||
one turns out to be wrong or unreachable, say so on the issue and get it
|
||||
amended by triage — do not silently ship less than the issue says.
|
||||
- Every behavior change adds one line to `CHANGELOG.md` under
|
||||
`## Unreleased` — insert **above** the heading below it, never over it
|
||||
(the monotonic guard's whole reason to exist).
|
||||
- Follow the repo's conventions file and match the code you touch. Tests are
|
||||
not optional: the issue's test plan is the floor, not the ceiling.
|
||||
- **Scope discipline: the PR does the issue — whole, and nothing else.**
|
||||
Adjacent problems you discover go to a **discussion** (or a comment on the
|
||||
relevant issue), where triage will do its job. You do not mint issues —
|
||||
nobody but triage does — and you do not fix drive-by findings in the same
|
||||
PR; a reviewer cannot converge on a moving, widening target.
|
||||
|
||||
## The review round
|
||||
|
||||
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the
|
||||
panel roster and any repo-specific flow notes live in that repo's own
|
||||
CONTRIBUTING; everything below is the shared flow.)
|
||||
|
||||
1. Mark ready-for-review; request **the whole panel** (the roster is in the
|
||||
repo's CONTRIBUTING).
|
||||
2. **Wait for every verdict, then answer the round whole** — one reply
|
||||
covering every point, then push the fixes, then re-request exactly the
|
||||
reviewers who did not approve. Prefer verification over argument: when a
|
||||
reviewer doubts behavior, add the test that settles it.
|
||||
3. Never dismiss a review, never merge, never mark your own work as passed.
|
||||
A blocking point you disagree with is answered with evidence or escalated
|
||||
in the PR — a maintainer can be asked for a ruling; silence and
|
||||
force-forward are not options.
|
||||
|
||||
## Handoff
|
||||
|
||||
When the round passes — every panel verdict approves the **current head**,
|
||||
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if
|
||||
this is a release PR) — hand it to the human, in order:
|
||||
|
||||
1. post the round summary (what changed per round, what was verified);
|
||||
2. request the human's review;
|
||||
3. set `state:needs-human` yourself.
|
||||
|
||||
The label write is optimistic — the reconciler validates it, and takes it
|
||||
back if the PR is not actually mergeable-right-now. Then stop: the PR is the
|
||||
human's. Address what comes back (`state:addressing`) and re-hand-off the
|
||||
same way.
|
||||
98
.ceremony/LABELS.md
Normal file
98
.ceremony/LABELS.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Labels
|
||||
|
||||
The taxonomy shared across the heavy-duty repos. Only the `scope:` set
|
||||
differs per repo (each repo's `.github/labels.conf` names its actual
|
||||
surfaces); everything else below is core and identical everywhere, created by
|
||||
the labels workflow's bootstrap dispatch (issue #10).
|
||||
|
||||
Two state machines share the taxonomy: the **PR machine** (proven in
|
||||
box/rig/cast, reconciled by machinery) and the **issue flow** (the
|
||||
triage → build queue, doctrine-enforced today, machinery to follow —
|
||||
issue #18). One rule joins everything: **states are machine-owned, intent
|
||||
labels are hand-set** — a hand-moved state label is a lie waiting to happen,
|
||||
and the reconciler recomputes it from GitHub's own facts.
|
||||
|
||||
## PR state — who is the ball with? (exactly one per open PR)
|
||||
|
||||
| Label | Color | Waiting on |
|
||||
|---|---|---|
|
||||
| `state:building` | `#FBCA04` | the builder — PR is a draft |
|
||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
|
||||
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up |
|
||||
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
|
||||
|
||||
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means
|
||||
*poke the reviewers*, in the second *the builder dropped the ball*. And
|
||||
`state:needs-human` means exactly one thing — a human could merge this now —
|
||||
so it requires zero blockers and head-current approvals; anything less and
|
||||
the reconciler takes it back. The author sets it at handoff (the one
|
||||
hand-set state); the `labeled` event fires the sweep that validates the
|
||||
write within seconds.
|
||||
|
||||
## PR blockers — what is in the way? (facts, as many as apply)
|
||||
|
||||
| Label | Color | Means |
|
||||
|---|---|---|
|
||||
| `blocker:conflict` | `#B60205` | does not merge — the builder owes a **rebase** |
|
||||
| `blocker:ci-red` | `#B60205` | a check failed — the builder owes a **fix**, which a rebase will not provide |
|
||||
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody, and nobody was asked |
|
||||
| `blocker:drill-pending` | `#B60205` | a `release` PR whose version has no `drills/X.Y.Z.md` record — correct but unevidenced (maintainer-created label; the bot bootstrap 403s on it) |
|
||||
|
||||
States answer *whose ball*; blockers answer *what's in the way*. They are
|
||||
separate axes because the single-label version kept lying — independent facts
|
||||
projected onto one totally-ordered label meant one always won and the losers
|
||||
vanished off the board (box's `state:needs-rebase`, retired: the reconciler
|
||||
strips it on sight).
|
||||
|
||||
## Issue flow — the work queue (exactly one per open, triaged, non-epic issue)
|
||||
|
||||
| Label | Color | Means | Set by |
|
||||
|---|---|---|---|
|
||||
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage |
|
||||
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
|
||||
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
|
||||
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
|
||||
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
|
||||
|
||||
The invariant a board scan relies on: every open issue is either
|
||||
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||
`blocked`. A `claimed` issue with no open PR and no activity is what the
|
||||
staleness sweep will reclaim (issue #18); until that machinery exists,
|
||||
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand.
|
||||
|
||||
## Cross-cutting (PRs and issues)
|
||||
|
||||
| Label | Color | Meaning |
|
||||
|---|---|---|
|
||||
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
|
||||
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) |
|
||||
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
|
||||
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
|
||||
|
||||
## Scope — which surface? (PRs and issues, any number)
|
||||
|
||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
|
||||
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via
|
||||
actions/labeler, issues get theirs from triage. This repo's set:
|
||||
|
||||
| Label | Covers |
|
||||
|---|---|
|
||||
| `scope:release-flow` | the reusable release workflow, decide, the doors |
|
||||
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded |
|
||||
| `scope:labels` | the labels workflow, reconciler, this taxonomy |
|
||||
| `scope:docs` | README doctrine, CONSUMERS.md, the role files |
|
||||
|
||||
## Issue types
|
||||
|
||||
`bug`, `enhancement`, `documentation` — issues only, set by triage. PRs carry
|
||||
their type in the conventional title (`feat:`, `fix:`, `docs:`); a type label
|
||||
on a PR would say the same thing twice and drift.
|
||||
|
||||
## Maintenance
|
||||
|
||||
The labels workflow (issue #10) recomputes PR state statelessly on PR events
|
||||
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on
|
||||
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands
|
||||
machinery for them. Default GitHub labels (`duplicate`, `invalid`,
|
||||
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at
|
||||
bootstrap — a `question` is a discussion, not an issue.
|
||||
14
.ceremony/README.md
Normal file
14
.ceremony/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# .ceremony/ — the vendored doctrine mirror
|
||||
|
||||
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
|
||||
these files here: they are byte-identical copies of
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
|
||||
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
|
||||
goes red. They are changed in heavy-duty/ceremony, through its own flow,
|
||||
and arrive here when the pin moves.
|
||||
|
||||
The pin lives in `.github/workflows/release.yml` — the single
|
||||
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
|
||||
pin governs machinery and doctrine alike: bump it and re-sync this mirror
|
||||
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
|
||||
what is stale).
|
||||
70
.ceremony/REVIEWER.md
Normal file
70
.ceremony/REVIEWER.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# REVIEWER.md — the reviewer role
|
||||
|
||||
You are one voice on a panel. The panel's job is to converge — on an
|
||||
approval the human can trust, or on a precise statement of what is wrong.
|
||||
The machine reads only your **verdict**; humans read your reasons.
|
||||
|
||||
## The verdict doctrine
|
||||
|
||||
- **Every review ends in a verdict**: approve, or request changes. A
|
||||
comment-only review is a non-verdict — it does not say whether the round
|
||||
passed, the state machine treats it as not-approved, and the PR simply
|
||||
stalls. If you have an opinion, you have a verdict; commenting without one
|
||||
only wedges the flow.
|
||||
- **The verdict carries blockingness only; the body carries the feedback.**
|
||||
Non-blocking nits ride an **approval**, and the builder addresses them at
|
||||
their discretion. Anything blocking — including a question whose answer
|
||||
gates your approval — is **request changes**, saying exactly what
|
||||
unblocks it.
|
||||
- An approval you would not defend to the human is a defect. You are not
|
||||
being asked to be agreeable; you are being asked to be right.
|
||||
|
||||
## What you review against
|
||||
|
||||
In order of authority:
|
||||
|
||||
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your
|
||||
spec. Check every criterion; a PR that ships less than the issue says is
|
||||
a request-changes even if the code is beautiful.
|
||||
2. **The repo's load-bearing constraints** — the rules bought with
|
||||
incidents (in ceremony itself: issue #1's constraint list; in a governed
|
||||
repo: its own CONTRIBUTING plus ceremony's README). A change that
|
||||
"simplifies away" a constraint gets request-changes with a link to the
|
||||
incident that made the rule.
|
||||
3. **The code itself** — correctness first, then tests (does the test plan's
|
||||
floor exist? do the failure cases actually fail?), then conventions.
|
||||
Changelog line present for behavior changes; comments carry why, not
|
||||
what.
|
||||
|
||||
**Verify over opine.** Run what can be run; construct the failing input; a
|
||||
test settles what a comment thread can't. A review that says "I ran X and
|
||||
saw Y" outranks one that says "this looks like it might".
|
||||
|
||||
## What you do not do
|
||||
|
||||
- **Re-litigate the spec.** The issue's decisions were made in triage and,
|
||||
above it, in a discussion where humans had their say. If you think the
|
||||
spec itself is wrong, say so with reasons — as a comment pointing at the
|
||||
discussion, while still reviewing the implementation against the spec as
|
||||
written. Spec changes go through triage, not through a review round.
|
||||
- **Merge, or tell the builder to merge.** Convergence hands the PR to a
|
||||
human; only humans merge.
|
||||
- **Approve a moving target.** Your approval is of a specific head. If the
|
||||
builder pushes after your approval, GitHub stales it — that is correct,
|
||||
and the builder owes a re-request, not an assumption.
|
||||
|
||||
## The round rhythm
|
||||
|
||||
- Review the **whole PR at the current head** each round, not just the diff
|
||||
since your last comments — the fix for someone else's point can break
|
||||
yours.
|
||||
- The builder answers rounds whole and re-requests you; until re-requested,
|
||||
the ball is not yours (`state:addressing` is the builder working — pile-on
|
||||
reviews mid-address just churn the target).
|
||||
- Convergence = every panel verdict approves the current head, no
|
||||
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and
|
||||
the panel's job is done.
|
||||
- If a round exposes a disagreement **within the panel**, argue it in the PR
|
||||
with evidence until one side concedes or the builder escalates to the
|
||||
maintainer for a ruling. Two reviewers pulling a builder in opposite
|
||||
directions without resolution is a panel failure, not a builder failure.
|
||||
95
.ceremony/TRIAGE.md
Normal file
95
.ceremony/TRIAGE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# TRIAGE.md — the triage role
|
||||
|
||||
You are the only door issues come through. Humans and agents open
|
||||
**discussions**; you decide what becomes work. The quality of every
|
||||
downstream stage — a builder succeeding without asking, a reviewer having a
|
||||
spec to review against — is set here, by you, and nowhere else.
|
||||
|
||||
## Why this door exists
|
||||
|
||||
Discussions are allowed to be ambiguous; issues are not. An issue is a work
|
||||
order a builder must be able to execute **without asking anyone anything**.
|
||||
Keeping one accountable role between the two is what keeps the bar from
|
||||
eroding — the moment anyone can mint an issue, the backlog fills with
|
||||
"improve X" entries nobody can build, and builders start guessing. Guessing
|
||||
is the failure this whole flow exists to prevent.
|
||||
|
||||
## Your inputs
|
||||
|
||||
- **Every open discussion** in the repo you serve.
|
||||
- **Stray issues** — anything filed directly, by anyone. Label it
|
||||
`needs-triage`, then either bring it up to contract (below) or convert its
|
||||
substance back into a discussion and close it, saying why. Do not shame the
|
||||
filer; do route the work correctly.
|
||||
|
||||
## For each discussion, converge on exactly one outcome
|
||||
|
||||
1. **Answer.** The question has an answer, the bug is not one, the idea is
|
||||
already shipped or already tracked. Reply with the answer (link the code,
|
||||
the doc, the existing issue), mark answered.
|
||||
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
|
||||
repo, its history, or its docs. Ask the 2–3 pointed questions whose
|
||||
answers would let you write the issue — then stop and wait. Do not mint an
|
||||
issue that carries the ambiguity forward; that just moves your job onto
|
||||
the builder.
|
||||
3. **Escalate.** The blocker is a *decision* only a human owns — scope,
|
||||
money, product direction, breaking a public contract. Say precisely what
|
||||
the decision is, list the options with your recommendation, and name the
|
||||
decider. The discussion is where humans decide; wait there.
|
||||
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
|
||||
where it belongs if anywhere, close. A refusal with reasons is a good
|
||||
outcome; a zombie discussion is not.
|
||||
5. **Accept.** It justifies work → mint the issue(s). The contract below is
|
||||
the bar.
|
||||
|
||||
## The issue contract
|
||||
|
||||
Every issue you mint carries, in this order:
|
||||
|
||||
- **A title that names the deliverable** — "lib/version.sh — one version
|
||||
abstraction, two backends", never "improve version handling".
|
||||
- **Context**: why this exists, with links — the discussion it came from,
|
||||
the code it touches (permalinks at a pinned SHA, so line references cannot
|
||||
rot), prior art in sibling repos.
|
||||
- **The spec**: decisions made, not options listed. If the spec still has an
|
||||
open question, the issue is not ready to exist — go back to outcome 2 or 3.
|
||||
- **Tasks**: the steps, checkboxed, in order.
|
||||
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become
|
||||
the builder's definition of done and the reviewer's review spec, verbatim.
|
||||
- **Test plan**: what proves it, including the cases that must fail.
|
||||
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
|
||||
epic organizes it.
|
||||
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
|
||||
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
|
||||
|
||||
The bar, stated once: **a competent builder who has read only this issue and
|
||||
the repo can succeed.** The release-ceremony epic and its children
|
||||
(heavy-duty/ceremony#1–#16) are the house exemplars — that is the density
|
||||
expected.
|
||||
|
||||
## Multi-issue work
|
||||
|
||||
When an acceptance produces more than one issue, mint an **epic** (`epic`
|
||||
label): the approach, the decisions, the constraint list, and a
|
||||
dependency-ordered task list of child issues. Children reference the epic;
|
||||
the epic's checklist is the progress view. Builders never pick the epic
|
||||
itself. Keep the checklist current — a stale epic misleads every scan.
|
||||
|
||||
## Backlog hygiene (yours until #18 automates it)
|
||||
|
||||
- **Dedup before minting** — search issues *and* closed issues; extend or
|
||||
reopen before duplicating.
|
||||
- **Flip `blocked` → `ready`** when the named dependency lands.
|
||||
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity →
|
||||
comment, unassign, restore `ready`.
|
||||
- **Close obsolete issues** with the reason and a link to what obsoleted
|
||||
them. Every label on every open issue stays true; the board is only worth
|
||||
scanning if it does not lie.
|
||||
|
||||
## What you never do
|
||||
|
||||
- Write code, review code, or build the thing yourself.
|
||||
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
|
||||
- Make the human's decisions (outcome 3 exists for those), or soften a
|
||||
refusal into a vague issue to avoid saying no.
|
||||
- Mint an issue to "discuss" something — that is a discussion.
|
||||
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
# path → scope:* map for actions/labeler — the PR half of LABELS.md's scope
|
||||
# path → scope:* map for actions/labeler — the PR half of .ceremony/LABELS.md's scope
|
||||
# story (issues are hand-scoped at triage; paths only exist on PRs). Additive
|
||||
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
|
||||
"scope:cli":
|
||||
|
|
|
|||
7
.github/labels.conf
vendored
Normal file
7
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
||||
scope:cli|C5DEF5|bin/box — the command surface
|
||||
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
||||
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
||||
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
||||
scope:templates|C5DEF5|templates/ — the box seeds
|
||||
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
||||
145
.github/scripts/changelog-armed.sh
vendored
145
.github/scripts/changelog-armed.sh
vendored
|
|
@ -1,145 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# changelog-armed.sh [<changelog>] [<version-file>] — assert that
|
||||
# CHANGELOG.md is ARMED: that there is a heading for the next PR's entry to
|
||||
# land under, and that it is the right one for the state this tree is in.
|
||||
#
|
||||
# The failure it exists to catch (#108, heavy-duty/rig#66) leaves no trace:
|
||||
# the ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and
|
||||
# nothing puts the heading back. A PR authored BEFORE the release wrote its
|
||||
# entry under '## Unreleased'; that heading is gone by the time it merges, so
|
||||
# git lands the entry under whatever heading now occupies that position — the
|
||||
# just-shipped section — CLEANLY, with no conflict. The one signal an author
|
||||
# would trust ("git told me to look") is absent exactly when the result is
|
||||
# wrong, and the drift is only ever discovered by reading the file.
|
||||
#
|
||||
# The rule, keyed on VERSION, because the two states are genuinely different:
|
||||
#
|
||||
# VERSION ends in -dev -> the top section MUST be '## Unreleased'
|
||||
# VERSION is bare -> the top section may be '## Unreleased' (armed,
|
||||
# the ceremony's own re-arm) or the stamped
|
||||
# section for exactly that VERSION — AND the
|
||||
# section for that VERSION must exist and carry
|
||||
# prose, because it is the one about to ship
|
||||
#
|
||||
# Keying on VERSION is the whole design, and the reason this is not simply
|
||||
# "require '## Unreleased'". That unconditional form is what rig#44 and
|
||||
# heavy-duty/cast#108 had to REVERT: it is false by construction on the
|
||||
# ceremony PR's own tree, which makes the release unshippable through a green
|
||||
# CI. Anyone tempted to simplify this back should read those two first.
|
||||
#
|
||||
# The consequence worth stating plainly: a ceremony PR that stamps and forgets
|
||||
# to re-arm still passes here — its VERSION is bare, and a bare tree is
|
||||
# allowed to be stamped. It goes red the moment the '-dev' bump lands on main,
|
||||
# which release.yml does automatically in the same job as the publish. So the
|
||||
# guard does not block the release; it refuses to let main SIT disarmed, which
|
||||
# is the window a late PR can fall into.
|
||||
#
|
||||
# A file of its own (not inlined in ci.yml) so test/release.sh can drive it
|
||||
# against constructed trees for both states — the same discipline as
|
||||
# release-notes.sh.
|
||||
|
||||
changelog="${1:-CHANGELOG.md}"
|
||||
version_file="${2:-VERSION}"
|
||||
|
||||
# release-notes.sh lives beside this script; the bare-VERSION branch runs it
|
||||
# rather than re-implementing the extraction, so the guard and the publisher
|
||||
# cannot disagree about what a section is or when one counts as empty.
|
||||
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
[ -f "$changelog" ] || { echo "changelog-armed: no such file: $changelog" >&2; exit 1; }
|
||||
[ -f "$version_file" ] || { echo "changelog-armed: no such file: $version_file" >&2; exit 1; }
|
||||
|
||||
ver="$(tr -d '[:space:]' < "$version_file")"
|
||||
[ -n "$ver" ] || { echo "changelog-armed: $version_file is empty" >&2; exit 1; }
|
||||
|
||||
# The TOP section: the first '## ' heading in the file. Everything above it is
|
||||
# the changelog's own preamble and belongs to no section.
|
||||
top="$(grep -m1 '^## ' "$changelog" || true)"
|
||||
[ -n "$top" ] || {
|
||||
echo "changelog-armed: $changelog has no '## ' section at all — nothing for a PR entry to land under" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# '## 0.7.0 — 2026-07-19' -> '0.7.0'. Split on whitespace, same shape
|
||||
# release-notes.sh matches on, so the two cannot disagree about what a
|
||||
# section header is.
|
||||
top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')"
|
||||
|
||||
case "$ver" in
|
||||
*-dev)
|
||||
if [ "$top_ver" != "Unreleased" ]; then
|
||||
cat >&2 <<EOF
|
||||
changelog-armed: VERSION is '$ver' (a development tree) but the top section of
|
||||
$changelog is:
|
||||
|
||||
$top
|
||||
|
||||
A -dev tree MUST carry '## Unreleased' at the top. Without it, a PR that
|
||||
wrote its entry under '## Unreleased' before the release merges CLEANLY into
|
||||
the section above — the one that already shipped — and the changelog quietly
|
||||
misattributes it (#108, heavy-duty/rig#66).
|
||||
|
||||
The fix is to re-arm: add an empty '## Unreleased' immediately above
|
||||
'$top'. The release ceremony is supposed to do this in the same edit that
|
||||
stamps the version — see CONTRIBUTING.md, "Releases".
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# A bare VERSION is the ceremony tree and the merge commit that publishes
|
||||
# it. Both arrangements are legal there: re-armed ('## Unreleased' back on
|
||||
# top, above the section just stamped) or not yet re-armed (the stamped
|
||||
# section still on top). What is NOT legal is a stamped top section naming
|
||||
# some OTHER version — that is a ceremony that stamped the wrong number,
|
||||
# and release.yml would publish a body that is not this release's.
|
||||
if [ "$top_ver" != "Unreleased" ] && [ "$top_ver" != "$ver" ]; then
|
||||
cat >&2 <<EOF
|
||||
changelog-armed: VERSION is '$ver' but the top section of $changelog is:
|
||||
|
||||
$top
|
||||
|
||||
A bare VERSION means this tree is a release. Its top section must be either
|
||||
'## Unreleased' (re-armed after stamping) or the stamped section for '$ver'
|
||||
itself. A stamped section naming a different version means the ceremony
|
||||
stamped the wrong number, and the published release body would come from
|
||||
the wrong section.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
# The top heading is deliberately left UNCONSTRAINED above — both ceremony
|
||||
# shapes must stay legal, which is the #44 / cast#108 lesson and is not
|
||||
# negotiable. That asymmetry leaves a gap of its own, the HALF-ceremony
|
||||
# tree: VERSION bumped to the release, a populated '## Unreleased' still on
|
||||
# top, and no stamped section for the version anywhere. The test above is
|
||||
# false on its first clause, short-circuits, and passes. Nothing else
|
||||
# refuses until release.yml extracts the notes — which happens AFTER the
|
||||
# merge, on main, and publishes a release with an empty body, the worst
|
||||
# place for this to land. So make the same assert one step earlier by
|
||||
# running the very script release.yml runs (heavy-duty/rig#67).
|
||||
if ! bash "$here/release-notes.sh" "$ver" "$changelog" >/dev/null 2>&1; then
|
||||
cat >&2 <<EOF
|
||||
changelog-armed: VERSION is '$ver' but $changelog has no non-empty section for
|
||||
'$ver'. The top section is:
|
||||
|
||||
$top
|
||||
|
||||
This is a HALF-DONE ceremony: the version was bumped but its section was
|
||||
never stamped — the stamp is MISSING, not misnumbered. A bare VERSION means
|
||||
this tree is a release, and the section it is about to publish has to exist
|
||||
and have prose in it. Left alone, this passes CI, merges, and only then does
|
||||
release.yml refuse to extract the notes — on main, after the fact, with the
|
||||
release already half-shipped.
|
||||
|
||||
The fix is the ceremony's first edit (CONTRIBUTING.md, "Releases"): stamp
|
||||
'## Unreleased' into '## $ver — DATE', then put an empty '## Unreleased'
|
||||
back above it.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "changelog-armed: VERSION '$ver' agrees with the top section ($top_ver)"
|
||||
228
.github/scripts/labels-reconcile.sh
vendored
228
.github/scripts/labels-reconcile.sh
vendored
|
|
@ -1,228 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
|
||||
# written by machinery, never by hand. Every run derives each open PR's
|
||||
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
|
||||
# reviews) and converges the labels to it, so a killed run or a hand-moved
|
||||
# label heals on the next pass. Stale is judged from real activity — commits,
|
||||
# comments, reviews — never from label churn, or the sweep would un-stale its
|
||||
# own mark every tick.
|
||||
#
|
||||
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
|
||||
# request-changes. Some live bots are comment-only and post agreement as a
|
||||
# COMMENTED review — a non-verdict this machine refuses to guess about (body
|
||||
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
|
||||
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
|
||||
# by requesting the human's review — an explicit request is a fact, and it is
|
||||
# the one this machine trusts (see decide_state's top precedence). The
|
||||
# machine auto-requests the human only in the no-judgment-needed case: three
|
||||
# formal head-current approvals. Any approval that counts must be bound to
|
||||
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
|
||||
# stale approval must never promote unreviewed code to the human.
|
||||
#
|
||||
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
|
||||
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
|
||||
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
|
||||
# sweep tolerates a missing label rather than recreating it.
|
||||
#
|
||||
# The state machine below is pure (globals in, state out) and covered by
|
||||
# fixture tests in test/labels-reconcile.sh.
|
||||
|
||||
HUMAN="${HUMAN_REVIEWER:-danmt}"
|
||||
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
|
||||
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
|
||||
STALE_AFTER=$((48 * 3600))
|
||||
|
||||
log() { printf 'labels: %s\n' "$*"; }
|
||||
|
||||
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
|
||||
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The state machine. Pure functions over four globals, set per PR:
|
||||
# DRAFT true|false
|
||||
# HEAD_SHA the PR's current head commit
|
||||
# REQUESTED newline-separated logins with a review currently requested
|
||||
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
||||
|
||||
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
|
||||
local review state commit
|
||||
review="$(jq -c --arg u "$1" \
|
||||
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
|
||||
<<<"$REVIEWS_JSON")"
|
||||
if [ -z "$review" ]; then echo MISSING; return; fi
|
||||
state="$(jq -r '.state' <<<"$review")"
|
||||
commit="$(jq -r '.commit_id' <<<"$review")"
|
||||
case "$state" in
|
||||
CHANGES_REQUESTED)
|
||||
# blocks at ANY head — GitHub's own semantic: only a newer review
|
||||
# from the same reviewer clears it
|
||||
echo BLOCK ;;
|
||||
APPROVED)
|
||||
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
|
||||
*)
|
||||
# COMMENTED and anything else: a non-verdict. The machine does not
|
||||
# read bodies — if the comment is really an agreement, the AUTHOR
|
||||
# says so by requesting the human's review.
|
||||
echo FEEDBACK ;;
|
||||
esac
|
||||
}
|
||||
|
||||
human_request_needed() { # 0 when needs-human requires a FRESH human request
|
||||
# already requested → the handoff is live; head-current human approval →
|
||||
# nothing left to ask. Anything else (never reviewed, an old comment, an
|
||||
# approval of an older head) stalls the handoff unless we request —
|
||||
# guarding on "has the human ever reviewed" wedged exactly that way.
|
||||
if requested "$HUMAN"; then return 1; fi
|
||||
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
|
||||
return 0
|
||||
}
|
||||
|
||||
decide_state() { # → the one state:* label this PR should carry
|
||||
if [ "$DRAFT" = true ]; then echo state:building; return; fi
|
||||
# an explicit human request outranks the bot rounds — it is the final
|
||||
# gate, and a maintainer pulling a PR to themselves early counts too
|
||||
if requested "$HUMAN"; then echo state:needs-human; return; fi
|
||||
local b v verdicts=""
|
||||
for b in "${BOTS[@]}"; do
|
||||
if requested "$b"; then echo state:bots-reviewing; return; fi
|
||||
done
|
||||
for b in "${BOTS[@]}"; do
|
||||
v="$(bot_verdict "$b")"
|
||||
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
|
||||
verdicts="$verdicts $v"
|
||||
done
|
||||
case "$verdicts" in
|
||||
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
|
||||
# STALE = a verdict for an older head → the agent owes a re-request.
|
||||
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
|
||||
esac
|
||||
# the bots all approve — but if the human's standing word is
|
||||
# changes-requested (and nobody re-requested them yet), the agent owes
|
||||
# fixes, not the human a nag
|
||||
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
|
||||
echo state:addressing
|
||||
else
|
||||
echo state:needs-human
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
|
||||
# others — each PR reconciles in a subshell and a failure just logs.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
|
||||
while IFS='|' read -r name color desc; do
|
||||
[ -n "$name" ] || continue
|
||||
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
||||
done <<'EOF'
|
||||
state:building|FBCA04|PR is a draft — the coding agent is still building
|
||||
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
|
||||
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
|
||||
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
|
||||
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
|
||||
blocked|6A737D|Waiting on another PR or issue to land first
|
||||
release|0E8A16|Release flow and version/packaging work
|
||||
scope:cli|C5DEF5|bin/box — the command surface
|
||||
scope:installer|C5DEF5|install.sh, versioned installs, upgrade/uninstall
|
||||
scope:host|C5DEF5|host/ — setup, teardown, firewall, isolation stack
|
||||
scope:tiers|C5DEF5|restricted tier — grant/revoke, multi-user
|
||||
scope:templates|C5DEF5|templates/ — the box seeds
|
||||
scope:drill|C5DEF5|drill/ — rehearsals, doctor, RUNS.md
|
||||
EOF
|
||||
}
|
||||
|
||||
has_label() { grep -qxF "$1" <<<"$LABELS"; }
|
||||
|
||||
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||
local n="$1" desired remove s args last_activity age
|
||||
|
||||
desired="$(decide_state)"
|
||||
|
||||
# encode the runbook's last step for the no-judgment case: three formal
|
||||
# head-current approvals → the human is asked, once. The guard asks whether
|
||||
# a FRESH human review is needed for THIS head — never "has the human ever
|
||||
# reviewed", which wedged the handoff after any earlier human comment.
|
||||
# Idempotent (a live request suppresses it); race-free via the shared
|
||||
# concurrency group in labels.yml. With a comment-only bot on the panel
|
||||
# this path stays cold and the AUTHOR requests the human.
|
||||
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
||||
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
|
||||
log "#$n: requested $HUMAN (round passed)"
|
||||
fi
|
||||
|
||||
# ---- converge the state:* labels ----
|
||||
remove=""
|
||||
for s in "${STATES[@]}"; do
|
||||
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
|
||||
done
|
||||
remove="${remove#,}"
|
||||
if ! has_label "$desired" || [ -n "$remove" ]; then
|
||||
args=(--add-label "$desired")
|
||||
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
||||
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
||||
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
|
||||
else
|
||||
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
||||
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
||||
last_activity="$(
|
||||
{
|
||||
jq -r '.created_at' <<<"$PR_JSON"
|
||||
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
||||
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
||||
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
|
||||
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
|
||||
} | sort | tail -n1
|
||||
)"
|
||||
age=$((NOW - $(date -d "$last_activity" +%s)))
|
||||
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
|
||||
if has_label stale; then
|
||||
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
||||
log "#$n: unstale"
|
||||
fi
|
||||
elif ! has_label stale; then
|
||||
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
|
||||
log "#$n: stale ($((age / 3600))h quiet)"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
REPO="${REPO:?set REPO to owner/name}"
|
||||
NOW="$(date +%s)"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
|
||||
log "workflow_dispatch: bootstrapping the taxonomy"
|
||||
bootstrap_labels
|
||||
fi
|
||||
|
||||
local n
|
||||
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
|
||||
(
|
||||
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
|
||||
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
||||
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
||||
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
||||
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
|
||||
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
||||
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
||||
| jq -s '[.[] | select(.state != "PENDING")]')"
|
||||
reconcile_pr "$n"
|
||||
) || log "#$n: reconcile failed — continuing with the remaining PRs"
|
||||
done
|
||||
log "reconciled."
|
||||
}
|
||||
|
||||
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
|
||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
||||
main "$@"
|
||||
fi
|
||||
30
.github/scripts/release-notes.sh
vendored
30
.github/scripts/release-notes.sh
vendored
|
|
@ -1,30 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# release-notes.sh <version> [<changelog>] — print exactly <version>'s
|
||||
# section of the changelog: every line between its '## <version> — <date>'
|
||||
# header and the next '## '. This is what release.yml hands to
|
||||
# 'gh release create', so the release notes are the curated prose we wrote,
|
||||
# not the PR list GitHub would generate (#83). Fails loudly when the section
|
||||
# is missing or empty — a tag without its changelog section is a release
|
||||
# ritual skipped, and an empty release body would paper over it.
|
||||
#
|
||||
# A file of its own (not inlined in release.yml) so test/release.sh drives
|
||||
# the same extraction against fixtures and the real CHANGELOG.md.
|
||||
|
||||
ver="${1:-}"
|
||||
changelog="${2:-CHANGELOG.md}"
|
||||
[ -n "$ver" ] || { echo "usage: release-notes.sh <version> [<changelog>]" >&2; exit 2; }
|
||||
[ -f "$changelog" ] || { echo "release-notes: no such file: $changelog" >&2; exit 1; }
|
||||
|
||||
# $2 of a section header ('## 0.6.0 — 2026-07-18') is the bare version —
|
||||
# compared WHOLE, so 0.6.0 can never match a 0.6.0-rc1 section (or vice
|
||||
# versa), and no regex-escaping of dots. sed drops the blank padding under
|
||||
# the header; the command substitution eats the trailing blanks.
|
||||
notes="$(awk -v ver="$ver" '
|
||||
/^## / { grab = ($2 == ver); next }
|
||||
grab { print }
|
||||
' "$changelog" | sed '/./,$!d')"
|
||||
|
||||
[ -n "$notes" ] || { echo "release-notes: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag (#83)" >&2; exit 1; }
|
||||
printf '%s\n' "$notes"
|
||||
84
.github/workflows/ci.yml
vendored
84
.github/workflows/ci.yml
vendored
|
|
@ -8,21 +8,56 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# fetch-depth: 0, for the changelog-monotonic step below and only
|
||||
# for it. That check is about a DIFF — which release headings the
|
||||
# merge base had — so it needs the base branch's history present,
|
||||
# and the default depth-1 checkout has none of it. An explicit
|
||||
# `git fetch origin <base>` would be narrower, but it has to be
|
||||
# right on both event types and on fork PRs, and getting it subtly
|
||||
# wrong degrades to a SKIP (a guard that silently stops guarding —
|
||||
# the exact failure this repo keeps refusing). Full history on a
|
||||
# pure-bash tree costs a second; the STRICT flag below turns any
|
||||
# remaining skip red rather than green.
|
||||
fetch-depth: 0
|
||||
- name: shellcheck
|
||||
# -x follows `source`/`.` directives; box has no lib split today, but the
|
||||
# flag costs nothing and keeps the invocation identical to rig's.
|
||||
# globstar so a script in a new subdirectory is linted without anyone
|
||||
# remembering to edit this list; bin/* covers the extensionless entrypoint
|
||||
# (bin/box). The file list is printed so under-coverage shows up in the log.
|
||||
#
|
||||
# dotglob is not decoration (#116): globstar makes `**` descend, but a
|
||||
# glob still does not MATCH a dot-prefixed name, so `**/` never entered
|
||||
# `.github/` — and the whole release path (changelog-armed.sh, which
|
||||
# gates every PR, release-notes.sh, labels-reconcile.sh) went unlinted
|
||||
# while the comment above told the next author it was covered.
|
||||
# Measured when this invariant was introduced: dotglob added exactly
|
||||
# those three and nothing else — a checkout's `.git` carries no `*.sh`
|
||||
# (its hooks ship as `*.sample`), so `**/*.sh` does not wander into it.
|
||||
#
|
||||
# The sweep below is the CLASS check, same shape as the eof_guard_sweep
|
||||
# in test/cli.sh (#112): the one-time fix is `dotglob`, but what keeps
|
||||
# the gap from reopening is asserting that every TRACKED script is in
|
||||
# the set actually handed to shellcheck. `git ls-files` is the authority
|
||||
# on what the repo contains; if the glob ever drifts from it again —
|
||||
# another dot-directory, another shopt subtlety — CI says which files
|
||||
# escaped instead of quietly linting a subset and passing.
|
||||
run: |
|
||||
shopt -s globstar
|
||||
shopt -s globstar dotglob
|
||||
files=(bin/* **/*.sh)
|
||||
printf 'shellcheck: %s\n' "${files[@]}"
|
||||
missing="$(comm -13 \
|
||||
<(printf '%s\n' "${files[@]}" | sort -u) \
|
||||
<(git ls-files '*.sh' | sort -u))"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "tracked scripts the shellcheck sweep does not cover (#116):"
|
||||
printf '%s\n' "$missing" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
shellcheck -x "${files[@]}"
|
||||
- name: cli tests
|
||||
run: bash test/cli.sh
|
||||
- name: labels state-machine tests
|
||||
run: bash test/labels-reconcile.sh
|
||||
- name: release-flow tests
|
||||
run: bash test/release.sh
|
||||
# The changelog is ARMED for the next entry (#108). Its own step rather
|
||||
|
|
@ -30,7 +65,48 @@ jobs:
|
|||
# tree, not about the release machinery, so when it goes red the log
|
||||
# says which check found the drift without anyone reading a suite.
|
||||
- name: changelog is armed for the next entry
|
||||
run: bash .github/scripts/changelog-armed.sh
|
||||
uses: heavy-duty/ceremony/actions/changelog-armed@0.1.0
|
||||
# ...and no SHIPPED release heading was deleted or DUPLICATED (#122, #143).
|
||||
# Its own step for the same reason as the one above — when it goes red the
|
||||
# log names the invariant that broke — but a DIFFERENT invariant: armed is
|
||||
# a fact about this tree, monotonicity is a fact about this tree versus
|
||||
# its merge base. STRICT=1 so a checkout that cannot reach the base ref
|
||||
# fails here instead of skipping quietly forever.
|
||||
#
|
||||
# NOT pull-request-only, and that is the #143 fix at the workflow level.
|
||||
# The two halves have different vacuity: DELETION is vacuous on a push to
|
||||
# main (the merge base IS HEAD), but DUPLICATION is vacuous on no tree at
|
||||
# all, so gating the whole script on `pull_request` left a duplicate that
|
||||
# reached main by any other route unasserted forever.
|
||||
#
|
||||
# The `|| github.ref_name` fallback is load-bearing, not defensive. On a
|
||||
# push event `github.base_ref` is EMPTY, so the argument would collapse to
|
||||
# a bare `origin/`, which does not resolve — and STRICT=1 correctly
|
||||
# promotes that to a hard failure, turning every push to main red. With
|
||||
# the fallback it resolves to the pushed branch, whose merge base with
|
||||
# HEAD is HEAD or its parent: containment passes vacuously, exactly as the
|
||||
# old `if` intended, while uniqueness now runs on every push.
|
||||
# ...and a RELEASE tree has a drill record. Its own step for the same
|
||||
# reason as the two above — when it goes red the log names the invariant
|
||||
# that broke — and a third invariant again: armed is about the shape of
|
||||
# the changelog, monotonic about the diff, this one about whether the
|
||||
# ritual CONTRIBUTING.md hangs on the release PR actually happened.
|
||||
#
|
||||
# NOT pull-request-only, and for the same reasoning the monotonic guard
|
||||
# was un-gated in #143: the release does not finish at the PR. The merge
|
||||
# that publishes it is a PUSH to main carrying the same bare VERSION,
|
||||
# and it is release.yml's trigger — so a check that only ever ran on the
|
||||
# PR would leave the tree that actually ships unasserted, and any bare
|
||||
# VERSION reaching main by another route (a direct push, a backfill,
|
||||
# the manual tag path) unasserted forever. On a -dev tree, which is
|
||||
# every ordinary push and PR, the script no-ops green by design, so
|
||||
# running it everywhere costs nothing and closes the route.
|
||||
- name: a release tree has a drill record
|
||||
uses: heavy-duty/ceremony/actions/drill-recorded@0.1.0
|
||||
- name: no shipped changelog heading was deleted or duplicated
|
||||
uses: heavy-duty/ceremony/actions/changelog-monotonic@0.1.0
|
||||
- name: vendored ceremony doctrine matches the pin
|
||||
uses: heavy-duty/ceremony/actions/docs-sync@0.1.0
|
||||
|
||||
# The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a
|
||||
# disposable VM, which is exactly the substrate the rehearsal needs. It runs
|
||||
|
|
|
|||
53
.github/workflows/labels.yml
vendored
53
.github/workflows/labels.yml
vendored
|
|
@ -1,56 +1,13 @@
|
|||
name: labels
|
||||
# The automation LABELS.md promises. Two halves:
|
||||
# scope — path-derived scope:* labels on PRs (actions/labeler)
|
||||
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
|
||||
#
|
||||
# pull_request_target, not pull_request: every PR here arrives from a fork,
|
||||
# where pull_request (and pull_request_review) run with a READ-ONLY token and
|
||||
# cannot label anything. _target is safe in this workflow because no PR code
|
||||
# is ever checked out or executed — labeler reads changed paths via the API,
|
||||
# and reconcile checks out the BASE branch only. Keep it that way.
|
||||
#
|
||||
# Review-submitted transitions (bots finishing a round) ride the cron: there
|
||||
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
|
||||
# the same cadence the reviewer bots poll at.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
||||
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||
pull_request_target:
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
|
||||
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled]
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scope:
|
||||
if: github.event_name == 'pull_request_target'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: labels-scope-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
# additive only — a hand-applied scope must survive the machine
|
||||
sync-labels: false
|
||||
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
||||
# PR-event runs must serialize or two sweeps race the same PR's labels
|
||||
# and both pass the request-the-human-once guard. GitHub keeps at most
|
||||
# one queued run per group (older queued runs are superseded), which
|
||||
# coalesces bursts instead of piling them up.
|
||||
concurrency:
|
||||
group: labels-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # base branch only — never the PR's code
|
||||
- name: reconcile state + stale
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: bash .github/scripts/labels-reconcile.sh
|
||||
labels:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
|
||||
|
|
|
|||
217
.github/workflows/release.yml
vendored
217
.github/workflows/release.yml
vendored
|
|
@ -1,213 +1,18 @@
|
|||
name: release
|
||||
# The release publisher — two doors into the same act (#83, #96):
|
||||
#
|
||||
# * The merge door (#96): merging the `release`-labeled PR into main IS the
|
||||
# release. The label is the intent, the version transition is the
|
||||
# interlock — VERSION at the merge commit must be non-`-dev` AND must have
|
||||
# changed in this PR, so a mislabeled ordinary PR fails loudly and creates
|
||||
# NOTHING. The job then tags the merge commit via the API and publishes,
|
||||
# in the SAME job on purpose: a GITHUB_TOKEN-created tag does not trigger
|
||||
# other workflows (GitHub's anti-recursion), so that tag can never re-enter
|
||||
# the tag door below and double-publish — publishing here is the only
|
||||
# chance, and the no-existing-tag/release assert covers a manual tag
|
||||
# racing the merge.
|
||||
#
|
||||
# * The tag door (#83) stays as the documented manual fallback and backfill,
|
||||
# on a bare X.Y.Z tag push (the 0.6.0 tag set the precedent — no 'v'
|
||||
# prefix). The tag must name the tree's own VERSION (a mismatch fails
|
||||
# loudly and creates NOTHING — a wrong release is worse than a missing
|
||||
# one).
|
||||
#
|
||||
# Both doors publish the release body from that version's CHANGELOG.md
|
||||
# section (.github/scripts/release-notes.sh, shared with test/release.sh) —
|
||||
# the curated prose, not the generated PR list. No assets are uploaded: for a
|
||||
# pure-bash tree, GitHub's source tarball for the tag IS the package, and
|
||||
# install.sh downloads exactly that.
|
||||
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||
on:
|
||||
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
||||
# `push:` silently replaces the first and kills a door (rig's review catch).
|
||||
push:
|
||||
# The merge door rides pushes to MAIN, not pull_request events, for one
|
||||
# load-bearing reason the first review round caught (#97): a workflow
|
||||
# run triggered by a pull_request from a public FORK gets a READ-ONLY
|
||||
# GITHUB_TOKEN — `permissions:` cannot raise that ceiling — and every
|
||||
# ceremony PR this org has ever merged is cross-repo from the bot fork.
|
||||
# The asserts would pass and the tag create would 403, red on main,
|
||||
# every release. A push to main is an in-repo event with the full write
|
||||
# token, whoever authored the PR.
|
||||
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||
# never be skipped by a shape filter that didn't match
|
||||
branches: [main]
|
||||
# Every tag, not a shape filter (rig's precedent): a tag that mismatches
|
||||
# VERSION — a habitual v0.7.0, a typo — must fail the assert LOUDLY
|
||||
# below, not be silently skipped by a pattern that didn't match.
|
||||
tags: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: write # create the tag ref + gh release create + the bump push
|
||||
# Two consumers (a declared permissions: block zeroes every unspecified
|
||||
# scope): the decide step's label read (commits/<sha>/pulls) and the bump
|
||||
# fallback's `gh pr create --label`.
|
||||
pull-requests: write
|
||||
# ...and the --label on that fallback PR rides the ISSUES API (labels.yml
|
||||
# grants the same pair for the same reason).
|
||||
issues: write
|
||||
|
||||
contents: write # tag ref create + release create + the bump push
|
||||
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||
issues: write # --label on that fallback PR rides the issues API
|
||||
jobs:
|
||||
# The merge door (#96), riding pushes to main (see the trigger comment:
|
||||
# fork PRs get a read-only token on pull_request events). The hand-set
|
||||
# `release` label (LABELS.md: automation never guesses intent) is read via
|
||||
# the API off the merge commit's PR, inside the decide step below.
|
||||
release-on-merge:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The pushed head plus its first parent (fetch-depth: 2): the
|
||||
# first parent is main the instant before the PR landed, which the
|
||||
# changed-in-this-PR assert compares against.
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 2
|
||||
# The decide step — the version asserts fused, because the `release`
|
||||
# label carries TWO legitimate meanings (LABELS.md: "release flow and
|
||||
# version/packaging work"): the ceremony PR that ships a version, and
|
||||
# ordinary work ON the release machinery — the PR that added this very
|
||||
# job included. The version tells them apart, in four states:
|
||||
# -dev, unchanged → work under the label: green NOTICE
|
||||
# no-op, not a red run per infra PR
|
||||
# -dev, changed → still a dev tree, so still work —
|
||||
# the post-release bump PR above all
|
||||
# (bare -> -dev after every release):
|
||||
# green NOTICE no-op
|
||||
# bare, unchanged, released → work merged in the post-release
|
||||
# window (ceremony landed, the -dev
|
||||
# bump has not): green NOTICE no-op
|
||||
# bare, unchanged, UNreleased→ the label says ship but this PR did
|
||||
# not mint the version: refuse to guess
|
||||
# bare, changed → the ceremony: proceed
|
||||
- name: 'decide: ceremony, or release-flow work under the label?'
|
||||
id: decide
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
base="$(git show HEAD^1:VERSION)"
|
||||
case "$ver" in
|
||||
*-dev)
|
||||
if [ "$base" = "$ver" ]; then
|
||||
echo "NOTICE: VERSION '$ver' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish."
|
||||
echo "ceremony=no" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "NOTICE: VERSION changed ('$base' -> '$ver') and still ends -dev — a dev tree is by definition not a release. This is work (the post-release bump, a renumber); nothing to publish."
|
||||
echo "ceremony=no" >> "$GITHUB_OUTPUT"
|
||||
exit 0 ;;
|
||||
esac
|
||||
if [ "$base" = "$ver" ]; then
|
||||
if gh release view "$ver" --json name >/dev/null 2>&1; then
|
||||
echo "NOTICE: VERSION '$ver' is already released and unchanged by this PR — release-flow work merged in the post-release window (before the -dev bump). Nothing to publish."
|
||||
echo "ceremony=no" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "VERSION '$ver' is bare, unchanged by this PR, and never released — the label says ship but this PR did not mint the version. Refusing to guess — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The version transitioned — now the LABEL, the operator's declared
|
||||
# intent, read via the API because a push event carries no PR
|
||||
# payload (and the PR lives on a fork — the trigger comment). No
|
||||
# merged, release-labeled PR behind this commit = a transition
|
||||
# nobody declared: refuse.
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
||||
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
|
||||
echo "VERSION transitioned ('$base' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#96), not a bare push — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
||||
- name: release notes — the version's own CHANGELOG.md section
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
# release-notes.sh fails loudly on a missing/empty section, which
|
||||
# fails the release here — before anything is created.
|
||||
run: |
|
||||
bash .github/scripts/release-notes.sh "$(cat VERSION)" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly)
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$ver" --silent 2>/dev/null; then
|
||||
echo "tag '$ver' already exists — a manual tag beat this run, or this is a re-run of a published release — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
if gh release view "$ver" --json name >/dev/null 2>&1; then
|
||||
echo "release '$ver' already exists — creating nothing." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: tag the merge commit, then publish — one job, on purpose
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
# Same job as the asserts: the GITHUB_TOKEN-created tag triggers no
|
||||
# workflows (GitHub's anti-recursion), so the tag door cannot fire
|
||||
# off it — this step is the release's only chance to publish.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$ver" -f "sha=$MERGE_SHA"
|
||||
gh release create "$ver" --verify-tag --title "$ver" --notes-file "$RUNNER_TEMP/notes.md"
|
||||
# The post-release bump, folded into the release act (#96 followup —
|
||||
# operator decision: a mechanical one-liner deserves no PR of its
|
||||
# own). X.Y.(Z+1)-dev is arithmetic, not judgment: derived, committed
|
||||
# straight to main with this job's token. A GITHUB_TOKEN push fires
|
||||
# no workflows (anti-recursion), so the bump triggers neither this
|
||||
# door nor a red run; should branch protection ever refuse the direct
|
||||
# push, the step opens the bump PR itself and says so, loudly,
|
||||
# instead of leaving main armed to impersonate the release.
|
||||
- name: bump main to the next -dev — the release re-arms main itself
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
next="$(printf '%s' "$ver" | awk -F. '{ printf "%s.%s.%s-dev", $1, $2, $3 + 1 }')"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main
|
||||
git checkout -B main origin/main
|
||||
printf '%s\n' "$next" > VERSION
|
||||
git add VERSION
|
||||
git commit -m "chore: bump main to $next — a dev install must not impersonate $ver"
|
||||
if ! git push origin main; then
|
||||
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
|
||||
git checkout -b "chore/bump-$next"
|
||||
git push origin "chore/bump-$next"
|
||||
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \
|
||||
--title "chore: bump main to $next" \
|
||||
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One file, one line." \
|
||||
--label release
|
||||
fi
|
||||
|
||||
# The tag door (#83) — the manual fallback and backfill, unchanged. Gated
|
||||
# to the push event so a closed PR (the trigger above) never runs it
|
||||
# against a branch ref.
|
||||
release:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: the tag must name the tree's VERSION
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
||||
echo "tag '$GITHUB_REF_NAME' does not match VERSION '$ver' — creating nothing." >&2
|
||||
echo "A release is a PR, then a tag (#83): the release PR bumps VERSION and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: release notes — the version's own CHANGELOG.md section
|
||||
# release-notes.sh fails loudly on a missing/empty section, which
|
||||
# fails the release here — before anything is created.
|
||||
run: |
|
||||
bash .github/scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/notes.md"
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
- name: create the release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/notes.md"
|
||||
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
|
||||
with:
|
||||
version-source: file
|
||||
|
|
|
|||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# AGENTS.md — start at .ceremony/
|
||||
|
||||
This repository is governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
|
||||
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||
810
CHANGELOG.md
810
CHANGELOG.md
|
|
@ -5,649 +5,161 @@ which records not just what changed but what each drill run proved.
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- `box exec` preserves newlines and command argv across its login-user boundary (#169)
|
||||
|
||||
### Changed
|
||||
|
||||
- Release and repository governance now use the shared ceremony pinned at `0.1.0` (heavy-duty/ceremony#14)
|
||||
|
||||
### Added
|
||||
|
||||
- `kimi-box` template — the Moonshot Kimi CLI agent seed (#158; rig#109's tenant)
|
||||
|
||||
## 0.9.0 — 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- `box import` stamps the trip, leaving the artifact's own mint stamp intact
|
||||
(#131)
|
||||
- A minted box records how it was minted, and `box info` reads it back (#103)
|
||||
- A clone re-stamps its own provenance instead of inheriting its source's
|
||||
(#103)
|
||||
- `box info` grew a provenance block, blank on boxes that predate the stamp
|
||||
(#103)
|
||||
- Every fresh mint marks a `pristine` snapshot, before rig converges anything
|
||||
(#104, heavy-duty/rig#62)
|
||||
- A mint that converges a tenant role marks a `bootstrapped` snapshot (#130)
|
||||
- CI refuses a release PR with no drill record at `drills/<version>.md`
|
||||
|
||||
### Changed
|
||||
|
||||
- `state:needs-human` is set at handoff, not by the cron (#141)
|
||||
- PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
|
||||
is in the way); `state:needs-rebase` is retired
|
||||
- BREAKING: the tenant templates carry rig's family suffix — `claude` →
|
||||
`claude-box`, `codex` → `codex-box`, `grok` → `grok-box`, `staging` →
|
||||
`staging-box` (#123, heavy-duty/rig#76)
|
||||
- Changelog entries are one line each, and the whole file now follows the rule
|
||||
(#147)
|
||||
|
||||
### Fixed
|
||||
|
||||
- `test/release.sh` is green on the release ceremony's own tree
|
||||
- `changelog-monotonic.sh` no longer lets a duplicate heading through when it
|
||||
cannot see the base (#143)
|
||||
- An unreadable check rollup no longer reads as "nothing is failing"
|
||||
- `state:needs-human` no longer appears on PRs a human cannot merge (#136)
|
||||
- CI's shellcheck sweep now lints `.github/scripts/*.sh` (#116)
|
||||
- A PR can no longer delete or duplicate a shipped changelog section and stay
|
||||
green (#122)
|
||||
- An upgrade over a pre-0.7.0 flat `/opt/box` no longer skips host setup (#115)
|
||||
- Host setup runs the version it just installed, not whatever `current` points
|
||||
at (#115)
|
||||
- The pre-0.7.0 migration says what it left behind, and how to keep or reap it
|
||||
(#117)
|
||||
- `teardown-host.sh` refuses a terminal-less run instead of aborting mute
|
||||
(#113)
|
||||
- `drill/wipe.sh` no longer carries #102's SIGPIPE shape, and the pin sweeps
|
||||
the class (#107)
|
||||
- The racing-reader sweep guards the class, not one spelling, and names
|
||||
`incus config trust list` as a second writer (#124)
|
||||
|
||||
## 0.8.0 — 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Merging the release PR IS the release — and the release re-arms main
|
||||
itself** (#96) — the 0.7.0 ceremony ended in an absence: the release PR
|
||||
merged with four approvals and nothing happened, correctly, because
|
||||
publishing hung off a separate, manual, silent-when-forgotten tag push —
|
||||
a failure shape with no error and no red X. The ship decision already
|
||||
lives in the release PR (the one PR whose whole diff is "the version
|
||||
leaves `-dev`"), so `release.yml` now fires on pushes to main
|
||||
(fork-sourced ceremony PRs get a read-only token on `pull_request`
|
||||
events), reading the transition from the push itself: `event.before` to
|
||||
the pushed head. A decide step answers four states — release-flow *work*
|
||||
merged under the `release` label (`-dev` endstates, the post-release
|
||||
window) no-ops green with a NOTICE; the two genuinely ambiguous bare
|
||||
states refuse loudly; a true transition then requires a merged,
|
||||
`release`-labeled PR behind the commit (read via the API — the label is
|
||||
the operator's declared intent) before anything is created. Then, in the
|
||||
same job, it tags the merge commit via the API, publishes — and bumps
|
||||
main to `X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR
|
||||
fallback, so no follow-up bump PR exists on the paved road. Same-job on
|
||||
purpose: a `GITHUB_TOKEN`-created tag triggers no workflows, which is
|
||||
also what makes double-publish impossible. The tag-push path stays
|
||||
unchanged as the documented manual fallback and backfill (it shipped
|
||||
0.7.0 itself). `test/release.sh` grep-pins the gate, every decide
|
||||
verdict, the single `on.push` key, and the same-job tag+publish+re-arm
|
||||
in the same daemon-free, fail-closed style.
|
||||
- Merging the release PR is the release, and the release re-arms main itself
|
||||
(#96)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let
|
||||
`main` sit disarmed** (#108) — the ceremony stamps `## Unreleased` into
|
||||
`## X.Y.Z — DATE` by hand, and nothing put the heading back, so `main`
|
||||
sat with no `## Unreleased` from the release until the next PR that
|
||||
happened to re-create one. A PR authored *before* the release wrote its
|
||||
entry under `## Unreleased`; with that heading gone, git lands the entry
|
||||
under whatever now occupies the position — **the section that just
|
||||
shipped** — and it merges **cleanly**. No conflict, no error, no red X:
|
||||
the one signal an author would trust is absent exactly when the outcome
|
||||
is wrong, and the changelog credits a released version with a change it
|
||||
does not contain until a human reads the file. Confirmed in the sibling
|
||||
repo (heavy-duty/rig#66); box has not drifted yet, and the reason is
|
||||
luck rather than design — 0.6.0's ceremony (`77599ab`) added its heading
|
||||
*without* removing `## Unreleased`, so main was never disarmed, while
|
||||
0.7.0 did disarm it and left a window that nothing happened to cross.
|
||||
Two halves land together. The ceremony step in `CONTRIBUTING.md` is now
|
||||
explicitly **two edits**: stamp, then put an empty `## Unreleased` back
|
||||
above the section just stamped — it belongs there and not in
|
||||
`release.yml`, which only ever touches `VERSION`. And
|
||||
`.github/scripts/changelog-armed.sh` enforces it in CI, keyed on
|
||||
`VERSION` because the two states are genuinely different: a `-dev` tree
|
||||
must carry `## Unreleased` on top, a bare-`VERSION` tree (the ceremony
|
||||
PR, and the merge that publishes it) may carry either that or its own
|
||||
stamped section. The keying is the whole design and not an
|
||||
over-complication — box previously had **no** top-section guard at all,
|
||||
and the obvious one, an unconditional `## Unreleased` requirement, is
|
||||
false by construction on the ceremony PR's own tree, which is why rig#44
|
||||
and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm
|
||||
does not block the release; it turns `main` red on the very next push,
|
||||
the automatic `-dev` bump the release itself makes. Leaving the bare
|
||||
branch's top heading unconstrained is what keeps both ceremony shapes
|
||||
legal, and a review round on the sibling fix (heavy-duty/cast#114) found
|
||||
the gap that asymmetry leaves: a **half-ceremony** tree — `VERSION`
|
||||
bumped, `## Unreleased` still populated on top, and the section for that
|
||||
version never stamped — makes the wrong-number test false on its first
|
||||
clause, short-circuits, and passes. Nothing then refuses until
|
||||
`release.yml` extracts the notes, which is *after* the merge, on `main`,
|
||||
with the release already half-shipped. So the bare branch now also
|
||||
requires that the section it is about to publish exists and is non-empty,
|
||||
and it asserts that by running `release-notes.sh` — the very script
|
||||
`release.yml` runs — so the guard and the publisher cannot drift apart
|
||||
over what a section is. The message is its own: a missing stamp is not a
|
||||
misnumbered one, and an operator sent to correct a version number that is
|
||||
already right will not find the real problem. Matches
|
||||
heavy-duty/rig#67, so the three repos agree.
|
||||
- **Ctrl-D at a confirmation prompt aborts out loud, instead of exiting
|
||||
in silence** (#111) — `confirm()` and `uninstall_confirm()` both took
|
||||
the operator's answer with a bare `read -r reply`. Every answer a
|
||||
human can type routes through the `case` below it and ends at a
|
||||
`return` or at `die "aborted."` — every answer except EOF. Ctrl-D
|
||||
makes `read` return non-zero, `set -euo pipefail` ends the run on that
|
||||
line, and the `case` is never reached: box exits 1 having printed
|
||||
nothing at all after the question it just asked. It fails closed,
|
||||
which is why this is a small fix and not an incident — nothing is
|
||||
destroyed, the abort is real. The damage is that the tool goes mute at
|
||||
the one moment it had the operator's full attention, and someone who
|
||||
Ctrl-Ds out of `box rm work` cannot tell from the output whether the
|
||||
box is still there. The cure is one token in each function,
|
||||
`read -r reply || die "aborted."`, the same one heavy-duty/rig#43
|
||||
applied to rig's credential prompts so the two repos read alike. The
|
||||
bug predates everything it touches — `rm` has carried a confirm gate
|
||||
for as long as the verb has existed — but #105 took the number of
|
||||
verbs reaching that line from one to two, and both are irreversible,
|
||||
which is the argument for closing it now rather than the next time
|
||||
someone notices. The three answers a human can actually give (`y`,
|
||||
`n`, and Ctrl-D) are now driven for real on a pty via util-linux
|
||||
`script`: they were structurally untested before, because `[ -t 0 ]`
|
||||
sends a terminal-less suite to the refusal branch and every existing
|
||||
check stopped there — which is exactly how this survived four
|
||||
releases. Review caught that the first pass fixed the bug where it was
|
||||
reported and stopped there, while the same defect sat at two more
|
||||
destructive gates in this repo: `host/revoke-user.sh:50`, the prompt
|
||||
guarding `box revoke --purge` — the one whose own text says "this
|
||||
cannot be undone" — and `host/teardown-host.sh:31`, guarding a full
|
||||
host teardown. Both run under `set -euo pipefail`, both died mute on
|
||||
EOF with their `aborted` line never reached; both now carry the guard
|
||||
in their own script's wording. The three `drill/` prompts are
|
||||
deliberately left alone — they run under `set -u` only, so EOF falls
|
||||
through to the `*)` arm and already aborts out loud — and
|
||||
`install.sh:65` was already guarded. What keeps the class closed is a
|
||||
repo-wide sweep in `test/cli.sh`: every statement-initial `read` fed
|
||||
from stdin, in any file that turns on errexit, must carry a `||`
|
||||
guard, with `while read` loops and `<<<` herestrings excluded because
|
||||
neither is a prompt. The sweep flags all four sites when their guards
|
||||
are removed and nothing else across the tree's fifteen shell files —
|
||||
the absence of exactly this check is why the `host/` pair was missed
|
||||
in the first place.
|
||||
- **`box restore` asks before it destroys — and the confirmation prompt is
|
||||
now the row's, not rm's** (#105) — `restore` and `rm` both irreversibly
|
||||
discard user state, and only one of them asked. The table gave `restore`
|
||||
the preconditions `box,arg2`: the instance is ours, a snapshot name is
|
||||
present, go. So `box restore work stale-label` silently threw away
|
||||
everything done in the box since that snapshot, with no prompt, no
|
||||
`--force`, and no way to take it back — a warning in `--help` is not a
|
||||
gate. It has been that way since the verb shipped, and it is about to
|
||||
become routine rather than rare (heavy-duty/rig#62's pristine snapshot),
|
||||
which is the wrong time to still be relying on the operator typing the
|
||||
right label. The reason it stayed ungated is worth recording, because it
|
||||
is the actual bug: `confirm` was already a precondition token, but the
|
||||
dispatch line hardcoded the *words* — `confirm "delete $inst and all its
|
||||
snapshots"` — so the one-token fix would have gated restore behind a
|
||||
prompt offering to DELETE the box the operator was trying to rescue. A
|
||||
gate that names the wrong act is worse than no gate; it is how people
|
||||
learn to answer `y` without reading. So the prompt moved into the table
|
||||
as a seventh field, each row saying what it is about to do in its own
|
||||
words, and `restore` now asks to "roll `<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.
|
||||
|
||||
- The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let main sit
|
||||
disarmed (#108, heavy-duty/rig#67)
|
||||
- Ctrl-D at a confirmation prompt aborts out loud instead of exiting in
|
||||
silence (#111)
|
||||
- `box restore` asks before it destroys, in the row's own words rather than
|
||||
`rm`'s (#105)
|
||||
- `box-firewall` could hand a UFW host the no-UFW firewall, ~2% of the time
|
||||
(#102)
|
||||
- A missing firewall log now diagnoses itself (#102)
|
||||
- `box grant` provisions an `incus-admin` member instead of refusing them
|
||||
(#99)
|
||||
|
||||
## 0.7.0 — 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- **The installer defaults to the latest release, and releases publish
|
||||
themselves** (#83) — `curl | bash` used to hand out whatever `main` was at
|
||||
that second: the 0.6.0 release was a bookmark, not a package, and two
|
||||
operators "on 0.6.0" could be running different trees. `install.sh` now
|
||||
resolves the latest release tag by following GitHub's `releases/latest`
|
||||
redirect (one HEAD request — no API, no token, no rate-limit pain) and
|
||||
downloads that tag's tarball; a failed resolution refuses loudly, naming
|
||||
`BOX_REF` as the way out — it never hangs and never silently falls back to
|
||||
`main`. A set `BOX_REF` is tried as a tag first, then as a branch, so one
|
||||
knob yields three channels: default = latest release, `BOX_REF=0.6.0` =
|
||||
pinned, `BOX_REF=main` = dev. A new `release.yml` (on a bare `X.Y.Z` tag
|
||||
push — the `0.6.0` tag set the no-`v` precedent) asserts the tag names the
|
||||
tree's own `VERSION` (a mismatch fails loudly and creates nothing) and
|
||||
publishes the GitHub release with that version's `CHANGELOG.md` section as
|
||||
the body (`.github/scripts/release-notes.sh` — the curated prose, not the
|
||||
generated PR list; no assets, the source tarball for the tag IS the
|
||||
package). And `main`'s `VERSION` now carries `-dev` between releases
|
||||
(this PR: `0.6.1-dev`): the versioned layout names install trees after
|
||||
`VERSION`, so a `main` install without the bump would land in
|
||||
`versions/0.6.0` and impersonate the released tree. `test/release.sh`
|
||||
drives all of it offline — the extraction against fixtures and the real
|
||||
changelog, the resolution and every channel against a shim curl.
|
||||
- **`setup-host` auto-picks a free subnet — nested box-in-box with zero
|
||||
flags** (#80, completing its fix #1: "refuse … or automatically select a
|
||||
non-colliding subnet"). A bare `box setup-host` now decides the subnet
|
||||
itself, in four deliberate cases: an explicit `BOX_SUBNET` is honored or
|
||||
refused, never silently overridden (scripted hosts keep exact semantics);
|
||||
an existing `boxnet` bridge is converged on as-is — the bridge IS the pin —
|
||||
turning the old bare-re-run agree-gate refusal into plain convergence
|
||||
(unless a foreigner *also* claims the bridge's subnet: that is #80's
|
||||
poisoned state, and converging would rebuild on it, so it still refuses and
|
||||
names the bridge move); a free `10.88.0.0/24` stays the default; and a
|
||||
*claimed* default — the nested case: a drill or rehearsal running inside a
|
||||
box, whose own uplink owns 10.88 — scans `10.89.0.0/24` … `10.127.0.0/24`
|
||||
in order, takes the first free candidate, announces the pick and the
|
||||
claimant loudly, and only refuses when every candidate is claimed. The
|
||||
decision happens before any mutation, and everything downstream (the
|
||||
bridge, `BOX_GW`, the ACL's gateway carve-out, the firewall, the doctor's
|
||||
expectations) derives from it.
|
||||
- **`setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another**
|
||||
(#80) — run inside a box, `setup-host` used to build a nested `boxnet` on
|
||||
the exact subnet and gateway of the guest's own uplink: the guest then held
|
||||
its gateway's address as a *local* address, carried duplicate connected
|
||||
routes for its uplink subnet, and suffered intermittent, self-recovering
|
||||
egress blackouts that looked like flaky internet (measured live: ~24–36 s
|
||||
outages, roughly hourly, with the host clean throughout). `setup-host` now
|
||||
scans the target subnet **before any mutation** — the default route's
|
||||
gateway inside it, or any non-`boxnet` interface holding an address in it —
|
||||
and refuses, naming the way out. A prior `boxnet` owning the subnet is the
|
||||
legitimate converge path and does not trip it. `BOX_SUBNET=<a.b.c.0/24>`
|
||||
(validated, alongside the existing `BOX_DNS`) moves the whole stack: the
|
||||
bridge address, the ACL's gateway carve-out (now converged via
|
||||
`network acl edit`, so a bridge moved off a colliding subnet no longer
|
||||
strands box DNS behind a stale `/32`), the firewall (`box-firewall` reads
|
||||
the gateway off the live bridge), and every drill/migrate probe that used
|
||||
to hardcode `10.88`.
|
||||
- **`box doctor` knows the #80 signature** — a default gateway held as a
|
||||
LOCAL address, and duplicate connected routes for the uplink subnet, judged
|
||||
from `ip route`/`ip addr` on the machine doctor runs on (both tiers, before
|
||||
any daemon check — the nested daemon answering could be the impostor) and
|
||||
probed *inside* every box it examines. The existing "egress broken but DNS
|
||||
fine" split now names itself as #80's fingerprint (the impostor dnsmasq on
|
||||
a captured gateway keeps resolving while IP egress dies), and the admin ACL
|
||||
section verifies the gateway carve-out matches `boxnet`'s actual gateway.
|
||||
The agent-context guard for the templates (suggested fix 4) lands in
|
||||
heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).
|
||||
|
||||
- **The `staging` template** (#81, the re-cut of #69's layering) — a
|
||||
server-class, creds-free seed: Debian 13, user `ops`, tmux, rig,
|
||||
`BOX_REQUIRE_VM=1` (the VM is its trust boundary), `BOX_AUTOSTART=1` (a
|
||||
server returns from a host reboot without an operator), and
|
||||
`BOX_BOOTSTRAP_ROLE="staging"` — the server posture (docker, sshd
|
||||
hardening) converges via `rig bootstrap staging` after mint. The tailnet
|
||||
workload join holds a pre-auth key and therefore **stays operator-run**
|
||||
(`box shell` → `sudo rig bootstrap workload`), printed as a next step —
|
||||
box never sees the key.
|
||||
- **`BOX_BOOTSTRAP_ROLE` template key + mint-time auto-run** (#81) — a
|
||||
template names the **creds-free** rig tenant role box runs inside the guest
|
||||
after cloud-init settles (`incus exec … rig bootstrap <role>`); the value
|
||||
is a role *name* by allowlist (anything shell-shaped dies at parse time, on
|
||||
the host). A failed role leaves the box up and names the re-run — the roles
|
||||
are convergent by contract (rig#31). `blank` names no role and auto-runs
|
||||
nothing.
|
||||
- **The rig pin point: `RIG_REPO` / `RIG_REF`** (#81) — the tenant seeds
|
||||
preinstall rig, inverting the rig→box install edge (rig#28), and the new
|
||||
edge gets the same honest treatment rig#29 gave box's unpinned install:
|
||||
`@RIG_REPO@`/`@RIG_REF@` tokens in the seed resolve at mint from the
|
||||
environment (default `heavy-duty/rig` @ `main` — unpinned, tracking main,
|
||||
until a release flow exists, rig#32/#83). The pin covers both the installer
|
||||
fetched and the tree it installs, so a rig branch under review is testable
|
||||
end to end; values are allowlist-validated before touching the YAML.
|
||||
- **Server-posture template keys** (#81, carved from #69) — two optional
|
||||
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
|
||||
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
|
||||
(exit 2): such a template's trust boundary is the VM. `BOX_AUTOSTART=1`
|
||||
stamps `boot.autostart=true` at launch, per-instance like `limits.*`, so
|
||||
the box returns from a host reboot without an operator; clones inherit it
|
||||
via `incus copy`. Still no key for a network or a `security.*` flag, on
|
||||
purpose.
|
||||
- **Dynamic template test suite** (#81, carved from #69) — `test/cli.sh`
|
||||
discovers `templates/*/` instead of hardcoding the list, so a new template
|
||||
cannot ship unseen. Per template: `box.env` is driven through the real,
|
||||
extracted `load_template` (unknown keys and missing `BOX_IMAGE`/`BOX_USER`
|
||||
fail, fixtures proving both dies); `user-data.yaml` exists, declares
|
||||
`#cloud-config`, parses as YAML, and installs tmux (#65). Grep guards pin
|
||||
the `cmd_new` half: the `REQUIRE_VM` refusal orders after `pick_mode`, and
|
||||
`boot.autostart` is stamped only under the `T_AUTOSTART` guard.
|
||||
- **`box export` / `box import`** (#70) — a box's state that survives the box
|
||||
_and_ the host, unblocking #66's humane upgrade flow (down, export, rm,
|
||||
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into
|
||||
one portable backup tarball (default `<box>-<UTC stamp>.tar.gz`), snapshots
|
||||
included by default (`--instance-only` opts out); the box must be stopped
|
||||
first (`box down`) so the artifact is a settled disk, not a moving one. The
|
||||
file is **shouted about, not scrubbed** — it carries the box's whole disk
|
||||
(agent logins, git credentials, SSH keys), and scrubbing a disk image is a
|
||||
promise tarball surgery cannot keep, so box says what is inside instead,
|
||||
every time. `box import <file> [--name <box>]` mints the box back and
|
||||
re-stamps what is the _current host's_ truth, not the artifact's: the
|
||||
`user.box=1` boundary tag (legacy `user.claudebox=1` honored), the
|
||||
`box-net` placement (re-assigned if the artifact's differs — the
|
||||
migrate-host move), and a fresh machine identity: the NIC's MAC (imports
|
||||
restore `volatile.*` verbatim, and a re-import beside its sibling collided
|
||||
at start with "MAC address already defined on another NIC" — measured
|
||||
live; `incus copy` regenerates it, `incus import` does not) plus
|
||||
`reset_identity` (the clone trust boundary: no DHCP collision with the box
|
||||
it was exported from).
|
||||
Import refuses any name an existing instance holds — the `resolve_box`
|
||||
boundary, seen from the other side. Works on both tiers: `box grant` now
|
||||
also converges `restricted.backups allow` (incus-user blocks backups by
|
||||
default exactly like snapshots, and an export _is_ a backup
|
||||
create+download — measured against incus 6.0's `permissions.go`); re-run
|
||||
`box grant <user>` after upgrading, as documented. CI's `rehearsal` job now
|
||||
proves the round-trip on a live Incus: mint → write a file → snapshot →
|
||||
down → export → `rm` → import → the file and the snapshot survived, the
|
||||
agent answers, the tag is present, and a colliding re-import is refused.
|
||||
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
|
||||
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
|
||||
`INSTALLED_FROM`), with a `current` symlink tracking the default and
|
||||
`$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New
|
||||
verbs: `box versions` (lists installs, marks the current default and the
|
||||
running tree), `box use <version>` (flips the default, converges the PATH
|
||||
symlinks, and *asserts the effective result* — `current` must resolve to
|
||||
the asked-for version and the chain's `box --version` must answer it).
|
||||
Re-running the installer with an installed version is a converging no-op
|
||||
(`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs
|
||||
side-by-side and flips `current` only when no boxes exist — under existing
|
||||
boxes the flip is refused loudly, naming the boxes (#66: never change
|
||||
versions under a user's boxes; `box use` keeps the same refusal). A
|
||||
pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's
|
||||
tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale
|
||||
or dangling `$BINDIR/box` is healed instead of wedging the install; and the
|
||||
installer warns when the *other* tier's install (/opt/box vs ~/.local)
|
||||
coexists, since PATH order decides which wins.
|
||||
- **A real uninstall** — `box uninstall [<version>] [--all] [--purge-host]`
|
||||
replaces the "rm -rf two paths" prose. One version: refuses the current one
|
||||
(`box use` off it first). Everything: runs in the safe order — refuses
|
||||
while boxes exist (naming them) unless `--purge-host` runs teardown-host
|
||||
first — then removes every version, the `current` and PATH symlinks, and
|
||||
the legacy claudebox crumbs (both name generations), and **ends with an
|
||||
absence assert**: every removed path is re-checked, and any survivor makes
|
||||
it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the
|
||||
`revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1`
|
||||
for automation and now points at `box uninstall` when done.
|
||||
- **`BOX_INSTALL_SOURCE=<dir-or-tarball>`** — installs from a local tree,
|
||||
bypassing the download. CI's rehearsal job now installs via install.sh
|
||||
itself (proving the installer under review, not a `cp -r` mimic of it), and
|
||||
ends with an **uninstall drill**: grant + `revoke --purge` a throwaway
|
||||
user, `teardown-host`, `box uninstall --all`, then assert **zero residue**
|
||||
— no networks, profiles, ACLs, nft tables, systemd units, files or
|
||||
symlinks.
|
||||
- **test/cli.sh drives real installs** — still dependency-free, non-root, no
|
||||
daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a
|
||||
fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge,
|
||||
reinstall, side-by-side upgrade, the three #66 refusals (install flip,
|
||||
`use`, `uninstall` — boxes named), flat-tree migration, symlink healing,
|
||||
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
|
||||
into *driven* tests instead of greps (154 checks).
|
||||
- The installer defaults to the latest release, and releases publish
|
||||
themselves (#83)
|
||||
- `setup-host` auto-picks a free subnet — nested box-in-box with zero flags
|
||||
(#80)
|
||||
- `setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another (#80)
|
||||
- `box doctor` knows the #80 signature: a gateway held as a local address, and
|
||||
duplicate connected routes for the uplink subnet
|
||||
- The `staging` template — a server-class, creds-free seed (#81)
|
||||
- The `BOX_BOOTSTRAP_ROLE` template key, auto-run at mint (#81)
|
||||
- The rig pin point: `RIG_REPO` / `RIG_REF` (#81)
|
||||
- Server-posture template keys `BOX_REQUIRE_VM` and `BOX_AUTOSTART` (#81)
|
||||
- The template test suite discovers `templates/*/` instead of hardcoding the
|
||||
list (#81)
|
||||
- `box export` / `box import` — a box's state that survives the box and the
|
||||
host (#70)
|
||||
- Versioned installs at `<root>/versions/<v>`, with `box versions` and
|
||||
`box use` (#66)
|
||||
- A real uninstall: `box uninstall [<version>] [--all] [--purge-host]`, ending
|
||||
in an absence assert
|
||||
- `BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree, and CI's
|
||||
rehearsal drills the uninstall to zero residue
|
||||
- `test/cli.sh` drives real installs against throwaway roots and a fake incus
|
||||
(154 checks)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Thin templates — box mints, rig converges** (#81, companion rig#31) —
|
||||
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the
|
||||
agent CLI installs, docker, node, the per-template agent-context heredocs)
|
||||
**moves to rig's bootstrap roles**, where it is convergent, idempotent and
|
||||
testable end to end instead of parse-only YAML. What remains per template
|
||||
is a thin, creds-free seed: the tenant user, tmux (#65), and rig
|
||||
preinstalled — nothing that joins a tailnet or admits credentials. The #80
|
||||
agent-context guard ("never run `box setup-host` or the drill inside a
|
||||
box") now lives once, in rig's roles, not copy-pasted per template. The
|
||||
template test sweep grew the contract's teeth: per-template seed asserts
|
||||
(user matches, rig pinned via both tokens) and fail-closed **absence
|
||||
greps** over effective cloud-init lines — no agent CLI, no docker, no
|
||||
tailscale/authkey/ssh, no `write_files` heredocs — so tenant content
|
||||
cannot quietly grow back.
|
||||
- Thin templates — box mints a creds-free seed, rig's bootstrap roles converge
|
||||
the tenant content (#81, heavy-duty/rig#31)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A wedged `incus launch` fails loudly, not forever — the mint's launch
|
||||
phase is narrated and time-boxed** (#93) — twice in the 2026-07-19
|
||||
release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached),
|
||||
the child `incus launch` under `box new` hung with *no server-side
|
||||
operation*: `incus operation list` empty, the instance never created, the
|
||||
daemon journal quiet — one wedge ran 56 minutes before being killed by
|
||||
hand, and an immediate retry of the identical command succeeded in
|
||||
minutes, both times. `box new` inherited that as an indefinite silent
|
||||
hang, indistinguishable from a cold mint working. It now prints
|
||||
`launching instance …` before the call, and the call rides
|
||||
`timeout -k 5 $BOX_LAUNCH_TIMEOUT` (seconds, default 600 — generous: the
|
||||
coldest measured mint is minutes, never an hour; the same scripting-knob
|
||||
shape as `BOX_CPU`/`BOX_MEMORY`), with stdin pinned per the drill's own
|
||||
trap list. On the budget firing it probes whether the instance was ever
|
||||
registered and tells the two stories apart — the measured #93 wedge (no
|
||||
server-side operation; an immediate retry has been observed to succeed)
|
||||
vs a slow launch that overran the budget with the instance already
|
||||
created — then best-effort deletes either way, so the retry advice is
|
||||
clean in both worlds, and points at `box doctor` for the host. The
|
||||
`--from` clone path is untouched: `incus copy` of a local instance is a
|
||||
different operation and has never been observed to wedge this way.
|
||||
- **UFW's gateway carve-out converges with the bridge, and the doctor can
|
||||
see it** (the #86 review's blind spot) — `box-firewall` gated its whole
|
||||
UFW block behind "a `DENY on boxnet` rule exists", pinning every UFW host
|
||||
to the gateway of the *first* run: a bridge remapped off a colliding
|
||||
subnet (#80's escape hatch) kept its stale `allow … to <old-gw> port 53`
|
||||
and never gained the live gateway's, so box→gateway DNS died at box's own
|
||||
deny — while the doctor's carve-out check read only the incus ACL (which
|
||||
setup-host converges) and called the host clean. The UFW allows now
|
||||
converge off the live bridge address on every run (stale DNS allows
|
||||
deleted, the live set ensured — ufw skips existing rules, so a fresh host
|
||||
gets the identical rule set and a re-run is a no-op), and `box doctor`
|
||||
reads UFW's own table wherever UFW is active, flagging a DNS allow that
|
||||
does not match `boxnet`'s gateway (and stale allows left beside a live
|
||||
one). The no-UFW nft carve-out never had this failure mode: it is
|
||||
interface-scoped, no gateway address to go stale.
|
||||
- **The boot-time gateway fallback is gone — no rule beats a wrong one** —
|
||||
with the bridge not yet addressed when `box-firewall.service` ran,
|
||||
`box-firewall` guessed `GW=10.88.0.1`; on a `BOX_SUBNET` host that hit
|
||||
that window the UFW carve-out was built for the wrong gateway, a latent
|
||||
DNS drop (#86 review). It now fails closed: an unaddressed bridge leaves
|
||||
the persisted UFW rules exactly as they are (they survive boots on their
|
||||
own, and nothing else in the script needs the gateway) and says so on
|
||||
stderr; the next setup-host run or service restart converges them once
|
||||
the bridge is addressed.
|
||||
- **`revoke --purge` re-checks the incus-user state** — the purge removed
|
||||
`/var/lib/incus/users/<uid>` without ever asserting its absence, the one
|
||||
path its own absence block did not cover; and the stat now rides
|
||||
`$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin,
|
||||
so a bare `[ -d ]` answered "absent" for a directory that was there).
|
||||
- **A wedged `$BINDIR/box` no longer blocks installing** — the old
|
||||
no-op-if-installed check keyed off the symlink's existence OR the tree's,
|
||||
so a stale symlink (or a half-removed tree) could fake "already installed"
|
||||
forever. Installed-ness is now judged from `versions/<v>` itself; symlinks
|
||||
are converged with `ln -sfn`, never trusted as the signal.
|
||||
- A wedged `incus launch` fails loudly, not forever: the launch phase is
|
||||
narrated and time-boxed (#93)
|
||||
- UFW's gateway carve-out converges with the bridge, and the doctor can see it
|
||||
(#86)
|
||||
- The boot-time gateway fallback is gone — an unaddressed bridge leaves the
|
||||
persisted UFW rules alone (#86)
|
||||
- `revoke --purge` re-checks the incus-user state, and stats it through
|
||||
`$SUDO`
|
||||
- A wedged `$BINDIR/box` no longer blocks installing
|
||||
|
||||
## 0.6.0 — 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin
|
||||
runs `box grant <user>` and that user gets their own boxes on the same
|
||||
hardened `boxnet`, seeing nobody else's; `box revoke <user>` takes it back
|
||||
(`--purge` deletes their world, and asserts the absence). The tier rides
|
||||
incus-user, whose defaults miss box's contract three measured ways (Debian
|
||||
13 / Incus 6.0.4): a private _unhardened_ NAT bridge per user, snapshots
|
||||
blocked, the `box-net` profile invisible — so grant is an idempotent
|
||||
convergence: project narrowed to `boxnet` **and only boxnet** (listing the
|
||||
private bridge too, the obvious fix, would keep an unhardened network one
|
||||
`--network` flag away), snapshots allowed, the shipped profile installed
|
||||
into their project. `box_tier()` (live credentials, argless `id -nG`)
|
||||
drives the tier-aware surface: `expose` refuses honestly before any daemon
|
||||
call, `setup-host` and `doctor` answer at the caller's tier. Rehearsed
|
||||
end-to-end by `drill/multiuser.sh` (criteria a–n: confinement, lifecycle,
|
||||
cross-user visibility, name collisions, the in-box isolation contract,
|
||||
escape hatches, re-sync survival, revoke incl. the live-session case) —
|
||||
54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review.
|
||||
- **CI runs the multi-user rehearsal on a real Incus** — a second `rehearsal`
|
||||
job stands up the full stack on the runner (setup-host, doctor, then
|
||||
`multiuser.sh --container`), so every PR proves the tier's semantics
|
||||
against a live daemon, not a mock. The VM trust boundary itself remains a
|
||||
real-hardware ritual, like the full drill.
|
||||
- **Global / root install** (#71) — run as root, box installs _once_ to
|
||||
`/opt/box` (world-readable) with the `box` symlink on `/usr/local/bin`, so
|
||||
every operator on a shared host runs the same tree. Per-user installs are
|
||||
unchanged (`$HOME/.local`); `BOX_HOME`/`BOX_BIN` still override. A per-user
|
||||
tree under `/root` is `0700` and unreadable to everyone else — the whole fleet
|
||||
got `command not found` — so the root branch lands in a system location and
|
||||
`chmod -R a+rX`'s it (read for files, +search on dirs), guarded on root. This
|
||||
unblocks "rig installs box" (rig#24's `box` role).
|
||||
- **CI + a test suite** — `.github/workflows/ci.yml` (a `check` job: globstar
|
||||
`shellcheck -x` over `bin/* **/*.sh`, then `bash test/cli.sh`) and `test/cli.sh`,
|
||||
dependency-free and runnable by a non-root user with no Incus. It exercises the
|
||||
`install.sh` DEST/BINDIR branch functionally (both tiers + `BOX_HOME`/`BOX_BIN`
|
||||
overrides), the CLI contract, and grep-guards the daemon-gated invariants and
|
||||
tmux in every template — the box was the repo with "no tests and no CI".
|
||||
- The restricted tier: `box grant` / `box revoke` give a user their own boxes
|
||||
on the shared hardened `boxnet` (#74)
|
||||
- CI runs the multi-user rehearsal on a real Incus
|
||||
- Global / root install — one world-readable tree at `/opt/box` (#71)
|
||||
- CI and a test suite: `.github/workflows/ci.yml` and `test/cli.sh`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`box restore` never worked against Incus 6** — the command table
|
||||
dispatched `incus restore`, a subcommand that does not exist (Incus 6
|
||||
spells it `incus snapshot restore`), so every restore died on "unknown
|
||||
command". Found by #74's rehearsal exercising the full lifecycle as a
|
||||
restricted user; fixed for every tier, and the rehearsal + a grep-guard in
|
||||
`test/cli.sh` now hold it.
|
||||
- **`box tmux` works on every template** (#65) — `box tmux` runs
|
||||
`tmux new-session` _inside_ the box, but the templates did not install tmux, so
|
||||
it failed with `tmux: command not found`. `tmux` is now in each template's
|
||||
cloud-init package list (`blank`/`claude`/`codex`/`grok`).
|
||||
|
||||
- **`box setup-host` finishes in one run** (#63). When it had to add you to
|
||||
`incus-admin` it stopped there and told you to re-login and re-run — an
|
||||
`exit 0` that reported success having built none of the stack: no `boxnet`,
|
||||
no ACL, no `box-net` profile, no firewall. It now re-execs itself under
|
||||
`sg incus-admin` and completes in that one invocation. The membership check
|
||||
was also asking the wrong question: `id -nG "$USER"` reads the group
|
||||
database, which lists the group the moment `usermod` returns, so a
|
||||
same-session re-run passed the check with credentials that still lacked the
|
||||
group and died further down on a bare permission error from `incus`. Argless
|
||||
`id -nG` asks the process what it actually holds.
|
||||
|
||||
- **`setup-host` works as root, with or without `sudo`** — every privileged
|
||||
call was a hardcoded `sudo`, so on a minimal root image (no `sudo` package)
|
||||
it died on `sudo: command not found` before doing anything. Privilege is now
|
||||
resolved once: nothing at UID 0, `sudo` otherwise, and a clear error if
|
||||
neither is possible. This is what made `install.sh`'s root path real rather
|
||||
than nominal.
|
||||
- **`setup-host` grants `incus-admin` to the human, not to root** — under
|
||||
`sudo install.sh` it would have added `root` to the group: a no-op (UID 0
|
||||
opens the socket regardless) that also left the actual user locked out of
|
||||
their own boxes. It now derives the login user from `SUDO_USER`.
|
||||
- **`box-firewall.service` now reports its state honestly** — the unit is
|
||||
`Type=oneshot` and was missing `RemainAfterExit=yes`, so it went
|
||||
`inactive (dead)` the instant it succeeded: a host whose isolation was
|
||||
perfectly live read as one whose firewall unit had died. drill.sh sends you
|
||||
to `systemctl status box-firewall` to diagnose exactly that, and
|
||||
setup-host.sh's own comment already asserted the unit "is RemainAfterExit" —
|
||||
it was not. Found by running the drill on a real host and mistrusting the
|
||||
green: `nft list table bridge box` showed the drop live while the unit read
|
||||
dead. `restart` was and remains correct either way.
|
||||
- **`setup-host`'s apt calls can no longer hang** — a fresh cloud image has
|
||||
`apt-daily`/`unattended-upgrades` holding the dpkg lock, and a plain
|
||||
`apt-get install` waits on it silently and indefinitely. Now bounded
|
||||
(`DPkg::Lock::Timeout=300`) and non-interactive, which matters because
|
||||
`install.sh` runs it with nobody watching.
|
||||
- `box restore` never worked against Incus 6 — it dispatched `incus restore`,
|
||||
which does not exist
|
||||
- `box tmux` works on every template — tmux is in each template's package list
|
||||
(#65)
|
||||
- `box setup-host` finishes in one run, re-execing itself under
|
||||
`sg incus-admin` (#63)
|
||||
- `setup-host` works as root, with or without `sudo`
|
||||
- `setup-host` grants `incus-admin` to the human, not to root
|
||||
- `box-firewall.service` reports its state honestly, via `RemainAfterExit=yes`
|
||||
- `setup-host`'s apt calls can no longer hang on the dpkg lock
|
||||
|
||||
### Changed
|
||||
|
||||
- **`drill.sh` proves the new contract instead of masking it** — the drill ran
|
||||
`setup-host` itself right after installing, so the stack existed by its own
|
||||
hand and a run passed identically whether or not `install.sh` had done a
|
||||
thing; a fresh run converged the stack three times, while the messages still
|
||||
described the pre-#63 "first pass may only add you to the group" behaviour.
|
||||
It now asserts the post-install stack in-group before touching the host, and
|
||||
runs `setup-host` exactly once more — after the clean, which deliberately
|
||||
unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1`
|
||||
hands sequencing back to the drill. Pre-setup tripwires now read _before_
|
||||
`install.sh`, since that is what triggers setup now.
|
||||
- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now
|
||||
prompts _"Install box?"_, then on a fresh host installs the tree and asks a
|
||||
second question, _"Set up this machine as a box host now?"_, running the whole
|
||||
isolation stack if you say yes (previously it only printed a warning and left
|
||||
you a command, so the install reported success and `box new` died on a host
|
||||
with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script
|
||||
itself is stdin; `BOX_YES=1` answers yes unattended (required where there is
|
||||
no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step.
|
||||
- **`install.sh` never overwrites an existing install** — if box is already
|
||||
installed it says so and changes nothing, so a stray re-run can no longer
|
||||
clobber a working tree or rebuild the host stack under live boxes. Upgrading
|
||||
is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes
|
||||
preserved first) and install fresh. This replaces the earlier version-diff
|
||||
refusal with a simpler rule that dissolves the same class of errors. The
|
||||
version-aware upgrade that migrates boxes instead is #67; a portable
|
||||
`box export` so a box survives its own deletion is #70.
|
||||
- `drill.sh` asserts the post-install stack instead of building it itself
|
||||
- `install.sh` asks, sets up the host, and no-ops on re-run (#64)
|
||||
- `install.sh` never overwrites an existing install
|
||||
|
||||
## 0.5.0 — 2026-07-15
|
||||
|
||||
|
|
@ -658,61 +170,27 @@ installer retires — but nothing current carries the old name.
|
|||
|
||||
### Added
|
||||
|
||||
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes,
|
||||
creds-free like every template. The template mechanic (image + user +
|
||||
resources, never a network or a `security.*` key) now has three tenants
|
||||
beside `blank`, and the drill mints all of them cold.
|
||||
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only
|
||||
door to a port inside a box, for seeing a dev server in your browser. The
|
||||
listen side is always the host's `127.0.0.1` (no flag to widen it), the door
|
||||
is per-port, `--list`/`--remove` manage it, and `box info` shows open
|
||||
exposures — a box with a hole says so.
|
||||
- **Inline resource overrides on `new`** — `--cpu <n> --memory <size>
|
||||
--disk <size>` (#57). Resolution most-specific-first: flag > `BOX_CPU` /
|
||||
`BOX_MEMORY` / `BOX_DISK` environment (the scripting form) > template
|
||||
`box.env` > defaults. Values pass to Incus verbatim; resources are all a
|
||||
flag can touch. `--from` refuses them — a clone carries its source's
|
||||
resources.
|
||||
- **Host lifecycle as verbs** — `box setup-host`, `box teardown-host`, and
|
||||
`box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
|
||||
(`--box <n>` / `--all-boxes`, authed state preserved) and retires the legacy
|
||||
bridge once empty (`--retire-legacy`).
|
||||
- **The `.box/` recipe convention** — the agent-facing runbook folder a repo
|
||||
can ship, renamed from `.claudebox/` (both spellings read).
|
||||
- `codex` and `grok` templates
|
||||
- `box expose <box> <port> [<host-port>]` — a loopback-only door to a port
|
||||
inside a box
|
||||
- Inline resource overrides on `new`: `--cpu`, `--memory`, `--disk` (#57)
|
||||
- Host lifecycle as verbs: `box setup-host`, `box teardown-host`,
|
||||
`box migrate-host`
|
||||
- The `.box/` recipe convention, renamed from `.claudebox/` (both spellings
|
||||
read)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **VM mints no longer hang at GRUB** — Incus defaults VMs to Secure Boot on,
|
||||
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
|
||||
shim signature" forever. Boxes now launch with `security.secureboot=false`;
|
||||
the VM boundary, not boot attestation, is the box threat model.
|
||||
- **`box expose` actually delivers packets** — a trilogy of drill-found
|
||||
absences: the NAT proxy needs the box's boxnet lease pinned as a static
|
||||
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not
|
||||
the lease); a loopback-sourced packet needs `route_localnet` plus a
|
||||
masquerade on the bridge to leave the host and be answerable; and the box's
|
||||
replies need a `ct state established,related` accept ahead of the host
|
||||
firewall's input drop, which was eating them statelessly. Boxes still
|
||||
cannot initiate toward the host — a box-originated SYN is a NEW flow.
|
||||
- **Firewall rules now converge on upgrade** — `box-firewall.sh` rebuilds its
|
||||
chains every run (add + flush + re-add) instead of skipping when they
|
||||
exist, which had pinned every host to the rule set of the release that
|
||||
first ran there.
|
||||
- **Failed mints tell you why** — cloud-init failures print the box's own log
|
||||
excerpts and leave the box up to inspect; a mint that never boots names the
|
||||
likely cause (corrupt image, Secure Boot, GRUB hang) and ships a sanitized
|
||||
console dump; the installer asserts it landed the ref it was asked for.
|
||||
- **`grok` installs the binary it actually ships** — the installer was read,
|
||||
not guessed at, and the CLI lands on the non-interactive PATH (same fix
|
||||
class as codex).
|
||||
- VM mints no longer hang at GRUB — boxes launch with
|
||||
`security.secureboot=false`
|
||||
- `box expose` actually delivers packets
|
||||
- Firewall rules converge on upgrade instead of pinning a host to the release
|
||||
that first ran there
|
||||
- Failed mints tell you why
|
||||
- `grok` installs the binary it actually ships
|
||||
|
||||
### Changed
|
||||
|
||||
- **Debrand complete** — env vars, install dir, docs, template descriptions
|
||||
and the README all say `box`; the install URL is
|
||||
`heavy-duty/box` (GitHub redirects the old one, `BOX_REPO` overrides).
|
||||
- **The drill grew from 47 to 84 checks** — the expose door opened, exercised
|
||||
and shut (with the contract re-probed around it), every template minted
|
||||
cold, a faithful pre-0.4.0 box re-homed through `migrate-host`, and the
|
||||
inline resource flags asserted (including their precedence over the
|
||||
environment).
|
||||
- Debrand complete — env vars, install dir, docs, template descriptions and
|
||||
the README all say `box`; the install URL is `heavy-duty/box`
|
||||
- The drill grew from 47 to 84 checks
|
||||
|
|
|
|||
196
CONTRIBUTING.md
196
CONTRIBUTING.md
|
|
@ -1,143 +1,83 @@
|
|||
# Contributing
|
||||
|
||||
How change lands in this repo. The short version: PRs are born as drafts,
|
||||
three reviewer bots take the first rounds, a human takes the last word — and
|
||||
labels tell you where everything is without opening anything.
|
||||
This repository is governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read
|
||||
[`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first, then the role file it
|
||||
selects. The files under `.ceremony/` are machine-managed and must never be
|
||||
edited in place.
|
||||
|
||||
## The PR loop
|
||||
Only triage mints issues. Everyone else opens or extends a discussion when
|
||||
they find work outside an existing issue contract. Only humans merge.
|
||||
|
||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
|
||||
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
|
||||
deserves one.
|
||||
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
||||
bots on purpose.
|
||||
3. **When it's ready**: mark ready-for-review and request all three bots —
|
||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
||||
They poll roughly every 15 minutes.
|
||||
4. **Rounds are answered whole.** Wait until all three have reviewed, then
|
||||
answer the entire round in a **single reply**, push the fixes, and
|
||||
re-request the bots that didn't approve. Prefer verification over
|
||||
argument: a test settles what a comment thread can't.
|
||||
5. **Reviews end in a verdict.** A reviewer — bot or human — either
|
||||
**approves** or **requests changes**, never a bare comment. A
|
||||
comment-only review is a non-verdict: it doesn't say whether the round
|
||||
passed, and the state machine (and anyone scanning the board) has to
|
||||
guess. The verdict carries *blockingness only*, the body carries the
|
||||
feedback: non-blocking nits ride an **approval** and the author addresses
|
||||
them at their discretion; anything blocking — including a question that
|
||||
gates the verdict — is **request changes**, saying what unblocks it. The
|
||||
reconciler treats a comment-only review as not-approved, so commenting
|
||||
without a verdict only stalls the PR. The machine never reads review
|
||||
bodies: when a comment-only reviewer's line is really an agreement, that
|
||||
judgment belongs to the **author** — escalate by requesting the
|
||||
maintainer's review (step 6), and the reconciler flips the label on that
|
||||
request, because an explicit request is a fact it can trust.
|
||||
6. **When the round passes, the author hands the PR to the maintainer** by
|
||||
requesting their review — that request is what flips `state:needs-human`.
|
||||
With three formal head-current approvals the labels workflow requests it
|
||||
automatically; when part of the panel is comment-only, reading their
|
||||
agreement is the author's judgment, so the author makes the request.
|
||||
7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally
|
||||
mirror what CI runs; the multi-user rehearsal runs in CI on a real Incus.
|
||||
## Review panel
|
||||
|
||||
The review panel is:
|
||||
|
||||
- `claude-bot-andresmgsl`
|
||||
- `codex-bot-andresmgsl`
|
||||
- `grok-bot-andresmgsl`
|
||||
- `kimi-bot-andresmgsl`
|
||||
|
||||
Every PR needs a current-head verdict from the whole panel minus its author.
|
||||
`dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain
|
||||
invisible to the panel; when ready, request every eligible reviewer.
|
||||
|
||||
## Code and verification
|
||||
|
||||
- Bash executables use `set -euo pipefail`; test harnesses use `set -u`
|
||||
because they assert failing commands.
|
||||
- Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`;
|
||||
CI also runs the Incus multi-user rehearsal.
|
||||
- Match whole versions: `0.7.0` must never match `0.7.0-rc1`.
|
||||
- Comments preserve the incident that bought a rule, including its issue
|
||||
number.
|
||||
|
||||
## Changelog
|
||||
|
||||
Every behavior-changing PR adds one concise line under `## Unreleased`,
|
||||
above the shipped heading below it. Cite the issue or PR. Never replace or
|
||||
duplicate a shipped heading; the shared armed and monotonic guards enforce
|
||||
both halves of this rule.
|
||||
|
||||
## Releases
|
||||
|
||||
A release is a PR, and merging it ships it
|
||||
([#96](https://github.com/heavy-duty/box/issues/96), building on
|
||||
[#83](https://github.com/heavy-duty/box/issues/83)):
|
||||
The release ceremony, merge and tag doors, version stamps, guard semantics,
|
||||
and recovery paths are defined by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony/blob/0.1.0/README.md).
|
||||
Box pins the shared machinery and doctrine at `0.1.0`.
|
||||
|
||||
1. **The release PR** — `release: X.Y.Z`, labeled `release` — bumps `VERSION`
|
||||
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
|
||||
date (feature PRs land their changelog entry as part of the PR, so the
|
||||
section is already written).
|
||||
Box uses the `file` version backend and has no artifact hook: for this
|
||||
pure-Bash tree, GitHub’s source tarball for the tag is the package, and
|
||||
`install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and
|
||||
`drills/<version>.md` remain box-owned release inputs.
|
||||
|
||||
**Stamping is two edits, not one — the second is re-arming.** After
|
||||
rewriting `## Unreleased` into `## X.Y.Z — DATE`, put an **empty
|
||||
`## Unreleased` back at the top**, immediately above the section you just
|
||||
stamped:
|
||||
### What a box drill proves
|
||||
|
||||
```markdown
|
||||
## Unreleased
|
||||
The box drill is the 85-probe VM isolation contract: it exercises the trust
|
||||
boundary on real hardware. The lighter Incus container rehearsal in CI proves
|
||||
the tier mechanics but cannot substitute for that boundary measurement. The
|
||||
record format and operating procedure live in [drills/README.md](drills/README.md).
|
||||
|
||||
## 0.7.1 — 2026-07-19
|
||||
`drills/<version>.md` and [`drill/RUNS.md`](drill/RUNS.md) are deliberately
|
||||
different artifacts. The former is per-release evidence read by the release
|
||||
guard; the latter is the harness’s ongoing run log and lore. Updating one
|
||||
never satisfies the purpose of the other.
|
||||
|
||||
### Fixed
|
||||
...
|
||||
```
|
||||
The family drills are independent and may run in any order. Each pins the
|
||||
same fixed candidate refs: rig’s drill uses the candidate box ref, while
|
||||
box’s drill mints with the candidate rig ref. Static refs dissolve the
|
||||
box↔rig runtime recursion; no repository needs to release first.
|
||||
|
||||
Not cosmetic, and not deferrable to the next PR that happens to need it.
|
||||
Between the stamp and the next re-creation of that heading, `main` has no
|
||||
`## Unreleased`. A PR authored *before* the release wrote its entry under
|
||||
that heading; with the heading gone, git lands the entry under whatever
|
||||
now occupies the position — **the section that just shipped** — and it
|
||||
merges **cleanly**, no conflict, no signal. The changelog then credits a
|
||||
released version with a change it does not contain, and nothing but a
|
||||
human reading the file will ever say so
|
||||
([#108](https://github.com/heavy-duty/box/issues/108); confirmed in the
|
||||
sibling repo as
|
||||
[heavy-duty/rig#66](https://github.com/heavy-duty/rig/issues/66)).
|
||||
A known gap remains from box#81: released box templates still default
|
||||
`RIG_REF` to `main`, so a later mint may consume a rig revision other than
|
||||
the one drilled. This conversion does not change that behavior or claim the
|
||||
gap is closed.
|
||||
|
||||
CI enforces the arming rule with
|
||||
[.github/scripts/changelog-armed.sh](.github/scripts/changelog-armed.sh),
|
||||
keyed on `VERSION`: a `-dev` tree must carry `## Unreleased` on top; a
|
||||
bare-`VERSION` tree (the ceremony PR, and the merge that publishes it) may
|
||||
carry either `## Unreleased` or its own stamped section. That is why the
|
||||
guard cannot simply demand `## Unreleased` unconditionally — the
|
||||
unconditional form is false on the ceremony PR's own tree and makes the
|
||||
release unshippable, which is why rig and cast both reverted it. The
|
||||
practical consequence: forgetting to re-arm does **not** block the release
|
||||
PR, it turns `main` red on the very next push — the automatic `-dev` bump
|
||||
the release itself makes. Do it in the ceremony PR and main is never
|
||||
disarmed at all.
|
||||
## Scope labels
|
||||
|
||||
This PR is where the release ritual hangs:
|
||||
the full drill on real hardware, recorded in
|
||||
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
|
||||
PR, a release still proves the boundary.
|
||||
2. **The maintainer's merge IS the release.**
|
||||
[release.yml](.github/workflows/release.yml) fires on the merged,
|
||||
`release`-labeled PR and asserts before creating anything: `VERSION` at
|
||||
the merge commit is non-`-dev` **and changed in this PR** (the `-dev`
|
||||
interlock — a mislabeled ordinary PR fails loudly and creates nothing),
|
||||
the version's `CHANGELOG.md` section extracts non-empty, and no tag or
|
||||
release exists for it yet. Then, in the same job, it tags the merge
|
||||
commit bare `X.Y.Z` (no `v` prefix, the `0.6.0` precedent) and publishes
|
||||
the GitHub release with that section as the body. No assets — the source
|
||||
tarball for the tag is the package, and `install.sh` downloads exactly
|
||||
that.
|
||||
|
||||
*Manual fallback/backfill*: the tag-push path stays. Tagging the merge
|
||||
commit bare `X.Y.Z` by hand and pushing the tag still publishes the same
|
||||
way (release.yml asserts the tag names the tree's own `VERSION`) — for
|
||||
backfills, or the day the merge path is red.
|
||||
3. **The release re-arms main itself**: the same workflow run bumps
|
||||
`VERSION` to `X.Y.(Z+1)-dev` and pushes the commit straight to main —
|
||||
no follow-up PR (it opens one only if branch protection refuses the
|
||||
direct push, and says so loudly). Not cosmetic — the versioned layout
|
||||
names install trees after `VERSION`, so a `main` install without the
|
||||
bump would land in `versions/X.Y.Z` and impersonate the release just
|
||||
cut. On the *manual* tag path the bump stays yours: open the one-line
|
||||
PR after publishing.
|
||||
|
||||
## Labels — who sets what
|
||||
|
||||
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
|
||||
who sets each kind — most of it is machinery, and hand-moving a
|
||||
machine-owned label just gets corrected on the next pass:
|
||||
|
||||
| Labels | Set by |
|
||||
|---|---|
|
||||
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. |
|
||||
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
|
||||
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
|
||||
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
|
||||
| `blocked`, `release` | you — automation never guesses intent. |
|
||||
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
|
||||
|
||||
## Issues
|
||||
|
||||
Give issues the same care as PR titles: say the surface in the title, apply a
|
||||
`scope:` label and a type label (`bug` / `enhancement` / `documentation`) when
|
||||
you open one, and `blocked` when it waits on something — that is what keeps
|
||||
the board navigable as the issue count grows.
|
||||
- `scope:cli` — `bin/box`, the command surface
|
||||
- `scope:installer` — `install.sh`, versioned installs, upgrade/uninstall
|
||||
- `scope:host` — host setup, teardown, firewall, and isolation stack
|
||||
- `scope:tiers` — grant/revoke and multi-user boundaries
|
||||
- `scope:templates` — template and profile seeds
|
||||
- `scope:drill` — rehearsals, doctor, and run evidence
|
||||
|
|
|
|||
87
LABELS.md
87
LABELS.md
|
|
@ -1,87 +0,0 @@
|
|||
# Labels
|
||||
|
||||
How this repo uses GitHub labels. The taxonomy is shared across the
|
||||
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
|
||||
because it names this repo's actual surfaces.
|
||||
|
||||
## State — who is the ball with? (PRs, exactly one)
|
||||
|
||||
Every open PR carries exactly one `state:` label, and it answers the only
|
||||
question a board scan actually asks: *who is this PR waiting on?* The states
|
||||
mirror the review loop this repo runs — PRs open as drafts, three reviewer
|
||||
bots pick up ready PRs with reviews requested, each round is answered in a
|
||||
single reply, and a human takes the final review.
|
||||
|
||||
| Label | Color | Waiting on | Enters when | Leaves when |
|
||||
|---|---|---|---|---|
|
||||
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
|
||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
|
||||
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
|
||||
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
|
||||
|
||||
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
|
||||
first means *poke the bots*, staleness in the second means *the agent dropped
|
||||
the ball*. Collapsing them loses exactly the information a sweep needs.
|
||||
|
||||
## Cross-cutting (PRs and issues)
|
||||
|
||||
| Label | Color | Meaning |
|
||||
|---|---|---|
|
||||
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
|
||||
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
|
||||
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
|
||||
|
||||
## Scope — which surface? (PRs and issues, any number)
|
||||
|
||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
|
||||
|
||||
| Label | Covers |
|
||||
|---|---|
|
||||
| `scope:cli` | `bin/box` — the command surface itself |
|
||||
| `scope:installer` | `install.sh`, the versioned install layout, upgrade/uninstall |
|
||||
| `scope:host` | `host/` — setup-host, teardown, the firewall and isolation stack |
|
||||
| `scope:tiers` | the restricted tier — grant/revoke, multi-user semantics |
|
||||
| `scope:templates` | `templates/` — the box seeds |
|
||||
| `scope:drill` | `drill/` — the rehearsals, doctor, RUNS.md |
|
||||
|
||||
## Issue types
|
||||
|
||||
`bug`, `enhancement`, `documentation` — issues only. PRs carry their type in
|
||||
the conventional title (`feat:`, `fix:`, `docs:`), so typing a PR with a label
|
||||
would just say the same thing twice, drifting apart eventually.
|
||||
|
||||
## Maintenance
|
||||
|
||||
State labels are written by automation, never by hand. Every state above is
|
||||
derivable from GitHub's own facts — the draft flag, requested reviewers,
|
||||
review states, push timestamps — so the labels workflow
|
||||
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
|
||||
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
|
||||
A hand-moved label is a lie waiting to happen; the workflow asserts the
|
||||
effective state instead. `scope:` labels on PRs are applied from the changed
|
||||
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
|
||||
|
||||
The same workflow bootstraps the taxonomy: a manual dispatch creates any
|
||||
missing label idempotently. To create them by hand (needs push access):
|
||||
|
||||
```sh
|
||||
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
|
||||
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
|
||||
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
|
||||
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force
|
||||
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
|
||||
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
|
||||
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
|
||||
gh label create "scope:cli" --color C5DEF5 --description "bin/box — the command surface" --force
|
||||
gh label create "scope:installer" --color C5DEF5 --description "install.sh, versioned installs, upgrade/uninstall" --force
|
||||
gh label create "scope:host" --color C5DEF5 --description "host/ — setup, teardown, firewall, isolation stack" --force
|
||||
gh label create "scope:tiers" --color C5DEF5 --description "restricted tier — grant/revoke, multi-user" --force
|
||||
gh label create "scope:templates" --color C5DEF5 --description "templates/ — the box seeds" --force
|
||||
gh label create "scope:drill" --color C5DEF5 --description "drill/ — rehearsals, doctor, RUNS.md" --force
|
||||
# delete is not an upsert: a label that is already gone exits non-zero. Swallow
|
||||
# that, so this block converges on re-run instead of erroring after first success.
|
||||
for L in duplicate invalid question wontfix "help wanted" "good first issue"; do
|
||||
gh label delete "$L" --yes 2>/dev/null || true
|
||||
done
|
||||
```
|
||||
207
README.md
207
README.md
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||
network-isolated Incus box from a **template**; the coding-agent templates
|
||||
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
|
||||
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
||||
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
|
||||
(OpenAI Codex), `grok-box` (xAI Grok), `kimi-box` (Moonshot Kimi) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
||||
converges**: the template is a thin seed, and the agent tooling lands via a
|
||||
creds-free `rig bootstrap` role auto-run at mint
|
||||
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
|
||||
|
|
@ -217,13 +217,14 @@ VM boundary itself is proven on real hardware, like the rest of the drill).
|
|||
## Quick start
|
||||
|
||||
```sh
|
||||
box new --name work --template claude # a creds-free coding-agent box (~10 min cold)
|
||||
box shell work # enter as the template's user
|
||||
box new --name work --template claude-box # a creds-free coding-agent box (~10 min cold)
|
||||
box shell work # enter as the template's user
|
||||
```
|
||||
|
||||
Pick whichever coding-agent template you like — `claude`, `codex`, `grok` — or
|
||||
`blank` for none. Inside the box, authenticate as needed. The `claude` template
|
||||
looks like this; the others follow the same shape with their own login step:
|
||||
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
|
||||
`grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
|
||||
`claude-box` template looks like this; the others follow the same shape with
|
||||
their own login step:
|
||||
|
||||
```sh
|
||||
claude # then run /login — copy the URL (press c), open it
|
||||
|
|
@ -238,13 +239,14 @@ claude # if the repo has .box/, the agent reads it and
|
|||
No coding agent is special — each is one template among several, and adding
|
||||
another is just another directory. What ships today:
|
||||
|
||||
| Template | What it becomes |
|
||||
| --------- | ---------------------------------------------------------------------- |
|
||||
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
||||
| `claude` | Claude Code, creds-free — where this project started |
|
||||
| `codex` | OpenAI Codex CLI, creds-free |
|
||||
| `grok` | xAI Grok CLI, creds-free |
|
||||
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
||||
| Template | What it becomes |
|
||||
| ------------- | ------------------------------------------------------------------ |
|
||||
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
||||
| `claude-box` | Claude Code, creds-free — where this project started |
|
||||
| `codex-box` | OpenAI Codex CLI, creds-free |
|
||||
| `grok-box` | xAI Grok CLI, creds-free |
|
||||
| `kimi-box` | Moonshot Kimi CLI, creds-free |
|
||||
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
||||
|
||||
**Templates are thin seeds; rig does the becoming**
|
||||
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
|
||||
|
|
@ -255,8 +257,11 @@ pin tokens below). The seed is deliberately small — the tenant user, tmux,
|
|||
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
|
||||
joins a tailnet or admits credentials — and after cloud-init settles, box
|
||||
auto-runs the template's **creds-free** tenant role inside the guest
|
||||
(`rig bootstrap claude` / `codex` / `grok` / `staging`,
|
||||
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI,
|
||||
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `kimi-box` / `staging-box`,
|
||||
[rig#31](https://github.com/heavy-duty/rig/issues/31); the roles carry a
|
||||
family suffix — `-box` for box tenants, `-server` for fleet machines — and a
|
||||
template is named for the role it converges,
|
||||
[rig#76](https://github.com/heavy-duty/rig/issues/76)). The agent CLI,
|
||||
docker, the server posture and the agent-context file all come from that
|
||||
role — convergent and idempotent, so the same command re-run later converges
|
||||
an *existing* box to a newer spec (`box shell <box>` →
|
||||
|
|
@ -265,9 +270,9 @@ an *existing* box to a newer spec (`box shell <box>` →
|
|||
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
|
||||
from rig's roles, instead of copy-pasted per template.
|
||||
|
||||
**Anything that joins or admits stays operator-run.** The `staging` box's
|
||||
tailnet workload join holds a pre-auth key, so box only prints it as the
|
||||
next step — `box shell <name>`, then `sudo rig bootstrap workload` — and
|
||||
**Anything that joins or admits stays operator-run.** The `staging-box`
|
||||
tenant's tailnet workload join holds a pre-auth key, so box only prints it as the
|
||||
next step — `box shell <name>`, then `sudo rig bootstrap workload-server` — and
|
||||
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
|
||||
split, kept).
|
||||
|
||||
|
|
@ -279,9 +284,9 @@ carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
|
|||
environment:
|
||||
|
||||
```sh
|
||||
box new --name work --template claude # heavy-duty/rig @ main
|
||||
box new --name work --template claude-box # heavy-duty/rig @ main
|
||||
RIG_REPO=you/rig RIG_REF=my-branch \
|
||||
box new --name trial --template claude # a rig branch under review
|
||||
box new --name trial --template claude-box # a rig branch under review
|
||||
```
|
||||
|
||||
Both directions of that edge track `main` unpinned today — said honestly,
|
||||
|
|
@ -328,6 +333,84 @@ refuses rather than assuming yes).
|
|||
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||
snapshot labels and the `--from` line to clone one.
|
||||
|
||||
### `pristine` — the one checkpoint box takes for you
|
||||
|
||||
Every fresh mint marks a snapshot called `pristine`
|
||||
([#104](https://github.com/heavy-duty/box/issues/104)) at the one moment it
|
||||
is true: **after cloud-init, before `rig bootstrap` converges the tenant
|
||||
role.** At that instant the guest is pristine Debian plus box's thin seed
|
||||
(the user, tmux, rig) and nothing else — the state
|
||||
[heavy-duty/rig#62](https://github.com/heavy-duty/rig/issues/62) calls "back
|
||||
to pristine Debian". It exists for a few seconds on every mint, so box
|
||||
captures it rather than asking you to be quick.
|
||||
|
||||
```sh
|
||||
box restore work pristine # undo the tenant role and everything since
|
||||
```
|
||||
|
||||
That is a complete undo for every tenant role: everything `rig bootstrap
|
||||
claude|codex|grok|staging` does — docker, node, the agent CLI, the
|
||||
agent-context file, the role marker — is box-local and file-shaped, so a
|
||||
filesystem rollback reaches all of it, without paying a ~10-minute re-mint.
|
||||
|
||||
Three things it deliberately does not do:
|
||||
|
||||
- **It is an undo, not a backup.** Snapshots die with their box: `box rm`
|
||||
deletes a box _and_ every snapshot it has. `box export` is the only state
|
||||
that outlives the box — see below.
|
||||
- **It cannot reach off-box state.** A tailnet join, a GitHub runner
|
||||
registration, a pushed commit: those are records held somewhere else, and
|
||||
no filesystem rollback undoes them (rig#62 covers those separately).
|
||||
- **A `--from` clone gets no `pristine` of its own.** A clone skips
|
||||
cloud-init and rig entirely, so it has no pristine moment to capture, and
|
||||
box will not label a source's worked-in state as one. Cloning a _box_
|
||||
inherits the source's snapshots (a real `pristine` among them, if the
|
||||
source had one); cloning a _snapshot_ starts with none. `box new` says
|
||||
which of the two you got.
|
||||
|
||||
On a host whose storage pool uses the `dir` driver, a snapshot is a full
|
||||
multi-GB copy rather than a near-free copy-on-write mark, so the mint
|
||||
**skips** `pristine` and says so loudly — take it by hand with `box snapshot
|
||||
<box> pristine` if you want it anyway. btrfs is what `box setup-host`
|
||||
installs by default precisely so snapshots are cheap. `BOX_SNAPSHOT_PRISTINE=0`
|
||||
skips the mark on any host.
|
||||
|
||||
### `bootstrapped` — the same undo, one step later
|
||||
|
||||
A mint whose template names a bootstrap role marks a second snapshot,
|
||||
`bootstrapped` ([#130](https://github.com/heavy-duty/box/issues/130)), once
|
||||
`rig bootstrap` has run and box has **watched it succeed**. That is the box
|
||||
converged and not yet touched — the state you actually wanted back most of
|
||||
the time.
|
||||
|
||||
```sh
|
||||
box restore work bootstrapped # keep the tenant role, undo what you did to it
|
||||
box restore work pristine # throw the tenant role away too
|
||||
```
|
||||
|
||||
Same rules as `pristine`: default on, never fatal, skipped loudly on a `dir`
|
||||
pool (with two marks that disk cost is twice the size, so a CoW-less host is
|
||||
not asked to pay it), and `BOX_SNAPSHOT_BOOTSTRAPPED=0` skips it anywhere.
|
||||
The same two caveats apply, unchanged: it **dies with the box** on `box rm`
|
||||
(`box export` is the durable path), and it **cannot reach off-box state** —
|
||||
a tailnet device record, a runner registration (rig#62).
|
||||
|
||||
**Read the label in one direction only.** Its _presence_ means the mint-time
|
||||
hook converged and nothing has touched the box since. Its _absence_ means
|
||||
nothing at all, because box only marks a hook it watched:
|
||||
|
||||
- A **blank** box runs no hook, so there is no convergence to mark. It gets
|
||||
none — `pristine` and `bootstrapped` would be the same disk state at twice
|
||||
the cost, and a label claiming a convergence that never happened is worse
|
||||
than no label.
|
||||
- A box whose hook **failed** gets none either. box tells you to re-run the
|
||||
role by hand through `box shell`, and a by-hand run happens in a shell box
|
||||
does not watch — so box hands you `box snapshot <box> bootstrapped` to take
|
||||
at the moment it is true, rather than inventing a fact.
|
||||
- A `dir`-pool host and `BOX_SNAPSHOT_BOOTSTRAPPED=0` both skip it.
|
||||
|
||||
`box info <box>` is what actually tells you which labels a box has.
|
||||
|
||||
## Survive the host: `box export` / `box import`
|
||||
|
||||
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
|
||||
|
|
@ -386,13 +469,17 @@ the door is per-port, punched and removable at runtime.
|
|||
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
|
||||
box templates # list the templates this install can mint
|
||||
box list # list your boxes
|
||||
box info <box> # one box: state, IP, exposures, snapshot labels
|
||||
box info <box> # one box: state, IP, exposures, provenance, snapshots
|
||||
box shell <box> # enter as the template's user
|
||||
box exec <box> -- <cmd...> # run a command in the box
|
||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||
box restore <box> <snap> [--force]
|
||||
# roll back to a snapshot — destructive, asks first
|
||||
# 'pristine' is auto-marked at mint: back to
|
||||
# pristine Debian + box's seed, before rig ran
|
||||
# 'bootstrapped' too, if a rig hook converged:
|
||||
# the role kept, everything since undone
|
||||
box export <box> [<file>] [--instance-only]
|
||||
# one portable file (snapshots incl.) — survives rm & host
|
||||
box import <file> [--name <box>]
|
||||
|
|
@ -423,6 +510,82 @@ clones an existing box or snapshot. VM mode (`--vm`, the default where
|
|||
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
||||
dev/test only.
|
||||
|
||||
## What minted this box: `box info`
|
||||
|
||||
A box outlives the release that minted it, the template that shaped it and the
|
||||
image build it came from — and until
|
||||
[#103](https://github.com/heavy-duty/box/issues/103) it recorded none of them.
|
||||
There is no host-side per-box store; the Incus instance config _is_ the
|
||||
database, so a fact not written at mint time is simply gone. `box new` now
|
||||
stamps what it knew, and `box info` reads it back:
|
||||
|
||||
```
|
||||
NAME work
|
||||
STATE RUNNING
|
||||
TYPE VM
|
||||
IPV4 10.x.x.x
|
||||
|
||||
MINTED 2026-07-19T14:22:07Z by box 0.8.1
|
||||
TEMPLATE claude (user claude, role claude)
|
||||
IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b…
|
||||
MODE vm (asked: auto)
|
||||
RIG heavy-duty/rig@main
|
||||
ORIGIN mint
|
||||
```
|
||||
|
||||
The image line carries both halves on purpose: the template names an
|
||||
_unpinned alias on a moving remote_, so what it resolved to at that mint is the
|
||||
only reproducible fact. `box info --json` carries every key verbatim — they
|
||||
ride `incus list --format json` in `config`.
|
||||
|
||||
**A clone re-stamps.** `incus copy` preserves `user.*` keys, so a clone inherits
|
||||
its source's template and user for free — but inheriting the mint stamp would
|
||||
not make it stale, it would make it **false**: the clone was not present at that
|
||||
mint. `box new --from` therefore re-stamps the four keys that describe _this_
|
||||
instance's coming into being (`ORIGIN clone of work/authed`, a fresh time, the
|
||||
box version that cloned it) and leaves the lineage keys alone, because the
|
||||
clone's disk genuinely did come from that image, template and role. `origin.from`
|
||||
records one hop: a clone of a clone names its parent, not its grandparent.
|
||||
|
||||
**An import records the trip, and rewrites nothing**
|
||||
([#131](https://github.com/heavy-duty/box/issues/131)). Everything `incus
|
||||
import` restores is the _artifact's_ truth, so an imported box keeps its mint
|
||||
stamp verbatim — the mint time, the box version, the image and the origin
|
||||
belong to the originating host and survive the trip on purpose. What `box
|
||||
import` adds is the one fact the artifact cannot carry: that the trip happened.
|
||||
|
||||
```
|
||||
MINTED 2026-06-01T10:00:00Z by box 0.7.0
|
||||
IMPORTED 2026-07-20T09:14:03Z by box 0.8.1 (the mint above predates it)
|
||||
ORIGIN clone of work/authed
|
||||
```
|
||||
|
||||
It is **not** `origin=import`, and the difference is the whole point. `origin`
|
||||
answers how the instance came into _being_ — mint or clone — and overwriting it
|
||||
would destroy that: the clone above would come back claiming to be an import,
|
||||
with nothing left saying it was ever a clone and an `origin.from` naming a
|
||||
lineage no key explains. The import is a _third_ fact, orthogonal to the first
|
||||
two, so it takes its own keys and leaves every other one alone.
|
||||
|
||||
The `IMPORTED` line sits directly under `MINTED` because that adjacency is what
|
||||
stops the mint time being misread as this host's. Note what it does not claim:
|
||||
box has no record of _which_ host minted the box, and a box can be exported and
|
||||
re-imported onto the same host (that is the upgrade flow above), so the line
|
||||
states only the ordering — the one thing box actually knows.
|
||||
|
||||
**A box can make the trip more than once**, and both ends are kept: the first
|
||||
import is pinned forever, the latest is refreshed on every arrival, and a count
|
||||
says how many. Last-wins alone would erase the evidence of the earlier trips,
|
||||
which is the same mistake `origin=import` makes one level up. (The shape
|
||||
follows [heavy-duty/rig#61](https://github.com/heavy-duty/rig/issues/61)'s
|
||||
manifest: a birth pair plus a latest pair.)
|
||||
|
||||
**Boxes minted before this stamp existed keep working**, under this verb and
|
||||
every other — they render as a box with blanks and say `MINTED (not recorded)`
|
||||
rather than erroring. `user.box.schema` names the stamp's _shape_ (an integer,
|
||||
not the box version) so a box minted by a later release reads back on an older
|
||||
box as "here is what I understand, and there is more I don't".
|
||||
|
||||
## Boxes are just Incus instances
|
||||
|
||||
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.0
|
||||
0.9.1-dev
|
||||
|
|
|
|||
616
bin/box
616
bin/box
|
|
@ -9,10 +9,27 @@ root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
|||
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
|
||||
cpu=""; memory=""; disk=""; instance_only=0
|
||||
inst="" # the resolved Incus instance, set by the 'box' precondition
|
||||
# Labels snapshot_mark actually CREATED this run, space-separated. Every path
|
||||
# through snapshot_mark returns 0 on purpose — the never-fatal contract — so
|
||||
# the exit status cannot tell a mark that was taken from one that was skipped
|
||||
# (dir pool, knob=0) or refused (incus said no). Anything that offers the
|
||||
# operator a mark must ask this, not assume the call happened.
|
||||
marks=""
|
||||
|
||||
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
||||
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
|
||||
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
|
||||
# The tree's own version, read in ONE place. 'box --version' says it out loud;
|
||||
# the mint stamp (#103) writes it onto every instance box creates, so a box can
|
||||
# still name the release that made it long after that release is history.
|
||||
box_version() { cat "$root/VERSION" 2>/dev/null || echo unknown; }
|
||||
version() { echo "box $(box_version) ($root)"; }
|
||||
|
||||
# The SHAPE of the mint stamp, not the box version — an integer that changes
|
||||
# only when a key is removed or repurposed, never when one is added (a reader
|
||||
# that does not know a key simply does not print it). Absent means pre-stamp:
|
||||
# every box minted before #103 has no schema key at all, and must keep working
|
||||
# under every verb, which is the same promise 'user.claudebox' carries.
|
||||
BOX_STAMP_SCHEMA=1
|
||||
|
||||
# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
|
||||
# what the kernel will present when incus opens the socket), never from the
|
||||
|
|
@ -73,7 +90,7 @@ CMDS=(
|
|||
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
|
||||
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
||||
"list^[--json]^^List your boxes^fn:cmd_list^"
|
||||
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
|
||||
"info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
|
||||
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
|
||||
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
||||
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
||||
|
|
@ -185,7 +202,7 @@ OPTIONS
|
|||
|
||||
EXAMPLES
|
||||
# mint a claude box and log in inside it — the tool never handles your token
|
||||
box new --name work --template claude
|
||||
box new --name work --template claude-box
|
||||
box shell work # then: run 'claude', then /login
|
||||
|
||||
# log in once, reuse forever: checkpoint the authed box, clone from it
|
||||
|
|
@ -242,13 +259,13 @@ help_cmd() {
|
|||
case "$1" in
|
||||
new) cat <<'EOF'
|
||||
Mint a box. Without --from, launches a fresh box from a template (default:
|
||||
blank — bare Debian 13, nobody home; --template claude gets Claude Code
|
||||
blank — bare Debian 13, nobody home; --template claude-box gets Claude Code
|
||||
installed, creds-free, ~10 min cold). With --from, clones an existing box or
|
||||
one of its snapshots — login state, git creds and clones carry over,
|
||||
isolation is preserved, and the clone knows its template's user without
|
||||
being told.
|
||||
|
||||
A tenant template (claude, codex, grok, staging) is a THIN seed — the user,
|
||||
A tenant template (claude-box, codex-box, grok-box, kimi-box, staging-box) is a THIN seed — the user,
|
||||
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
|
||||
role inside it ('rig bootstrap <role>', rig#31): that role installs the
|
||||
agent CLI / server posture and the agent-context file. rig is preinstalled
|
||||
|
|
@ -285,9 +302,40 @@ there is no flag for a network or a security key, on purpose.
|
|||
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
|
||||
a launch that overruns it fails loudly instead of hanging forever (#93).
|
||||
|
||||
Every fresh mint marks a snapshot called 'pristine' (#104) at the one moment
|
||||
it is true: after cloud-init, before rig converges anything. That is the
|
||||
guest as pristine Debian plus box's thin seed, and it is what
|
||||
|
||||
box restore <box> pristine
|
||||
|
||||
rolls back to — undoing the tenant role and everything done since, without
|
||||
paying a re-mint. It is an UNDO, not a backup: 'box rm' deletes a box and
|
||||
every snapshot it has, so 'box export' remains the only state that outlives
|
||||
the box. A --from clone takes no 'pristine' of its own — it has no pristine
|
||||
moment; it inherits the source's snapshots (copying a box) or starts with
|
||||
none (copying a snapshot). On a 'dir'-backend host, where a snapshot is a
|
||||
full copy rather than a CoW mark, the mint skips it and says so.
|
||||
BOX_SNAPSHOT_PRISTINE=0 skips it anywhere.
|
||||
|
||||
A mint whose template names a bootstrap role marks a second snapshot,
|
||||
'bootstrapped' (#130), once the rig hook has run and box has watched it
|
||||
SUCCEED — the box converged and not yet touched. It is the cheaper undo:
|
||||
'pristine' throws the tenant role away, 'bootstrapped' keeps it and throws
|
||||
away only what you did afterwards. Same rules: never fatal, skipped on a
|
||||
'dir' pool, and BOX_SNAPSHOT_BOOTSTRAPPED=0 skips it anywhere.
|
||||
|
||||
It is deliberately NOT unconditional the way 'pristine' is. A blank box
|
||||
runs no hook, so there is no convergence to mark and it gets none — the
|
||||
label marks an event, not a mint. And if the hook FAILS, box tells you to
|
||||
re-run it by hand and takes no mark afterwards: a by-hand run happens in a
|
||||
shell box does not watch, so box will not label a convergence it never saw.
|
||||
It names 'box snapshot <box> bootstrapped' at that moment instead. So read
|
||||
the label one way only: its PRESENCE means the hook converged untouched;
|
||||
its ABSENCE means nothing at all.
|
||||
|
||||
box new --name scratch # blank, the default
|
||||
box new --name work --template claude
|
||||
box new --name lean --template claude --cpu 2 --memory 3GiB
|
||||
box new --name work --template claude-box
|
||||
box new --name lean --template claude-box --cpu 2 --memory 3GiB
|
||||
box new --name feature --from work/authed
|
||||
EOF
|
||||
;;
|
||||
|
|
@ -366,6 +414,19 @@ label defaults to manual-<epoch>; 'box info <box>' shows the labels you
|
|||
have.
|
||||
|
||||
box snapshot work authed
|
||||
|
||||
Two labels are taken for you (see 'box help restore'): every fresh mint
|
||||
marks 'pristine' after cloud-init and before rig's tenant role runs (#104),
|
||||
and a mint with a bootstrap role marks 'bootstrapped' after box has watched
|
||||
that role converge (#130). 'bootstrapped' is skipped where there was no hook
|
||||
to watch — a blank box, or a hook box told you to re-run by hand — so its
|
||||
absence proves nothing. Take it yourself in that case:
|
||||
|
||||
box snapshot work bootstrapped
|
||||
|
||||
Snapshots do not outlive their box: 'box rm' deletes a box and every
|
||||
snapshot it has. They are an undo, not a backup — 'box export' is the
|
||||
durable path.
|
||||
EOF
|
||||
;;
|
||||
restore) cat <<'EOF'
|
||||
|
|
@ -374,6 +435,41 @@ that snapshot is lost. 'box info <box>' lists the labels.
|
|||
|
||||
box restore work authed
|
||||
box restore work authed --force
|
||||
box restore work pristine
|
||||
|
||||
'pristine' is the one label box takes for you (#104): every fresh mint marks
|
||||
it after cloud-init and before rig converges the tenant role, so it is the
|
||||
guest as pristine Debian plus box's thin seed. Restoring it is the answer to
|
||||
"I broke this box" for every tenant role — everything 'rig bootstrap
|
||||
<tenant>' does is box-local and file-shaped, so a filesystem rollback undoes
|
||||
all of it, without a ~10-minute re-mint.
|
||||
|
||||
What it does NOT undo is anything that left the box: a tailnet join, a
|
||||
GitHub runner registration, a pushed commit. Those are off-box records a
|
||||
filesystem rollback cannot reach (heavy-duty/rig#62). And 'pristine' is not
|
||||
a backup — it dies with the box on 'box rm'; 'box export' is what survives.
|
||||
|
||||
A box minted with --from has no 'pristine' of its own unless it inherited
|
||||
one from its source, because a clone has no pristine moment to capture.
|
||||
|
||||
'bootstrapped' is the other label box takes for you (#130), and it is the
|
||||
one you probably want more often: it is the box AFTER the tenant role
|
||||
converged and before you touched it.
|
||||
|
||||
box restore work bootstrapped # keep the role, undo what you did to it
|
||||
box restore work pristine # throw the role away too
|
||||
|
||||
The same two caveats apply to it exactly as written above — it dies with the
|
||||
box on 'box rm' ('box export' is what survives), and it cannot undo anything
|
||||
that left the box, a tailnet join or a runner registration among them
|
||||
(heavy-duty/rig#62).
|
||||
|
||||
Its absence proves NOTHING. box takes it only after a rig hook it ran and
|
||||
watched succeed, so a blank box has none, a 'dir'-pool host has none, and a
|
||||
box whose hook failed and was re-run by hand through 'box shell' has none
|
||||
either — box will not label a convergence it did not watch. 'box info <box>'
|
||||
tells you which labels a box actually has; take the mark yourself any time
|
||||
with 'box snapshot <box> bootstrapped'.
|
||||
|
||||
Destructive, so it asks first — naming the snapshot it is rolling back to,
|
||||
because the whole risk is picking the wrong label. --force (-f) skips the
|
||||
|
|
@ -427,6 +523,17 @@ snapshots). What box then re-stamps is THIS host's truth:
|
|||
differs, the same move migrate-host makes re-homing a legacy box
|
||||
· a fresh machine identity (reset_identity, exactly like a clone), so its
|
||||
DHCP lease can never collide with the box it was exported from
|
||||
· the import EVENT — when the box landed here, and by which box version
|
||||
|
||||
The mint stamp is NOT rewritten. The artifact's mint time, box version, image,
|
||||
template and origin (mint or clone) are the originating host's truth and
|
||||
survive the trip intact — 'box info' shows them unchanged, with an IMPORTED
|
||||
line under them saying the mint predates the arrival. In particular the import
|
||||
does not overwrite 'origin': a clone that was exported and imported still
|
||||
reads as a clone, and still names the box it was cloned from.
|
||||
|
||||
A box that makes the trip more than once keeps both ends: the first import is
|
||||
pinned forever, the latest is refreshed each time, and a count says how many.
|
||||
|
||||
Auth state rides along by design — the artifact carries the box's whole disk,
|
||||
logins included. That is the point (log in once, keep the file), and the same
|
||||
|
|
@ -473,7 +580,7 @@ The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
|
|||
a VM's forwarder connects to the box over the network. Inside an isolated box
|
||||
that is safe: nothing but this proxy can reach the port.
|
||||
|
||||
box new --name web --template claude
|
||||
box new --name web --template claude-box
|
||||
box shell web # inside: run a dev server on 0.0.0.0:3000
|
||||
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
|
||||
EOF
|
||||
|
|
@ -1002,8 +1109,16 @@ load_template() {
|
|||
# environment must die on the host, never execute in the guest. bash's =~
|
||||
# anchors to the whole string — a multi-line value cannot sneak one clean
|
||||
# line past it the way a line-oriented grep would.
|
||||
# The rig pin, resolved from the mint environment, in ONE place: render_userdata
|
||||
# substitutes it into the seed, and the mint stamp (#103) records it onto the
|
||||
# instance. Two spellings of the same default would eventually disagree, and a
|
||||
# stamp that disagrees with the seed is worse than no stamp at all.
|
||||
rig_repo() { printf '%s\n' "${RIG_REPO:-heavy-duty/rig}"; }
|
||||
rig_ref() { printf '%s\n' "${RIG_REF:-main}"; }
|
||||
|
||||
render_userdata() {
|
||||
local f="$1" repo="${RIG_REPO:-heavy-duty/rig}" ref="${RIG_REF:-main}" data
|
||||
local f="$1" repo data ref
|
||||
repo="$(rig_repo)"; ref="$(rig_ref)"
|
||||
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|
||||
|| die "RIG_REPO must look like owner/repo: $repo"
|
||||
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
|
||||
|
|
@ -1026,6 +1141,144 @@ cmd_templates() {
|
|||
echo "mint one: box new --name <box> --template <template>"
|
||||
}
|
||||
|
||||
# When this instance came into being. A timestamp in a CONVERGENT file would be
|
||||
# churn — the same run writing a different byte every time — but a mint is not
|
||||
# convergent: it happens exactly once, to exactly one instance, and is never
|
||||
# re-run against it. UTC and ISO 8601 so it sorts as a string and means the same
|
||||
# thing on every host that reads it back.
|
||||
mint_time() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
# Which storage driver is under this box? The placement contract answers it:
|
||||
# every box launches with --profile box-net, and that profile hardcodes the
|
||||
# root disk's pool (profiles/box-net.yaml), so the pool is not a guess — it is
|
||||
# read back from the profile that actually placed the instance, and only falls
|
||||
# back to the name setup-host.sh creates. Two probes for the driver because
|
||||
# the tiers see storage differently: 'incus storage show' is the admin read
|
||||
# (the same line setup-host.sh:297 prints), 'incus storage list --format csv'
|
||||
# is the one a restricted user's incus-user proxy is likelier to answer.
|
||||
# Prints the driver, or nothing when neither probe answers.
|
||||
storage_driver() {
|
||||
local pool driver
|
||||
pool="$(incus profile device get box-net root pool 2>/dev/null || true)"
|
||||
[ -n "$pool" ] || pool=default
|
||||
# '|| true' on all three probes, not just the first. A refusal here is an
|
||||
# ANSWER ("this tier cannot read the pool"), never an error — this function
|
||||
# must never be the thing that kills a mint. Today the two pipelines are
|
||||
# safe only by accident: command substitution strips errexit, so a failing
|
||||
# 'incus storage show' under pipefail falls through to the fallback instead
|
||||
# of aborting. Add 'shopt -s inherit_errexit' to this file — exactly the
|
||||
# robustness tweak #107 describes sailing through review — and that accident
|
||||
# reverses into a fatal abort mid-mint, inside the function whose contract
|
||||
# is NEVER fatal. Make the three probes read alike and depend on nothing.
|
||||
driver="$(incus storage show "$pool" 2>/dev/null | awk '/^driver:/ {print $2; exit}' || true)"
|
||||
[ -n "$driver" ] || driver="$(incus storage list --format csv 2>/dev/null \
|
||||
| awk -F, -v p="$pool" '$1 == p {print $2; exit}' || true)"
|
||||
printf '%s' "$driver"
|
||||
}
|
||||
|
||||
# The shared policy behind every mark box takes for you (#104, #130):
|
||||
# read the opt-out, read the storage driver, refuse to double the disk cost on
|
||||
# a pool with no copy-on-write, and NEVER fail a mint over a checkpoint.
|
||||
#
|
||||
# It lives once on purpose. What generalises here is the MECHANISM, and the
|
||||
# never-fatal contract inside it is exactly the property that must not be got
|
||||
# subtly different in two places — one function, one place to review it, one
|
||||
# place a future third mark inherits it from. What does NOT generalise is the
|
||||
# PROSE: 'pristine' and 'bootstrapped' name different moments and are worth
|
||||
# different sentences, so each wrapper below owns its own narration and passes
|
||||
# it in. Two thin wrappers over one policy is the honest split; a single
|
||||
# stringly-parameterised function taking five sentences would not be.
|
||||
#
|
||||
# The opt-out variable name is DERIVED from the label (BOX_SNAPSHOT_PRISTINE,
|
||||
# BOX_SNAPSHOT_BOOTSTRAPPED) so the message can never drift from the knob the
|
||||
# operator actually has to set.
|
||||
#
|
||||
# snapshot_mark <instance> <name> <label> <enabled> <what-this-state-is>
|
||||
snapshot_mark() {
|
||||
local instance="$1" name="$2" label="$3" enabled="$4" what="$5" driver knob
|
||||
knob="BOX_SNAPSHOT_$(printf '%s' "$label" | tr '[:lower:]' '[:upper:]')"
|
||||
if [ "$enabled" = 0 ]; then
|
||||
echo "box: skipping the '$label' snapshot ($knob=0)."
|
||||
return 0
|
||||
fi
|
||||
driver="$(storage_driver)"
|
||||
# On btrfs (the deliberate default, host/setup-host.sh:261-298) this costs
|
||||
# kilobytes until the guest diverges. On the documented 'dir' fallback
|
||||
# (:294) there is no CoW at all: the snapshot is a full copy of the root,
|
||||
# several GB and minutes, on EVERY mint. Skip it there — but loudly, and
|
||||
# naming the by-hand command, because a silent skip teaches an operator to
|
||||
# expect a mark that will not be there. With two marks the objection is
|
||||
# twice the size, so the skip covers both: a 'dir' host is not asked to pay
|
||||
# for one full root copy per mint, let alone two.
|
||||
if [ "$driver" = dir ]; then
|
||||
echo "box: NOT taking the '$label' snapshot — this host's storage pool is the 'dir'" >&2
|
||||
echo "box: driver, which has no copy-on-write, so the mark would be a full multi-GB copy" >&2
|
||||
echo "box: of the root disk on every mint rather than the near-free btrfs one." >&2
|
||||
echo "box: take it by hand if you want it here: box snapshot $name $label" >&2
|
||||
echo "box: or fix the host (btrfs is what 'box setup-host' installs by default)." >&2
|
||||
return 0
|
||||
fi
|
||||
# Neither probe answered — an unusual host, or a tier that cannot read the
|
||||
# pool. Proceed, and say what was assumed. The two mistakes are not
|
||||
# symmetric: a snapshot taken on a 'dir' host wastes disk the operator can
|
||||
# see in 'box info' and delete, while a snapshot NOT taken is the moment
|
||||
# gone for good, which is the whole thing this exists to prevent.
|
||||
[ -n "$driver" ] || echo "box: could not read the storage driver — taking '$label' anyway," \
|
||||
"assuming copy-on-write ($knob=0 to skip)." >&2
|
||||
echo "box: marking '$label' — $what"
|
||||
if ! incus snapshot create "$instance" "$label" </dev/null; then
|
||||
# An undo is not the mint's product: a mint that worked must not be
|
||||
# failed by a checkpoint that didn't.
|
||||
echo "box: WARNING — could not take the '$label' snapshot (incus said why above)." >&2
|
||||
echo "box: the box is fine; it just has no $label mark. The mint continues." >&2
|
||||
return 0
|
||||
fi
|
||||
marks="$marks $label"
|
||||
echo "box: roll back to it any time with: box restore $name $label"
|
||||
}
|
||||
|
||||
# Did THIS run actually create <label>? The only honest source for a message
|
||||
# that offers a rollback: see 'marks' above for why the return status cannot
|
||||
# answer it.
|
||||
mark_taken() { case " $marks " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
||||
|
||||
# Take the 'pristine' snapshot, or say loudly why not. Default ON: the value
|
||||
# of this mark only exists if it is already there on the bad day, and nobody
|
||||
# takes it by hand at the one moment it is true. The escape hatch is the
|
||||
# BOX_LAUNCH_TIMEOUT shape — an environment knob, not another flag on 'new'.
|
||||
snapshot_pristine() {
|
||||
snapshot_mark "$1" "$2" pristine "${BOX_SNAPSHOT_PRISTINE:-1}" \
|
||||
"the guest after cloud-init, before rig converges anything."
|
||||
}
|
||||
|
||||
# Take the 'bootstrapped' snapshot (#130), after the rig hook box RAN and
|
||||
# WATCHED SUCCEED. Same default, same opt-out shape, same never-fatal
|
||||
# contract as 'pristine' — it is the same policy, one function up.
|
||||
#
|
||||
# The label is deliberately CONDITIONAL where 'pristine' is unconditional, and
|
||||
# the asymmetry is the point rather than an oversight. 'pristine' marks a
|
||||
# MOMENT that every fresh mint has, so it is taken on every fresh mint.
|
||||
# 'bootstrapped' marks an EVENT — "a tenant role converged, and nothing has
|
||||
# happened since" — and a blank box has no such event. Minting the label there
|
||||
# anyway would either duplicate 'pristine' byte for byte (nothing ran between
|
||||
# them, so it is the same disk state at twice the cost, which is precisely the
|
||||
# doubling the 'dir' skip above exists to refuse) or, worse, assert a
|
||||
# convergence that never happened. So a hookless box gets nothing, and the
|
||||
# label's presence is itself information: this box had a role and box saw it
|
||||
# converge.
|
||||
#
|
||||
# Read only in that direction. PRESENCE means the mint-time hook succeeded and
|
||||
# the box has not been touched since; ABSENCE means nothing at all — a blank
|
||||
# template, a 'dir' pool, an opt-out, or a role converged by hand afterwards
|
||||
# (see cmd_new's hook-failure path, which hands the operator the command
|
||||
# because box cannot honestly take a mark for a run it did not watch). Nothing
|
||||
# in box may treat a missing 'bootstrapped' as evidence a box is unconverged.
|
||||
snapshot_bootstrapped() {
|
||||
snapshot_mark "$1" "$2" bootstrapped \
|
||||
"${BOX_SNAPSHOT_BOOTSTRAPPED:-1}" \
|
||||
"the tenant role converged, and nothing has touched the box since."
|
||||
}
|
||||
|
||||
cmd_new() {
|
||||
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
||||
require_stack
|
||||
|
|
@ -1037,10 +1290,78 @@ cmd_new() {
|
|||
case "$from" in */*) snap="${from#*/}" ;; esac
|
||||
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
||||
incus copy "$srcref" "$instance"
|
||||
# 'incus copy' carries every user.* key forward (audit B2) — which is what
|
||||
# makes a clone know its template and user for free, and is also why the
|
||||
# mint stamp (#103) cannot simply ride along. A clone that inherited the
|
||||
# stamp verbatim would claim to have been minted at the source's mint time,
|
||||
# by the box version that minted the SOURCE, in a mint that never touched
|
||||
# this instance. That is not a stale field, it is a false one.
|
||||
#
|
||||
# So re-stamp exactly the keys that describe THIS instance's coming into
|
||||
# being, and leave the rest alone:
|
||||
# · version / created / schema — the clone was made HERE, NOW, by THIS box
|
||||
# · origin=clone, origin.from=<srcref> — how, and from what
|
||||
# Deliberately NOT re-stamped, because they are lineage and stay true: the
|
||||
# clone's disk really did come from that image, that template, that user and
|
||||
# that rig role — reading them off the source is the whole point of a clone.
|
||||
# ('incus copy' preserves the instance type too, so mode stays true as well.)
|
||||
#
|
||||
# 'mode.asked' is the one key that sits in NEITHER column, and so it is
|
||||
# CLEARED rather than re-stamped or inherited. It is a mint-event fact —
|
||||
# only the mint knew whether a container was asked for or fallen back into
|
||||
# for want of /dev/kvm — and the asker was the SOURCE's operator. A clone
|
||||
# refuses --vm/--container outright (nobody was asked anything here), so an
|
||||
# inherited 'asked' makes 'box info' print a demand that was never made of
|
||||
# this instance. There is no true value to re-stamp it with: the honest
|
||||
# answer is absence, and absence is already how the whole block renders
|
||||
# what it does not know — the MODE line simply does not print, while TYPE
|
||||
# above still says VM or CT off the preserved instance type.
|
||||
#
|
||||
# origin.from records ONE hop. A clone of a clone names its parent and
|
||||
# forgets its grandparent: the alternative is an unbounded chain in a config
|
||||
# value, and the parent is the box an operator can actually go look at.
|
||||
incus config set "$instance" \
|
||||
user.box.schema="$BOX_STAMP_SCHEMA" \
|
||||
user.box.version="$(box_version)" \
|
||||
user.box.created="$(mint_time)" \
|
||||
user.box.origin=clone \
|
||||
user.box.origin.from="$srcref"
|
||||
# Cleared, not set-to-empty: an empty value is still a key, and a reader
|
||||
# that greps the config would find it. Tolerated failure because the source
|
||||
# may predate the stamp and never have carried the key at all — a clone
|
||||
# must not die over a key that was already absent.
|
||||
incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true
|
||||
incus start "$instance"
|
||||
wait_agent "$instance"
|
||||
reset_identity "$instance"
|
||||
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
||||
# A clone gets NO 'pristine' of its own (#104). It skips cloud-init and
|
||||
# the rig hook entirely, so the pristine MOMENT never happens here —
|
||||
# anything box marked at clone time would be "whatever the source was",
|
||||
# converged and worked-in, wearing a label that promises pristine Debian.
|
||||
# That is worse than no mark at all, so the mint-path snapshot lives
|
||||
# strictly in the fresh-mint branch below and never runs here.
|
||||
#
|
||||
# What a clone gets instead is INHERITANCE, and it is already correct:
|
||||
# 'incus copy <box>' carries the source's snapshots, so a source that has
|
||||
# a 'pristine' hands the clone a real one — the true pristine state of
|
||||
# the disk this clone descends from. Copying a SNAPSHOT ('--from box/snap')
|
||||
# carries no snapshot list, so that clone starts with none. Both are
|
||||
# honest; neither is invented. Say which one this is rather than leaving
|
||||
# the operator to run 'box info' to find out.
|
||||
# Capture first, THEN read (#124's class). Piping a multi-line incus
|
||||
# writer straight into an early-exit reader lets grep close the pipe on
|
||||
# the first match, SIGPIPE incus, and hand pipefail a 141 — which here
|
||||
# would read as "no pristine" and narrate the WRONG inheritance shape on a
|
||||
# clone that actually has one. Un-racy today at this writer's size, but
|
||||
# the assignment costs nothing and does not depend on that staying true.
|
||||
local snaps; snaps="$(incus snapshot list "$instance" --format csv 2>/dev/null || true)"
|
||||
if printf '%s\n' "$snaps" | grep -q '^pristine,'; then
|
||||
echo "box: it inherited the source's snapshots, 'pristine' among them (box restore $name pristine)."
|
||||
else
|
||||
echo "box: no 'pristine' mark here — a clone has no pristine moment to capture, and box"
|
||||
echo "box: will not label the source's state as one. 'box info $name' lists what it did inherit."
|
||||
fi
|
||||
else
|
||||
local t="${template:-blank}" m extra=()
|
||||
load_template "$t"
|
||||
|
|
@ -1077,6 +1398,47 @@ cmd_new() {
|
|||
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
||||
# knows what it is without ever consulting the template again.
|
||||
#
|
||||
# And the rest of what this line knows and used to drop on the floor (#103).
|
||||
# There is no host-side per-box store — the Incus instance config IS the
|
||||
# database — so a fact not written here is simply gone the moment the mint
|
||||
# returns. The stamp describes the MINT, not the outcome: it lands before
|
||||
# cloud-init and before rig, and nothing later edits it.
|
||||
#
|
||||
# schema the stamp's shape, so a future reader knows what it is holding
|
||||
# version the box that minted it — 'box --version' is a fact about the
|
||||
# binary in front of you, never about the box you are looking at
|
||||
# image the alias asked for. It is an UNPINNED alias on a moving
|
||||
# remote: two boxes minted a month apart from "the same
|
||||
# template" are not the same box, and the alias alone cannot
|
||||
# say so. What it resolved to is pinned after the launch below.
|
||||
# mode what it minted as, and what was ASKED — a container that fell
|
||||
# back for want of /dev/kvm and one the operator asked for read
|
||||
# identically afterwards, and only the mint knew which
|
||||
# role the rig role box auto-runs at the hook below
|
||||
# rig.* WHICH rig converged it, stamped only for a seed that actually
|
||||
# installs rig from the pin ('blank' seeds none, so it gets none)
|
||||
# created when. See mint_time() for why a timestamp belongs here.
|
||||
# origin mint. A clone re-stamps it (see the --from branch above).
|
||||
#
|
||||
# NOT stamped, on purpose: cpu/memory (limits.* already hold them, and a
|
||||
# duplicate drifts the first time someone edits the limit by hand); disk
|
||||
# (a VM's is the root device size, and a container's does not exist — its
|
||||
# root rides the pool, so a stamped value would be fiction); and tier,
|
||||
# which box_tier() derives from whoever is ASKING, not from the box.
|
||||
local stamp=(
|
||||
--config user.box.schema="$BOX_STAMP_SCHEMA"
|
||||
--config user.box.version="$(box_version)"
|
||||
--config user.box.image="$T_IMAGE"
|
||||
--config user.box.mode="$m"
|
||||
--config user.box.mode.asked="$mode"
|
||||
--config user.box.created="$(mint_time)"
|
||||
--config user.box.origin=mint
|
||||
)
|
||||
[ -z "$T_BOOTSTRAP_ROLE" ] || stamp+=(--config user.box.role="$T_BOOTSTRAP_ROLE")
|
||||
if grep -q '@RIG_REPO@' "$root/templates/$t/user-data.yaml" 2>/dev/null; then
|
||||
stamp+=(--config user.box.rig.repo="$(rig_repo)" --config user.box.rig.ref="$(rig_ref)")
|
||||
fi
|
||||
#
|
||||
# The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
|
||||
# release drill the child 'incus launch' wedged before the create was
|
||||
# even accepted — 'incus operation list' empty, the instance never
|
||||
|
|
@ -1095,6 +1457,7 @@ cmd_new() {
|
|||
--config user.box=1 \
|
||||
--config user.box.template="$t" \
|
||||
--config user.box.user="$T_USER" \
|
||||
"${stamp[@]}" \
|
||||
--config limits.cpu="$T_CPU" \
|
||||
--config limits.memory="$T_MEMORY" \
|
||||
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
|
||||
|
|
@ -1132,6 +1495,20 @@ cmd_new() {
|
|||
# Not a wedge: incus refused and said why on stderr, right above.
|
||||
die "incus launch failed (exit $rc)"
|
||||
fi
|
||||
# The one field the launch line could not know: 'user.box.image' above is
|
||||
# the ALIAS, and an alias on a moving remote is not a reproducible fact.
|
||||
# Incus resolves it during the launch and records what it landed on in
|
||||
# volatile.base_image — read it back and pin it into the stamp, so an
|
||||
# incident six months from now can ask "was this box built on the image
|
||||
# that broke?" and get an answer instead of a template name.
|
||||
#
|
||||
# Best-effort BY CONSTRUCTION, and that is the whole design of this line:
|
||||
# it runs only after a launch that already succeeded, and a box that exists
|
||||
# and boots must never be failed over a provenance field. Every failure
|
||||
# here is silent and leaves the alias standing as the honest partial answer
|
||||
# — a stamp with no fingerprint, which is exactly how cmd_info renders it.
|
||||
local fp; fp="$(incus config get "$instance" volatile.base_image 2>/dev/null || true)"
|
||||
[ -z "$fp" ] || incus config set "$instance" user.box.image.fingerprint="$fp" >/dev/null 2>&1 || true
|
||||
wait_agent "$instance"
|
||||
echo "box: waiting for phase-1 (cloud-init)..."
|
||||
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
|
||||
|
|
@ -1164,6 +1541,19 @@ cmd_new() {
|
|||
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
|
||||
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
||||
fi
|
||||
# The pristine mark (#104, child of rig#62). RIGHT HERE — cloud-init has
|
||||
# finished and rig has not started — the guest is pristine Debian plus
|
||||
# box's seed, and nothing else. That state exists for a few seconds on
|
||||
# every mint and has never been captured; rig#62's "back to pristine
|
||||
# Debian" is exactly this state, so take it while it is true.
|
||||
#
|
||||
# Unconditional within a fresh mint, not gated on T_BOOTSTRAP_ROLE: a
|
||||
# blank box has no rig hook but it does have the same pristine moment,
|
||||
# and 'box restore <box> pristine' should mean one thing on every box.
|
||||
#
|
||||
# NEVER fatal. This is an undo, not the mint's product — a mint that
|
||||
# worked must not be failed by a checkpoint that didn't.
|
||||
snapshot_pristine "$instance" "$name"
|
||||
# The tenant convergence (#81): the seed above is thin — the user, tmux,
|
||||
# rig — and what the box BECOMES is rig's job. A template that names a
|
||||
# bootstrap role gets it auto-run here, as root inside the guest, because
|
||||
|
|
@ -1179,20 +1569,57 @@ cmd_new() {
|
|||
echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
|
||||
echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
|
||||
echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
|
||||
# The mark box cannot take for you (#130), said HERE because here is
|
||||
# the only moment the operator is looking. 'bootstrapped' means "box
|
||||
# ran the hook and watched it succeed" — a by-hand re-run happens in a
|
||||
# shell box does not watch, so box cannot know when (or whether) it
|
||||
# converged, and a mark taken on a guess would be a label asserting a
|
||||
# convergence box never saw. That is the same lie #104 refused on the
|
||||
# clone path. So: hand over the command instead of inventing the fact.
|
||||
echo "box: no 'bootstrapped' mark was taken — box only marks a hook it watched succeed," >&2
|
||||
echo " and a by-hand re-run happens in a shell it does not watch. Take it yourself once" >&2
|
||||
echo " the role converges, at the moment it is true:" >&2
|
||||
echo " box snapshot $name bootstrapped" >&2
|
||||
# Only offer the rollback that EXISTS. Three mints reach this line
|
||||
# with no pristine mark, all by this stack's own design: a 'dir' pool
|
||||
# (skipped), BOX_SNAPSHOT_PRISTINE=0, and a refused create (warned,
|
||||
# mint continued). On a 'dir' host that is EVERY hook failure, and an
|
||||
# operator standing at the one moment this message exists for would
|
||||
# copy-paste a restore that errors. Offering a mark the disk does not
|
||||
# have is the same lie this PR refuses one message earlier, when it
|
||||
# declines to invent a 'bootstrapped' it never watched.
|
||||
if mark_taken pristine; then
|
||||
echo "box: 'box restore $name pristine' is still there if you would rather start the role over." >&2
|
||||
fi
|
||||
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
|
||||
fi
|
||||
# The hook RAN and box WATCHED it succeed — so right here the box is
|
||||
# converged and untouched, the state an operator re-mints ten minutes
|
||||
# for when they break a tenant role but not the OS under it. Never
|
||||
# fatal, same as 'pristine': a mint that worked is not failed by a
|
||||
# checkpoint that didn't.
|
||||
snapshot_bootstrapped "$instance" "$name"
|
||||
fi
|
||||
fi
|
||||
# The login hint belongs to the claude template — read the EFFECTIVE
|
||||
# template off the instance, so a clone of a claude box gets it too and a
|
||||
# blank box is not told to run a binary it doesn't have.
|
||||
# The login hint belongs to the claude-box template — read the EFFECTIVE
|
||||
# template off the instance, so a clone of a claude-box box gets it too and
|
||||
# a blank box is not told to run a binary it doesn't have.
|
||||
#
|
||||
# Both spellings match, and that is not an alias for the ROLE. The role
|
||||
# names are a hard cut (rig#76) — 'rig bootstrap claude' is gone, so the
|
||||
# seeds ask for 'claude-box' and nothing here softens that. What these arms
|
||||
# read is user.box.template, a stamp left on an INSTANCE at its own mint
|
||||
# time: every box minted before the rename carries the bare name forever,
|
||||
# and a clone carries it forward. Refusing the old spelling here would not
|
||||
# cut anything over, it would only drop the hint on boxes that predate the
|
||||
# rename — the same reason user.claudebox is honored everywhere else.
|
||||
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
|
||||
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
|
||||
if [ "$eff" = claude ]; then
|
||||
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
|
||||
if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
|
||||
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
||||
elif [ "$eff" = staging ]; then
|
||||
elif [ "$eff" = staging-box ] || [ "$eff" = staging ]; then
|
||||
echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
|
||||
echo " box shell $name # then: sudo rig bootstrap workload --hostname $name"
|
||||
echo " box shell $name # then: sudo rig bootstrap workload-server --hostname $name"
|
||||
else
|
||||
echo "box: ready — 'box shell $name'."
|
||||
fi
|
||||
|
|
@ -1275,6 +1702,85 @@ cmd_list() {
|
|||
fi
|
||||
}
|
||||
|
||||
# One instance config key. 'incus config get' on an UNSET key prints empty and
|
||||
# exits 0 (audit B4) — so the '|| true' here covers the daemon refusing, not the
|
||||
# key being absent, and every caller below reads absence as an empty string.
|
||||
box_cfg() { incus config get "$1" "$2" 2>/dev/null || true; }
|
||||
|
||||
# The mint stamp (#103), read back for 'box info'. Every key is optional and so
|
||||
# is the whole block: a box minted before the stamp existed carries none of it
|
||||
# and must render as a box with blanks — never as an error, and never as a box
|
||||
# wearing a mint time it does not have. Legacy boxes are not a transitional
|
||||
# case: a box outlives the release that minted it, which is exactly what the
|
||||
# legacy 'user.claudebox' tag already says out loud at resolve_box.
|
||||
box_provenance() {
|
||||
local i="$1" schema created ver img fp m asked tpl u role rrepo rref origin from
|
||||
local imp impby impl implby impn
|
||||
schema="$(box_cfg "$i" user.box.schema)"
|
||||
created="$(box_cfg "$i" user.box.created)"; ver="$(box_cfg "$i" user.box.version)"
|
||||
img="$(box_cfg "$i" user.box.image)"; fp="$(box_cfg "$i" user.box.image.fingerprint)"
|
||||
m="$(box_cfg "$i" user.box.mode)"; asked="$(box_cfg "$i" user.box.mode.asked)"
|
||||
tpl="$(box_cfg "$i" user.box.template)"; u="$(box_cfg "$i" user.box.user)"
|
||||
role="$(box_cfg "$i" user.box.role)"
|
||||
rrepo="$(box_cfg "$i" user.box.rig.repo)"; rref="$(box_cfg "$i" user.box.rig.ref)"
|
||||
origin="$(box_cfg "$i" user.box.origin)"; from="$(box_cfg "$i" user.box.origin.from)"
|
||||
# The import event (#131) — a THIRD fact, read from its own keys. 'origin'
|
||||
# above still says mint or clone, which is what it has always meant.
|
||||
imp="$(box_cfg "$i" user.box.imported)"; impby="$(box_cfg "$i" user.box.imported.by)"
|
||||
impl="$(box_cfg "$i" user.box.imported.last)"
|
||||
implby="$(box_cfg "$i" user.box.imported.last.by)"
|
||||
impn="$(box_cfg "$i" user.box.imported.count)"
|
||||
# A pre-rename box has no metadata at all but is always a Claude box — the
|
||||
# same mapping box_user() makes, for the same reason.
|
||||
[ -n "$tpl" ] || [ "$(box_cfg "$i" user.claudebox)" != 1 ] || { tpl=claude; u="${u:-claude}"; }
|
||||
|
||||
echo
|
||||
if [ -n "$created" ] || [ -n "$ver" ]; then
|
||||
printf '%-11s%s\n' MINTED "${created:-(time not recorded)} by box ${ver:-unknown}"
|
||||
else
|
||||
printf '%-11s%s\n' MINTED "(not recorded — this box predates the mint stamp)"
|
||||
fi
|
||||
# Directly under MINTED, because adjacency is what defeats the misreading:
|
||||
# the mint time above is the ARTIFACT's, carried across the trip on purpose,
|
||||
# and a reader who meets it alone will take it for this host's.
|
||||
#
|
||||
# The qualifier states the one thing box actually knows — the ORDERING. It
|
||||
# does not say "on another host": a box can be exported and re-imported onto
|
||||
# the SAME host (that is the installer's own upgrade advice, #66), and
|
||||
# nothing on the box records which host minted it. Inventing a location here
|
||||
# would be precisely the fabricated fact the stamp exists to avoid.
|
||||
if [ -n "$impl" ] || [ -n "$imp" ]; then
|
||||
printf '%-11s%s\n' IMPORTED \
|
||||
"${impl:-$imp} by box ${implby:-${impby:-unknown}} (the mint above predates it)"
|
||||
# The birth of the import history, shown only when it is a DIFFERENT trip
|
||||
# from the latest — on a single import both pairs hold the same values and
|
||||
# a second line would be noise. Continuation indent, no second label.
|
||||
if [ -n "$imp" ] && [ "$imp" != "${impl:-$imp}" ]; then
|
||||
printf '%-11s%s\n' '' "import ${impn:-?} — the first was $imp by box ${impby:-unknown}"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$tpl" ]; then
|
||||
local paren=""
|
||||
[ -z "$u" ] || paren="user $u"
|
||||
[ -z "$role" ] || paren="${paren:+$paren, }role $role"
|
||||
printf '%-11s%s\n' TEMPLATE "$tpl${paren:+ ($paren)}"
|
||||
fi
|
||||
[ -z "$img" ] || printf '%-11s%s\n' IMAGE "$img${fp:+ @ ${fp:0:12}…}"
|
||||
# The mode is only worth a line alongside what was ASKED: TYPE above already
|
||||
# says VM or CT, but only the mint knew whether a container was chosen or
|
||||
# fallen back into for want of /dev/kvm.
|
||||
[ -z "$asked" ] || printf '%-11s%s\n' MODE "${m:-?} (asked: $asked)"
|
||||
[ -z "$rrepo" ] || printf '%-11s%s\n' RIG "$rrepo@${rref:-?}"
|
||||
[ -z "$origin" ] || printf '%-11s%s\n' ORIGIN "$origin${from:+ of $from}"
|
||||
# A schema box does not recognise is NEWER than box, not broken: show what is
|
||||
# understood and say so, rather than refusing to describe a box that a later
|
||||
# release minted perfectly well. (A non-integer lands here too, which is the
|
||||
# right side to fail on.)
|
||||
if [ -n "$schema" ] && { ! [ "$schema" -eq "$schema" ] 2>/dev/null || [ "$schema" -gt "$BOX_STAMP_SCHEMA" ]; }; then
|
||||
printf '%-11s%s\n' NOTE "stamp schema '$schema' is newer than this box ($(box_version)) reads ($BOX_STAMP_SCHEMA) — showing what it understands"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_info() {
|
||||
local box="${args[0]}" row
|
||||
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
||||
|
|
@ -1296,6 +1802,12 @@ cmd_info() {
|
|||
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
|
||||
done < <(incus config device list "$inst" 2>/dev/null)
|
||||
|
||||
# What built this box, from what, when, with which box (#103). Nothing else
|
||||
# on the host records it — the instance config IS the store — so a stamp
|
||||
# nothing surfaces is a stamp nobody has. 'box info --json' carries the keys
|
||||
# for free: 'incus list --format json' includes config verbatim.
|
||||
box_provenance "$inst"
|
||||
|
||||
echo
|
||||
case "${snaps:-0}" in
|
||||
''|0)
|
||||
|
|
@ -1333,13 +1845,18 @@ box_user() {
|
|||
}
|
||||
|
||||
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
||||
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
|
||||
# sudo -i joins its command argv into one shell string. In that join, a
|
||||
# backslash-newline becomes a shell continuation and silently deletes the
|
||||
# newline from a multi-line `box exec` payload (#169). Keep the login
|
||||
# environment explicitly, but let the inner shell exec the original argv.
|
||||
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -H bash -lc 'cd ~ && exec "$@"' _ "${args[@]:1}"; }
|
||||
|
||||
# A shell is a child of the exec connection: drop the terminal and everything
|
||||
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
|
||||
# attaches when the session exists and creates it when it doesn't, so starting
|
||||
# work and reattaching after a disconnect are the same command. 'shell' stays
|
||||
# bare on purpose — two verbs, two contracts.
|
||||
# bare on purpose — two verbs, two contracts. Unlike cmd_exec, tmux has no
|
||||
# caller-supplied command payload; its session name is validated below.
|
||||
cmd_tmux() {
|
||||
local session="${args[1]:-main}"
|
||||
case "$session" in
|
||||
|
|
@ -1445,6 +1962,69 @@ cmd_import() {
|
|||
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
|
||||
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
|
||||
[ "$tag" = 1 ] || incus config set "$target" user.box=1
|
||||
# The import EVENT (#131). Everything above either restores the ARTIFACT's
|
||||
# truth or re-establishes THIS host's boundary; none of it records that the
|
||||
# trip happened. The mint stamp (#103) rides inside the artifact verbatim —
|
||||
# which is correct, mint facts belong to the originating host — and that is
|
||||
# exactly what creates the gap: an imported box is otherwise indistinguishable
|
||||
# from one minted here at the artifact's mint time.
|
||||
#
|
||||
# Deliberately NOT 'origin=import'. 'origin' answers how this instance came
|
||||
# into BEING — mint or clone — and overwriting it destroys that answer: a
|
||||
# clone that was exported and imported would come back claiming to be an
|
||||
# import, with nothing left saying it was ever a clone and an 'origin.from'
|
||||
# naming a lineage no key explains any more. The import is a THIRD fact,
|
||||
# orthogonal to the first two, so it takes its OWN keys, and every key the
|
||||
# artifact carried — origin, origin.from, created, version, template, user,
|
||||
# image, role, rig.* — is left exactly as it arrived.
|
||||
#
|
||||
# Birth pair + latest pair, the shape heavy-duty/rig#61 settled on for the
|
||||
# same repeated-event question. It transfers: a box can make the trip more
|
||||
# than once, and these keys ride inside the artifact on every subsequent one.
|
||||
# · imported / imported.by FIRST-wins. Pinned once, never rewritten.
|
||||
# · imported.last / .last.by LAST-wins. When it landed HERE.
|
||||
# · imported.count how many trips — first and last alone
|
||||
# cannot tell 2 apart from 5, and nothing
|
||||
# else on the box can reconstruct it.
|
||||
# Last-wins alone would repeat, one level down, the very mistake this key
|
||||
# exists to avoid: the second import would erase the evidence of the first.
|
||||
# First-wins alone cannot answer "when did this land on THIS host", which is
|
||||
# the question an operator actually asks.
|
||||
local first_at prev_n n now
|
||||
first_at="$(incus config get "$target" user.box.imported 2>/dev/null || true)"
|
||||
prev_n="$(incus config get "$target" user.box.imported.count 2>/dev/null || true)"
|
||||
# A count that is absent (the first trip) or not an integer (a hand-edited
|
||||
# config, a foreign user.box.imported.count) must never fail an import that
|
||||
# has already happened — arithmetic on it under 'set -e' would. Same side the
|
||||
# schema check in box_provenance() falls on: degrade, never die.
|
||||
# Two holes, and they have to close together. `[ 08 -eq 08 ]` PASSES — test
|
||||
# parses decimal — and then `$((08 + 1))` aborts with "value too great for
|
||||
# base", because arithmetic reads a leading zero as octal. That abort lands
|
||||
# after the physical 'incus import' and before the stamp, the placement fix
|
||||
# and the start: precisely the window this guard exists to protect. So the
|
||||
# digits-only case closes sign and garbage, and 10# forces base ten.
|
||||
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
|
||||
n=$((10#$prev_n + 1)); now="$(mint_time)"
|
||||
local istamp=(
|
||||
user.box.imported.last="$now"
|
||||
user.box.imported.last.by="$(box_version)"
|
||||
user.box.imported.count="$n"
|
||||
)
|
||||
# The birth of this box's import history: pinned on the first trip and
|
||||
# carried by the artifact forever after, so a box on its third host still
|
||||
# names the trip that started it.
|
||||
[ -n "$first_at" ] || istamp+=(user.box.imported="$now" user.box.imported.by="$(box_version)")
|
||||
# No 'user.box.schema' is written here, on either path. "Absent schema means
|
||||
# pre-stamp" is load-bearing for the MINTED line, and stamping schema=1 onto
|
||||
# a legacy artifact that carries no mint stamp would claim a shape it does
|
||||
# not have. These keys need no schema to be read — every reader tolerates
|
||||
# absence by construction. Nor does the schema BUMP: #103's contract is that
|
||||
# it moves on removal or repurposing, never on addition, and this only adds.
|
||||
#
|
||||
# Before the start, like the clone re-stamp and for the same reason: an
|
||||
# imported box is never observable — not for a second, not by a concurrent
|
||||
# 'box info' — without the record of how it got here.
|
||||
incus config set "$target" "${istamp[@]}"
|
||||
# Placement: the artifact carries its profile list, but the isolation
|
||||
# contract is THIS host's box-net profile. A box export already says
|
||||
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# box design
|
||||
|
||||
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
||||
with a coding agent installed** (`claude`, `codex`, `grok`, or `blank` for
|
||||
none). It is infrastructure, not a project provisioner.
|
||||
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
|
||||
`kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
|
||||
|
||||
See issue #3 for the full reframe and rationale. This doc captures the durable
|
||||
design decisions.
|
||||
|
|
@ -43,6 +43,33 @@ snapshots, not a secrets store:
|
|||
|
||||
Log in once → snapshot → spin up authed boxes from it.
|
||||
|
||||
One checkpoint box takes itself: **`pristine`**, marked on every fresh mint
|
||||
after cloud-init and before the `rig bootstrap` hook (#104, child of
|
||||
heavy-duty/rig#62). That instant — pristine Debian plus box's thin seed, rig
|
||||
installed but not yet run — is the state "back to pristine Debian" names, it
|
||||
lasts a few seconds, and nobody is standing there to snapshot it. `box
|
||||
restore <n> pristine` undoes a tenant role wholesale, because everything the
|
||||
creds-free roles do is box-local and file-shaped. A `--from` clone takes no
|
||||
`pristine`: it never has the moment, so it inherits its source's snapshots or
|
||||
has none, and box refuses to label a worked-in state as pristine. On a `dir`
|
||||
pool the mark would be a full copy rather than a CoW one, so the mint skips
|
||||
it loudly.
|
||||
|
||||
A second, **`bootstrapped`**, is marked one step later (#130) — after the rig
|
||||
hook has run and box has *watched it succeed*. Same policy, literally the same
|
||||
function: default on, never fatal, skipped on `dir`, opt-out via
|
||||
`BOX_SNAPSHOT_BOOTSTRAPPED=0`. Where the two differ is deliberate.
|
||||
`pristine` marks a **moment** every fresh mint has, so it is unconditional;
|
||||
`bootstrapped` marks an **event**, so it is not. A blank box has no hook and
|
||||
therefore no event — marking it anyway would duplicate `pristine` byte for
|
||||
byte at twice the disk cost, or assert a convergence that never happened. A
|
||||
failed hook gets no mark either: box tells the operator to re-run the role
|
||||
through `box shell`, which is a run box does not watch, so it hands over `box
|
||||
snapshot <n> bootstrapped` instead of guessing. The consequence is a label
|
||||
that is only ever read one way — **presence** means "converged and untouched";
|
||||
**absence** means nothing, and nothing in box may treat a missing
|
||||
`bootstrapped` as evidence a box is unconverged.
|
||||
|
||||
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
|
||||
clone still lives on the same host. The off-host mechanism is `box export` /
|
||||
`box import` (#70) — one portable backup tarball, snapshots included by
|
||||
|
|
@ -50,7 +77,13 @@ default, that survives `rm`, a host teardown, an upgrade, a move. The split
|
|||
of truths is the design: everything `incus import` restores is the artifact's
|
||||
(disk, config, snapshots); everything box re-stamps on import is the current
|
||||
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
|
||||
machine identity via the same `reset_identity` a clone gets). Auth state
|
||||
machine identity via the same `reset_identity` a clone gets, and the record
|
||||
that the trip happened). That last one is #131, and it is deliberately *not*
|
||||
`origin=import`: `origin` says how the instance came into **being** — mint or
|
||||
clone — and the import is a third, orthogonal fact. Overwriting `origin` would
|
||||
make an exported clone come back claiming to be an import, with its
|
||||
`origin.from` lineage left unreadable, so the import gets its own keys and the
|
||||
artifact's mint stamp survives the trip untouched. Auth state
|
||||
rides along deliberately — and because scrubbing a disk image is a promise
|
||||
tarball surgery cannot keep, export shouts that the file is a credential
|
||||
instead of pretending to sanitize it.
|
||||
|
|
@ -60,15 +93,17 @@ instead of pretending to sanitize it.
|
|||
A template is a **thin, creds-free seed** — base image, the tenant user,
|
||||
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
|
||||
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
|
||||
template's creds-free tenant role after cloud-init (`rig bootstrap claude` /
|
||||
`codex` / `grok` / `staging`), which installs the agent CLI or server
|
||||
posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
||||
template's creds-free tenant role after cloud-init (`rig bootstrap claude-box`
|
||||
/ `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
|
||||
`-box` for box tenants and `-server` for fleet machines, and the templates are
|
||||
named for the roles they converge, rig#76), which installs the agent CLI or
|
||||
server posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
||||
not convergent, not re-runnable, only parse-and-grep testable — while a rig
|
||||
role is an idempotent script with effective-state asserts that can also
|
||||
converge an *existing* box to a newer spec. Anything that joins a tailnet or
|
||||
holds a key (staging's workload join) stays operator-run through
|
||||
`box shell`; box prints it as a next step and never sees the key. The seed's
|
||||
rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
|
||||
holds a key (the staging-box tenant's workload join) stays operator-run
|
||||
through `box shell`; box prints it as a next step and never sees the key. The
|
||||
seed's rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
|
||||
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
|
||||
and box's template suite holds the line with fail-closed absence greps: no
|
||||
agent CLI, no docker, no tailscale, no context-file heredocs in any
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
`box` mints trust-less, creds-free, isolated VMs with a coding agent already
|
||||
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`) — the
|
||||
`claude`, `codex`, and `grok` templates each ship a CLI agent. The tool knows
|
||||
**nothing** about your project. There is no `install` step and no host-run
|
||||
setup script.
|
||||
`claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
|
||||
CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
|
||||
host-run setup script.
|
||||
|
||||
A project makes itself easy to stand up inside a box by shipping an optional
|
||||
`.box/` folder. This folder is **agent-facing documentation** — read and
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
|
|||
# Read from inside a box WITHOUT ever hanging the drill.
|
||||
#
|
||||
# Two traps, both hit for real:
|
||||
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
|
||||
# all). Fine for a person, needless machinery for a probe.
|
||||
# · 'box exec' crosses a login-user shell boundary. Fine for a person,
|
||||
# needless machinery for a probe.
|
||||
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
||||
# inheriting the exec session's stdout keeps the substitution open forever,
|
||||
# and 'timeout' does not save you: it kills the wrapper, not the holder of
|
||||
|
|
@ -378,7 +378,7 @@ if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
|
|||
fi
|
||||
inf "host setup complete"
|
||||
|
||||
# A real server has room for the claude template's resources (8GiB/4cpu), and
|
||||
# A real server has room for the claude-box template's resources (8GiB/4cpu), and
|
||||
# drilling the real numbers is worth more than drilling shrunken ones. Only
|
||||
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
|
||||
# template at mint — a profile edit no longer reaches them; the supported
|
||||
|
|
@ -386,9 +386,9 @@ inf "host setup complete"
|
|||
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
||||
if [ "$ram" -lt 20 ]; then
|
||||
export BOX_MEMORY=3GiB BOX_CPU=2
|
||||
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)"
|
||||
note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude-box template's 8GiB/4cpu is what was NOT drilled)"
|
||||
else
|
||||
inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
|
||||
inf "host has ${ram}GiB RAM — drilling the claude-box template's resources (8GiB/4cpu) unchanged"
|
||||
fi
|
||||
|
||||
KVM=0; [ -e /dev/kvm ] && KVM=1
|
||||
|
|
@ -492,10 +492,10 @@ fi
|
|||
|
||||
# --- templates: the mint surface is itself a surface to test ----------------
|
||||
tpl_missing=""
|
||||
for t in blank claude codex grok; do
|
||||
for t in blank claude-box codex-box grok-box kimi-box; do
|
||||
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
|
||||
done
|
||||
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \
|
||||
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude-box, codex-box, grok-box, kimi-box" \
|
||||
|| no "templates listing is missing:$tpl_missing"
|
||||
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
||||
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
||||
|
|
@ -551,19 +551,22 @@ else
|
|||
fi
|
||||
|
||||
# The generic mechanic (metadata, placement, user, isolation parity) is proven
|
||||
# once by blank+claude and needs no per-template repeat. What a NEW template
|
||||
# once by blank+claude-box and needs no per-template repeat. What a NEW template
|
||||
# still has to prove is its own payload: the CLI installs, lands on the
|
||||
# non-interactive exec PATH, and answers --version. One mint each.
|
||||
# The box NAME stays the bare agent name — it is what the pre-flight banner
|
||||
# announces and what teardown deletes — while the TEMPLATE carries rig#76's
|
||||
# family suffix. They are two different namespaces and only one of them moved.
|
||||
for t in codex grok; do
|
||||
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
|
||||
printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
|
||||
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then
|
||||
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t-box"; then
|
||||
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
|
||||
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
|
||||
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
|
||||
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
|
||||
else
|
||||
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)"
|
||||
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude-box template's #15 bug)"
|
||||
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
|
||||
# Do not throw the evidence away — say WHAT the installer actually left.
|
||||
# Do NOT throw the evidence away — say what the installer actually left
|
||||
|
|
@ -583,10 +586,10 @@ for t in codex grok; do
|
|||
fi
|
||||
done
|
||||
|
||||
printf '\n minting a claude box (cold, ~10 min)…\n'
|
||||
printf '\n minting a claude-box box (cold, ~10 min)…\n'
|
||||
t0=$SECONDS
|
||||
if mint_box /tmp/mint-drill.log --name drill --template claude; then
|
||||
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
|
||||
if mint_box /tmp/mint-drill.log --name drill --template claude-box; then
|
||||
ok "box new --name drill --template claude-box ($((SECONDS - t0))s)"
|
||||
else
|
||||
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
||||
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
|
||||
|
|
@ -799,7 +802,7 @@ fi
|
|||
# ===========================================================================
|
||||
phase "E. box expose — a deliberate loopback door (#55)"
|
||||
# ===========================================================================
|
||||
# archive is a running claude box (node is installed). Start a DETACHED
|
||||
# archive is a running claude-box box (node is installed). Start a DETACHED
|
||||
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
|
||||
# from the HOST's loopback. Then prove removing it closes the door, and that a
|
||||
# NON-exposed port still obeys the ingress drop — the feature must not
|
||||
|
|
|
|||
|
|
@ -202,6 +202,31 @@ fi
|
|||
rm -f "$mintlog"
|
||||
as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine"
|
||||
as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed"
|
||||
|
||||
# #169: compare the new explicit login-user boundary with sudo -i while the
|
||||
# latter is still safe (one fixed `env` argv), then exercise the exact
|
||||
# silent-success multiline shape that sudo -i used to corrupt.
|
||||
target_user="$(as_u "$U1" incus config get mine user.box.user)"
|
||||
legacy_env="$(as_u "$U1" incus exec mine -- sudo -u "$target_user" -i env \
|
||||
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||
exec_env="$(as_u "$U1" box exec mine -- env \
|
||||
| grep -E '^(USER|LOGNAME|SHELL|HOME|PWD|PATH)=' | sort)"
|
||||
[ "$exec_env" = "$legacy_env" ] \
|
||||
&& ok "(b) box exec preserves cross-user login environment (USER/LOGNAME/SHELL/HOME/PWD/PATH)" \
|
||||
|| {
|
||||
no "(b) box exec changed the cross-user login environment (#169)"
|
||||
diff -u <(printf '%s\n' "$legacy_env") <(printf '%s\n' "$exec_env") | sed 's/^/ /' || true
|
||||
}
|
||||
as_u "$U1" box exec mine -- bash -lc '
|
||||
set -o errexit -o nounset -o pipefail
|
||||
touch /tmp/box-169-step-one
|
||||
touch /tmp/box-169-step-two
|
||||
' >/dev/null 2>&1 \
|
||||
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-one \
|
||||
&& as_u "$U1" box exec mine -- test -f /tmp/box-169-step-two \
|
||||
&& ok "(b) box exec preserves multiline commands, including the silent-success set shape" \
|
||||
|| no "(b) box exec corrupted a multiline command (#169)"
|
||||
|
||||
as_u "$U1" box info mine 2>/dev/null | grep -qF "$(boxnet_pfx)" \
|
||||
&& ok "(g) box info shows a boxnet ($(boxnet_pfx)x) address — placed on the hardened network" \
|
||||
|| no "(g) mine has no boxnet address in box info"
|
||||
|
|
|
|||
|
|
@ -117,10 +117,39 @@ for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
|
|||
# shellcheck disable=SC2086 # the table spec is two words by design
|
||||
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
|
||||
done
|
||||
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
# Every ufw read is CAPTURED before it is matched, never piped into a reader
|
||||
# that exits on its first match (#102, #107).
|
||||
#
|
||||
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
|
||||
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
|
||||
# and the pipeline yields 141. This file is 'set -u' with no pipefail, so
|
||||
# that 141 is discarded, grep's 0 is the pipeline's result, and the branch
|
||||
# held — the defect was latent here, never live. It was also one line from
|
||||
# live: adding 'set -o pipefail' for unrelated robustness would silently turn
|
||||
# this into #102, skipping every UFW removal on a host the operator was told
|
||||
# is wiped, with no error and no red X to see. Captured and matched with
|
||||
# [[ ]], it is correct under any future 'set' line.
|
||||
#
|
||||
# The numbered loop had the same defect for a different reason: its condition
|
||||
# was also an early-exit reader, so it could end while rules remained, and it
|
||||
# re-read un-captured to get the number. It now reads one capture per
|
||||
# iteration and breaks on absence — the re-scan is still per-delete (numbers
|
||||
# shift after each removal), just no longer racing.
|
||||
ufw_status=""
|
||||
if command -v ufw >/dev/null; then
|
||||
# '|| true': ufw exits non-zero when it cannot read its config, and "no
|
||||
# usable ufw here" is nothing-to-clean, not a failure to report.
|
||||
ufw_status="$(sudo ufw status 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ "$ufw_status" == *"Status: active"* ]]; then
|
||||
for net in boxnet claudenet; do
|
||||
while sudo ufw status numbered | grep -q "on $net"; do
|
||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||
while :; do
|
||||
numbered="$(sudo ufw status numbered 2>/dev/null || true)"
|
||||
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
|
||||
[ -n "$line" ] || break
|
||||
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
||||
[ -n "$n" ] || break
|
||||
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
|
||||
done
|
||||
done
|
||||
|
|
|
|||
57
drills/0.9.0.md
Normal file
57
drills/0.9.0.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# box 0.9.0 — release drill
|
||||
|
||||
**WAIVED. No drill was run for this release.**
|
||||
|
||||
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
|
||||
gate requires a *record*, not a passing result — so a skip is a line in a diff
|
||||
somebody reviewed, rather than the silence that let 0.7.0, 0.8.0 and every
|
||||
release before them ship unproven.
|
||||
|
||||
## Why
|
||||
|
||||
The drill harness is not in a state to produce a trustworthy run. box has the
|
||||
only real harness in the family (`drill/drill.sh`, ~85 probes), but the
|
||||
procedure around it — standing up the substrate, pinning the release
|
||||
candidates, capturing the result — was undocumented and assembled by hand.
|
||||
rig and cast have no harness at all. A run improvised under those conditions
|
||||
produces numbers nobody should rely on, and `drills/` is worth nothing if it
|
||||
fills with records like that.
|
||||
|
||||
Deferred deliberately, and tracked: #155.
|
||||
|
||||
## What this release therefore does NOT prove
|
||||
|
||||
Stated plainly so it is not rediscovered as a surprise:
|
||||
|
||||
- **The VM trust boundary.** `ci.yml` says it in its own words: *"What
|
||||
container mode canNOT validate is the VM trust boundary itself; that stays a
|
||||
real-hardware ritual."* That is exactly the delta a release drill adds, and
|
||||
it is exactly what is missing here.
|
||||
- **The isolation contract on real hardware** — egress, the sibling drop, DNS
|
||||
enumeration, IPv6 off, inbound drop — as measured by `drill/drill.sh` against
|
||||
real VMs rather than containers.
|
||||
- **The combination users receive.** A released box still resolves `RIG_REF` to
|
||||
`main` (#150), so even a passing drill would have proven a pair that drifts
|
||||
the moment rig's main moves.
|
||||
|
||||
## What CI did prove on this tree
|
||||
|
||||
Not nothing, and worth recording so the gap is precise rather than total:
|
||||
|
||||
| Job | Result |
|
||||
|---|---|
|
||||
| `rehearsal` — multi-user tier on a real Incus | pass |
|
||||
| `scope` | pass |
|
||||
| `reconcile` | pass |
|
||||
| `test/cli.sh` | 653 passed, 0 failed |
|
||||
| `test/release.sh` | 173 passed, 0 failed |
|
||||
| `test/labels-reconcile.sh` | 72 passed, 0 failed |
|
||||
| `shellcheck -x bin/* **/*.sh` | clean |
|
||||
|
||||
The tier's semantics are covered. The boundary is not.
|
||||
|
||||
## The commitment
|
||||
|
||||
The next release carries a real drill record at `drills/<version>.md` — what
|
||||
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
|
||||
failed drill is a valid record; another waiver is not. Tracked in #155.
|
||||
82
drills/README.md
Normal file
82
drills/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# drills/ — release evidence, one file per version
|
||||
|
||||
This directory holds the **evidence that a release was proven on real
|
||||
hardware**. One file per shipped version, named exactly for the version:
|
||||
|
||||
```
|
||||
drills/0.9.0.md
|
||||
drills/0.9.0-rc1.md
|
||||
```
|
||||
|
||||
The name must match the contents of `VERSION` exactly.
|
||||
[the pinned ceremony drill-recorded action](https://github.com/heavy-duty/ceremony/tree/0.1.0/actions/drill-recorded)
|
||||
refuses any tree with a bare `VERSION` that has no such file, or whose file is
|
||||
blank. A `-dev` tree passes with nothing to assert.
|
||||
|
||||
Because each version owns a file, `0.9.0` and `0.9.0-rc1` can never be
|
||||
confused for one another — they are simply different paths. That used to take
|
||||
careful whole-version field matching inside one shared file; now it is free.
|
||||
|
||||
## This is not `drill/RUNS.md`
|
||||
|
||||
Two different artifacts, and the distinction is load-bearing:
|
||||
|
||||
| | what it is |
|
||||
|---|---|
|
||||
| [`drill/RUNS.md`](../drill/RUNS.md) | the **harness's own history** — every run of `drill/drill.sh`, the traps table, the lore about what broke and why. It is not release-scoped and it is not going anywhere. |
|
||||
| `drills/<version>.md` | **release evidence** — the record that *this version* was drilled before it shipped. Release-scoped, one file, gated by CI. |
|
||||
|
||||
Appending to `drill/RUNS.md` does not satisfy the release gate, and is not
|
||||
meant to. Keep using it for what it has always been for.
|
||||
|
||||
## What a record should contain
|
||||
|
||||
- **What ran** — which drill, how many probes, `drill/drill.sh` invocation.
|
||||
- **On what host** — the machine, the OS, the Incus version. "Real hardware"
|
||||
is the claim; name the hardware.
|
||||
- **The pinned candidate refs** — the exact `BOX_REF` / `RIG_REF` /
|
||||
`CAST_REF` under test, and the other repos' commit SHAs. A drill that does
|
||||
not say what it drilled proves nothing later.
|
||||
- **The shared run ID**, so this record reconciles with the sibling repos'.
|
||||
- **The numbers** — passed, failed, how long it took.
|
||||
- **What failed**, plainly.
|
||||
|
||||
**A failed drill is still a valid record.** The gate wants *evidence*, not
|
||||
success. A record saying "83/85, criterion (m) regressed, here is the issue"
|
||||
is a good record. So is a maintainer's written waiver explaining why this
|
||||
release shipped without a full drill. What the gate refuses is silence — #95,
|
||||
#114 and #148 all shipped unproven because a skip left no trace.
|
||||
|
||||
## Worked example
|
||||
|
||||
The version below is a **placeholder that can never be a real release**.
|
||||
Copy the shape, not the number.
|
||||
|
||||
```markdown
|
||||
# Release drill — 9.9.9
|
||||
|
||||
- **Run ID:** `drill-9.9.9-20260721-01` (shared with rig, cast)
|
||||
- **Host:** bare Debian 13, Ryzen 7 5800X / 64 GB, Incus 6.0.2
|
||||
- **Date:** 2026-07-21
|
||||
- **Candidate refs:**
|
||||
- box `release/9.9.9` @ `abc1234`
|
||||
- rig `release/4.4.4` @ `def5678` (minted with `RIG_REF=release/4.4.4`)
|
||||
- cast `release/2.2.2` @ `9abcdef`
|
||||
|
||||
## What ran
|
||||
|
||||
`bash drill/drill.sh --ref release/9.9.9` — the full end-to-end: install the
|
||||
stack, mint every template cold, snapshot and restore, uninstall to zero
|
||||
residue. Then `drill/multiuser.sh` for the two-user grant matrix.
|
||||
|
||||
## Result
|
||||
|
||||
**84/85 passed, 1 failed.** 41 minutes wall clock.
|
||||
|
||||
- Failed: `multiuser.sh` criterion (m) — the raw instance kept a stale route
|
||||
after teardown. Filed as #999. Judged not release-blocking: it affects
|
||||
teardown residue on a host that is about to be wiped, not the trust
|
||||
boundary itself.
|
||||
- The VM boundary probes (the 85-probe isolation contract) passed clean,
|
||||
which is the assertion this repo's drill exists to make.
|
||||
```
|
||||
|
|
@ -64,7 +64,11 @@ legacy_boxes() {
|
|||
}
|
||||
|
||||
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
|
||||
# tool minted), so the new metadata is the claude template's.
|
||||
# tool minted), so the new metadata is the claude-box template's. The stamp
|
||||
# names the template as it is called TODAY, not as it was called when the box
|
||||
# was minted: it is what 'box shell' and the mint hints read, so a re-homed
|
||||
# box should look like a fresh claude-box mint, not like a fossil (rig#76's
|
||||
# family suffix — the template is named for the role it converges).
|
||||
rehome_one() {
|
||||
local b="$1" st
|
||||
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
|
||||
|
|
@ -75,7 +79,7 @@ rehome_one() {
|
|||
|
||||
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
|
||||
# valid legacy box (the old tag is untouched) AND now a new one.
|
||||
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \
|
||||
incus config set "$b" user.box=1 user.box.template=claude-box user.box.user=claude \
|
||||
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
|
||||
|
||||
# 2. Stop, reassign the profile (this is the network move), restart. Incus
|
||||
|
|
|
|||
|
|
@ -200,10 +200,23 @@ fi
|
|||
# and a promise the header makes is a promise this block checks. The
|
||||
# incus-user state directory too — it was purged for releases without being
|
||||
# re-checked, which is exactly the gap this block exists to close.
|
||||
# The trust store is read into a capture rather than piped into a reader that
|
||||
# stops at its first match — #102's shape, and this file is `set -euo pipefail`
|
||||
# already, so unlike drill/wipe.sh (#107) nothing but the writer's size is
|
||||
# holding it. A reader that exits early SIGPIPEs incus mid-table and the
|
||||
# pipeline yields 141; sitting left of `&&` that is also set -e-exempt, so it
|
||||
# would read as "no leftover cert" on a host that still trusts the revoked
|
||||
# user's certificate and the purge would report success. Fail-open, on the
|
||||
# cleanup path whose entire job is to prove access is gone.
|
||||
# Un-racy in practice today — the trust store is small and likely one write —
|
||||
# so this is defensive, not a live defect. Captured so it cannot become one.
|
||||
trust_csv="$(incus config trust list --format csv --columns nf 2>/dev/null || true)"
|
||||
|
||||
leftover=""
|
||||
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
||||
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
|
||||
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
|
||||
# Leading newline so the first CSV row anchors like the `^` this replaces.
|
||||
[[ $'\n'"$trust_csv" == *$'\n'"incus-user-$uid,"* ]] \
|
||||
&& leftover="$leftover cert:incus-user-$uid"
|
||||
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
||||
&& leftover="$leftover /var/lib/incus/users/$uid"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,18 @@ $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
|
|||
if [ "$yes" -eq 1 ]; then
|
||||
echo "(confirmed non-interactively: --yes/BOX_YES)"
|
||||
else
|
||||
# No terminal to ask on, and no consent given: refuse and say how to proceed,
|
||||
# rather than fall into 'read', hit instant EOF and abort with nothing but
|
||||
# "aborted" (#113). This must stay BELOW the --yes/BOX_YES arm above — the
|
||||
# order is the contract: consent given non-interactively still runs headless
|
||||
# (CI's uninstall drill and 'box uninstall --purge-host --force' depend on
|
||||
# it), consent NOT given without a terminal is a usage error, exit 2, the
|
||||
# same shape as host/revoke-user.sh and install.sh. It also lands before the
|
||||
# first 'incus' call below, so the refusal needs no daemon.
|
||||
if [ ! -t 0 ]; then
|
||||
echo "teardown-host: refusing to run without a terminal to confirm on. --yes (or BOX_YES=1) means yes." >&2
|
||||
exit 2
|
||||
fi
|
||||
# EOF (Ctrl-D) refuses, out loud: unguarded, errexit would end the run on
|
||||
# this line and the 'aborted' below would never print (#111).
|
||||
read -rp "Continue? [y/N] " a || { echo "aborted"; exit 1; }
|
||||
|
|
|
|||
61
install.sh
61
install.sh
|
|
@ -143,12 +143,39 @@ flip_current() {
|
|||
mv -Tf "$DEST/current.new.$$" "$DEST/current"
|
||||
}
|
||||
|
||||
# Whether ANY version was installed before this run — computed BEFORE the
|
||||
# migration below, which is the whole point. It gates the host-setup offer: a
|
||||
# host that already ran box has made that decision (and may have live boxes the
|
||||
# stack must not be rebuilt under, #66); after an upgrade, 'box setup-host'
|
||||
# re-applies stack changes on purpose.
|
||||
#
|
||||
# Order is load-bearing (#115). The migration converts a pre-0.7.0 flat tree
|
||||
# into versions/<flat_ver>, so computing this AFTER it made the test true by
|
||||
# its own doing: a flat host looked "already installed", setup-host was
|
||||
# skipped, and every host-side artifact stayed at the old version while
|
||||
# 'box --version' reported the new one. A tree that needs migrating has by
|
||||
# definition never been converged by THIS version's setup-host, so it must
|
||||
# read as had_install=0. A genuinely versioned tree still reads 1 — the
|
||||
# directory it is testing predates this run.
|
||||
had_install=0
|
||||
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
|
||||
had_install=1
|
||||
fi
|
||||
|
||||
# --- migrate a pre-0.7.0 flat install --------------------------------------
|
||||
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
|
||||
# it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an
|
||||
# upgrade from 0.6.0 is seamless and the version comparison below sees the
|
||||
# truth. The move is two renames inside one parent directory — no copying, no
|
||||
# window with no install — and the operator's tree is preserved bit for bit.
|
||||
#
|
||||
# What the migration LEAVES is the operator's to decide (#117): the old tree
|
||||
# becomes a first-class 'box versions' entry — a rollback target if the new
|
||||
# version misbehaves, garbage otherwise. Deleting it here is the wrong default
|
||||
# (it is the only thing to roll back TO, at exactly the moment that matters),
|
||||
# so name it instead — and name it AGAIN in the closing summary, because a
|
||||
# line ~250 lines of output above 'done' is a line the operator scrolled past.
|
||||
migrated_from=""
|
||||
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
|
||||
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
|
||||
# The flat tree's VERSION is data from disk, not from this installer — the
|
||||
|
|
@ -164,15 +191,9 @@ if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
|
|||
mkdir -p "$BINDIR"
|
||||
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
||||
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
|
||||
fi
|
||||
|
||||
# Whether ANY version was installed before this run — read before we add one.
|
||||
# It gates the host-setup offer below: a host that already ran box has made
|
||||
# that decision (and may have live boxes the stack must not be rebuilt under,
|
||||
# #66); after an upgrade, 'box setup-host' re-applies stack changes on purpose.
|
||||
had_install=0
|
||||
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
|
||||
had_install=1
|
||||
log " it is a normal version entry now — 'box versions' lists it. Keep it as a"
|
||||
log " rollback target ('box use $flat_ver'), or reap it: box uninstall $flat_ver"
|
||||
migrated_from="$flat_ver"
|
||||
fi
|
||||
|
||||
# --- temp workspace --------------------------------------------------------
|
||||
|
|
@ -386,6 +407,15 @@ esac
|
|||
# 'box setup-host' re-applies stack changes deliberately, after an upgrade.
|
||||
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
||||
# up by hand); BOX_YES answers "yes".
|
||||
#
|
||||
# It runs $VDIR's script, NOT $DEST/current's. They are usually the same tree,
|
||||
# but 'current' does not always flip: the #66 guard above keeps the default
|
||||
# where it is when the host has existing boxes, so on such a host 'current'
|
||||
# still names the OLD version. Going through it would converge the host with
|
||||
# the old release's host-side scripts — reinstating exactly the stale artifacts
|
||||
# #115 is about, in the one case where the operator's boxes make it costly.
|
||||
# $VDIR is unambiguously the version this run installed, which is the version
|
||||
# whose host contract we are being asked to satisfy.
|
||||
setup_ok=""
|
||||
setup_declined=""
|
||||
if [ "$had_install" -eq 1 ]; then
|
||||
|
|
@ -396,7 +426,7 @@ elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
|||
setup_declined=1
|
||||
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||
warn "cannot set up the host: it needs root and sudo was not found."
|
||||
warn " run this as root to finish: $DEST/current/host/setup-host.sh"
|
||||
warn " run this as root to finish: $VDIR/host/setup-host.sh"
|
||||
setup_declined=1
|
||||
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
||||
# </dev/null because under 'curl … | bash' this script IS stdin: a child that
|
||||
|
|
@ -404,7 +434,7 @@ elif confirm "Set up this machine as a box host now? (installs Incus + the isola
|
|||
# it prompts on /dev/tty, so an interactive host can still authenticate.
|
||||
# setup-host re-execs itself under sg incus-admin if it must add you to the
|
||||
# group; that re-exec is a child here and completes the whole setup in one go.
|
||||
if bash "$DEST/current/host/setup-host.sh" </dev/null; then
|
||||
if bash "$VDIR/host/setup-host.sh" </dev/null; then
|
||||
setup_ok=1
|
||||
else
|
||||
warn "host setup did not complete — box is installed, the host is not ready."
|
||||
|
|
@ -422,3 +452,12 @@ elif [ -n "$setup_declined" ]; then
|
|||
else
|
||||
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
|
||||
fi
|
||||
|
||||
# Re-state the migration where the operator is actually looking (#117). The
|
||||
# line at the migration itself is true but ~250 lines of output ago; this is
|
||||
# the last thing printed, and it is where an unexplained 'box versions' entry
|
||||
# stops being a surprise.
|
||||
if [ -n "$migrated_from" ]; then
|
||||
log "note: your pre-0.7.0 install was migrated to versions/$migrated_from and 'box versions' now lists it."
|
||||
log " keep it to roll back ('box use $migrated_from'), or remove it: box uninstall $migrated_from"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
# The claude template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# The claude-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI,
|
||||
# docker, node, the agent-context file with its #80 guard — is rig's job:
|
||||
# box auto-runs 'rig bootstrap claude' after mint (heavy-duty/rig#31).
|
||||
# box auto-runs 'rig bootstrap claude-box' after mint (heavy-duty/rig#31).
|
||||
# The template is named for the role it converges, suffix and all
|
||||
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||
# 'claude' would ask a post-rename rig for a role that no longer exists.
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
|
|
@ -14,4 +18,4 @@ BOX_USER="claude"
|
|||
BOX_CPU="4"
|
||||
BOX_MEMORY="8GiB"
|
||||
BOX_DISK="60GiB"
|
||||
BOX_BOOTSTRAP_ROLE="claude"
|
||||
BOX_BOOTSTRAP_ROLE="claude-box"
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||
# 'rig bootstrap claude' (heavy-duty/rig#31), which box auto-runs after
|
||||
# 'rig bootstrap claude-box' (heavy-duty/rig#31), which box auto-runs after
|
||||
# mint; the agent-context file — including the #80 guard (never run
|
||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||
# once, instead of being copy-pasted per template.
|
||||
|
|
@ -21,7 +21,7 @@ packages:
|
|||
- ca-certificates
|
||||
runcmd:
|
||||
# Preinstall rig so the box can converge — and re-converge — via
|
||||
# 'rig bootstrap claude'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# 'rig bootstrap claude-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
# The codex template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# The codex-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI,
|
||||
# docker, node, the agent-context file with its #80 guard — is rig's job:
|
||||
# box auto-runs 'rig bootstrap codex' after mint (heavy-duty/rig#31).
|
||||
# box auto-runs 'rig bootstrap codex-box' after mint (heavy-duty/rig#31).
|
||||
# The template is named for the role it converges, suffix and all
|
||||
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||
# 'codex' would ask a post-rename rig for a role that no longer exists.
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
|
|
@ -14,4 +18,4 @@ BOX_USER="codex"
|
|||
BOX_CPU="4"
|
||||
BOX_MEMORY="8GiB"
|
||||
BOX_DISK="60GiB"
|
||||
BOX_BOOTSTRAP_ROLE="codex"
|
||||
BOX_BOOTSTRAP_ROLE="codex-box"
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||
# 'rig bootstrap codex' (heavy-duty/rig#31), which box auto-runs after
|
||||
# 'rig bootstrap codex-box' (heavy-duty/rig#31), which box auto-runs after
|
||||
# mint; the agent-context file — including the #80 guard (never run
|
||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||
# once, instead of being copy-pasted per template.
|
||||
|
|
@ -21,7 +21,7 @@ packages:
|
|||
- ca-certificates
|
||||
runcmd:
|
||||
# Preinstall rig so the box can converge — and re-converge — via
|
||||
# 'rig bootstrap codex'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# 'rig bootstrap codex-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
# The grok template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# The grok-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI,
|
||||
# docker, the agent-context file with its #80 guard — is rig's job:
|
||||
# box auto-runs 'rig bootstrap grok' after mint (heavy-duty/rig#31).
|
||||
# box auto-runs 'rig bootstrap grok-box' after mint (heavy-duty/rig#31).
|
||||
# The template is named for the role it converges, suffix and all
|
||||
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||
# 'grok' would ask a post-rename rig for a role that no longer exists.
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
|
|
@ -14,4 +18,4 @@ BOX_USER="grok"
|
|||
BOX_CPU="4"
|
||||
BOX_MEMORY="8GiB"
|
||||
BOX_DISK="60GiB"
|
||||
BOX_BOOTSTRAP_ROLE="grok"
|
||||
BOX_BOOTSTRAP_ROLE="grok-box"
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||
# 'rig bootstrap grok' (heavy-duty/rig#31), which box auto-runs after
|
||||
# 'rig bootstrap grok-box' (heavy-duty/rig#31), which box auto-runs after
|
||||
# mint; the agent-context file — including the #80 guard (never run
|
||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||
# once, instead of being copy-pasted per template.
|
||||
|
|
@ -21,7 +21,7 @@ packages:
|
|||
- ca-certificates
|
||||
runcmd:
|
||||
# Preinstall rig so the box can converge — and re-converge — via
|
||||
# 'rig bootstrap grok'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# 'rig bootstrap grok-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||
22
templates/kimi-box/box.env
Normal file
22
templates/kimi-box/box.env
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# The kimi-box template — a thin, creds-free seed (#81): Debian 13, the
|
||||
# 'kimi' user, tmux and rig. What the box BECOMES — the Moonshot Kimi CLI,
|
||||
# docker, the agent-context file with its #80 guard — is rig's job:
|
||||
# box auto-runs 'rig bootstrap kimi-box' after mint (heavy-duty/rig#31,
|
||||
# the role itself: heavy-duty/rig#109).
|
||||
# The template is named for the role it converges, suffix and all
|
||||
# (heavy-duty/rig#76): rig's roles carry a family suffix — '-server' for
|
||||
# fleet machines, '-box' for box tenants — and a seed that named the bare
|
||||
# 'kimi' would ask a post-rename rig for a role that no longer exists.
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
# BOX_USER must match the user user-data.yaml creates (the duplication is
|
||||
# deliberate and by hand) — and it is the tenant user the rig role converges
|
||||
# (rig dies loudly if the seed did not create it).
|
||||
BOX_DESCRIPTION="Moonshot Kimi CLI on Debian 13, creds-free — box mints, rig converges"
|
||||
BOX_IMAGE="images:debian/13/cloud"
|
||||
BOX_USER="kimi"
|
||||
BOX_CPU="4"
|
||||
BOX_MEMORY="8GiB"
|
||||
BOX_DISK="60GiB"
|
||||
BOX_BOOTSTRAP_ROLE="kimi-box"
|
||||
33
templates/kimi-box/user-data.yaml
Normal file
33
templates/kimi-box/user-data.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#cloud-config
|
||||
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||
# 'rig bootstrap kimi-box' (heavy-duty/rig#31), which box auto-runs after
|
||||
# mint; the agent-context file — including the #80 guard (never run
|
||||
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||
# once, instead of being copy-pasted per template.
|
||||
users:
|
||||
- name: kimi
|
||||
shell: /bin/bash
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
lock_passwd: true
|
||||
package_update: true
|
||||
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
|
||||
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||
# cloud image is not guaranteed to ship either.
|
||||
packages:
|
||||
- tmux
|
||||
- curl
|
||||
- ca-certificates
|
||||
runcmd:
|
||||
# Preinstall rig so the box can converge — and re-converge — via
|
||||
# 'rig bootstrap kimi-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||
# release flow). The pin covers both the installer fetched AND the tree
|
||||
# it installs, so a branch under review is testable end to end.
|
||||
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
# The staging template — a thin, creds-free, server-class seed (#81, the
|
||||
# The staging-box template — a thin, creds-free, server-class seed (#81, the
|
||||
# re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The
|
||||
# server posture — docker, sshd hardening — is rig's job: box auto-runs
|
||||
# 'rig bootstrap staging' after mint (heavy-duty/rig#31). The tailnet
|
||||
# 'rig bootstrap staging-box' after mint (heavy-duty/rig#31). The template is
|
||||
# named for the role it converges, suffix and all (heavy-duty/rig#76): rig's
|
||||
# roles carry a family suffix — '-server' for fleet machines, '-box' for box
|
||||
# tenants — and a seed that named the bare 'staging' would ask a post-rename
|
||||
# rig for a role that no longer exists. Server-CLASS, not a fleet machine:
|
||||
# this is still a box tenant, so it takes '-box', not '-server'. The tailnet
|
||||
# workload join holds a key and therefore STAYS operator-run:
|
||||
# box shell <name> # then: sudo rig bootstrap workload --hostname <name>
|
||||
# box shell <name> # then: sudo rig bootstrap workload-server --hostname <name>
|
||||
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
|
||||
# key for a network or a security flag, on purpose — the shared box-net
|
||||
# profile is the placement contract and no template can weaken it.
|
||||
|
|
@ -21,4 +26,4 @@ BOX_MEMORY="8GiB"
|
|||
BOX_DISK="60GiB"
|
||||
BOX_REQUIRE_VM="1"
|
||||
BOX_AUTOSTART="1"
|
||||
BOX_BOOTSTRAP_ROLE="staging"
|
||||
BOX_BOOTSTRAP_ROLE="staging-box"
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
# A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65),
|
||||
# and rig — nothing that joins a tailnet or admits credentials, no docker,
|
||||
# no sshd config, no keys. The server posture comes from
|
||||
# 'rig bootstrap staging' (heavy-duty/rig#31), which box auto-runs after
|
||||
# 'rig bootstrap staging-box' (heavy-duty/rig#31), which box auto-runs after
|
||||
# mint; the tailnet workload join holds a pre-auth key and stays
|
||||
# operator-run ('box shell' → 'sudo rig bootstrap workload'), exactly as
|
||||
# operator-run ('box shell' → 'sudo rig bootstrap workload-server'), exactly as
|
||||
# #69 designed it — box never sees the key.
|
||||
users:
|
||||
- name: ops
|
||||
|
|
@ -22,7 +22,7 @@ packages:
|
|||
- ca-certificates
|
||||
runcmd:
|
||||
# Preinstall rig so the box can converge — and re-converge — via
|
||||
# 'rig bootstrap staging'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# 'rig bootstrap staging-box'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||
1233
test/cli.sh
1233
test/cli.sh
File diff suppressed because it is too large
Load diff
|
|
@ -1,150 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fixture tests for the labels-reconcile state machine: a comment is a
|
||||
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
|
||||
# human), a stale approval does not promote unreviewed code, and an explicit
|
||||
# human request outranks everything.
|
||||
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
# shellcheck source=.github/scripts/labels-reconcile.sh
|
||||
. .github/scripts/labels-reconcile.sh
|
||||
|
||||
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
|
||||
# machine's inputs, consumed inside the sourced decide_state — not unused.
|
||||
# shellcheck disable=SC2034
|
||||
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
|
||||
pass=0 fail=0
|
||||
|
||||
expect() { # $1 = description, $2 = want, $3 = got
|
||||
if [ "$2" = "$3" ]; then
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
fail=$((fail + 1))
|
||||
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
|
||||
fi
|
||||
}
|
||||
|
||||
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
|
||||
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
|
||||
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
|
||||
}
|
||||
|
||||
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
|
||||
|
||||
# -- drafts are building, whoever is requested --------------------------------
|
||||
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
||||
expect "draft PR is building" state:building "$(decide_state)"
|
||||
|
||||
# -- fresh ready PR with bots requested ---------------------------------------
|
||||
DRAFT=false REQUESTED="$BOT1
|
||||
$BOT2
|
||||
$BOT3" REVIEWS_JSON='[]'
|
||||
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||
|
||||
# -- a bot that never reviewed keeps the round open ---------------------------
|
||||
REQUESTED="" REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)")"
|
||||
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||
|
||||
# -- a comment is a non-verdict, agreement body or not: the author escalates --
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
|
||||
# ...and the author's escalation — requesting the human — flips it
|
||||
REQUESTED="$HUMAN"
|
||||
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
|
||||
REQUESTED=""
|
||||
|
||||
# -- three formal approvals need no author judgment ---------------------------
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
|
||||
|
||||
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
|
||||
|
||||
# -- changes requested blocks, at any head ------------------------------------
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
|
||||
|
||||
# -- a stale approval must not promote unreviewed code ------------------------
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED old1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
|
||||
|
||||
# -- a re-requested bot reopens the round even with an old approval on file ---
|
||||
REQUESTED="$BOT1"
|
||||
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||
REQUESTED=""
|
||||
|
||||
# -- only the LATEST review per bot counts ------------------------------------
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t3)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t4)")"
|
||||
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
|
||||
|
||||
# -- an explicit human request outranks the bot rounds ------------------------
|
||||
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
|
||||
expect "human requested outranks bots" state:needs-human "$(decide_state)"
|
||||
REQUESTED=""
|
||||
|
||||
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)" \
|
||||
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
|
||||
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
|
||||
# ...and re-requesting the human hands it back to them
|
||||
REQUESTED="$HUMAN"
|
||||
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
|
||||
REQUESTED=""
|
||||
|
||||
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
|
||||
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
||||
# ...a stale human APPROVAL likewise needs a re-request for the new head
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
||||
# ...a HEAD-CURRENT human approval needs nothing more
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
||||
# ...and a live request suppresses re-requesting
|
||||
REQUESTED="$HUMAN"
|
||||
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
||||
REQUESTED=""
|
||||
|
||||
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
358
test/release.sh
358
test/release.sh
|
|
@ -1,22 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
# The release flow (#83), proven offline. Run: bash test/release.sh
|
||||
#
|
||||
# Three surfaces: the changelog-section extraction release.yml publishes
|
||||
# (.github/scripts/release-notes.sh, driven against fixtures AND the real
|
||||
# CHANGELOG.md so the header format cannot drift under it), the
|
||||
# latest-release tag resolution install.sh defaults to (the extracted
|
||||
# function, driven against a shim curl serving canned redirects), and the
|
||||
# three install channels — REAL install.sh runs against throwaway roots,
|
||||
# with the shim curl standing in for GitHub. Nothing here touches the
|
||||
# network; the same discipline as test/cli.sh. Deliberately no `set -e` —
|
||||
# the harness asserts on failing commands.
|
||||
# Box-specific release-channel coverage. Shared release/guard machinery lives
|
||||
# in heavy-duty/ceremony and is tested there; this file drives real install.sh.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0 FAIL=0
|
||||
|
||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
||||
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
||||
# contains want_substr.
|
||||
check() {
|
||||
local desc="$1" want="$2" substr="$3"; shift 3
|
||||
local out rc
|
||||
|
|
@ -34,271 +22,17 @@ check() {
|
|||
echo "ok: $desc"; PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
NOTES="$ROOT/.github/scripts/release-notes.sh"
|
||||
WORK="$(mktemp -d)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# release-notes.sh — the extraction, against a fixture changelog that carries
|
||||
# every boundary: an Unreleased section that must never leak into a release,
|
||||
# two adjacent versions, a version that prefixes another (0.7.0 vs
|
||||
# 0.7.0-rc1), and a stamped-but-empty section that must refuse.
|
||||
# ---------------------------------------------------------------------------
|
||||
check "release-notes: runnable bash" 0 "" bash -n "$NOTES"
|
||||
|
||||
FIX="$WORK/CHANGELOG.md"
|
||||
cat > "$FIX" <<'EOF'
|
||||
# Changelog
|
||||
|
||||
Intro prose that belongs to no section.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Not yet released** — must never appear in a release body.
|
||||
|
||||
## 0.7.0 — 2026-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- **The seven-oh entry** — prose for 0.7.0, and only 0.7.0.
|
||||
|
||||
## 0.7.0-rc1 — 2026-07-19
|
||||
|
||||
- **The rc entry** — must not ride along with 0.7.0.
|
||||
|
||||
## 0.6.0 — 2026-07-18
|
||||
|
||||
- **The six-oh entry** — the previous release's prose.
|
||||
|
||||
## 0.5.0 — 2026-07-15
|
||||
|
||||
EOF
|
||||
|
||||
check "extract: prints the asked-for version's prose" 0 "The seven-oh entry" bash "$NOTES" 0.7.0 "$FIX"
|
||||
check "extract: keeps the section's own subheaders" 0 "### Added" bash "$NOTES" 0.7.0 "$FIX"
|
||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
||||
check "extract: stops at the NEXT section" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "rc entry"' _ "$NOTES" "$FIX"
|
||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
||||
check "extract: never leaks Unreleased into a release" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "Not yet released"' _ "$NOTES" "$FIX"
|
||||
# shellcheck disable=SC2016 # $1/$2 expand in the child shell, by design
|
||||
check "extract: never prints the header itself" 1 "" bash -c 'bash "$1" 0.7.0 "$2" | grep -q "^## "' _ "$NOTES" "$FIX"
|
||||
check "extract: the version is matched WHOLE (rc1 is its own section)" \
|
||||
0 "The rc entry" bash "$NOTES" 0.7.0-rc1 "$FIX"
|
||||
check "extract: an adjacent older version still resolves" 0 "six-oh" bash "$NOTES" 0.6.0 "$FIX"
|
||||
check "extract: a missing version refuses by name" 1 "no section for '9.9.9'" bash "$NOTES" 9.9.9 "$FIX"
|
||||
check "extract: ...and names the ritual that was skipped" 1 "#83" bash "$NOTES" 9.9.9 "$FIX"
|
||||
check "extract: a stamped-but-EMPTY section refuses" 1 "no section for '0.5.0'" bash "$NOTES" 0.5.0 "$FIX"
|
||||
check "extract: no version argument is a usage error" 2 "usage:" bash "$NOTES"
|
||||
check "extract: a missing changelog refuses by path" 1 "no such file" bash "$NOTES" 1.0.0 "$WORK/nope.md"
|
||||
|
||||
# The REAL changelog: released sections must keep extracting, or release.yml
|
||||
# breaks the day it runs — this is the guard against header-format drift.
|
||||
check "extract: the real 0.6.0 section extracts" 0 "restricted tier" bash "$NOTES" 0.6.0 "$ROOT/CHANGELOG.md"
|
||||
check "extract: the real 0.5.0 section extracts" 0 "" bash "$NOTES" 0.5.0 "$ROOT/CHANGELOG.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# release.yml — a daemon-free run cannot push a tag, so the wiring is
|
||||
# grepped, fail-closed (the house discipline): the VERSION assertion, the
|
||||
# shared extraction script, and that the tag is verified before creation.
|
||||
# ---------------------------------------------------------------------------
|
||||
RY="$ROOT/.github/workflows/release.yml"
|
||||
check "release.yml: exists" 0 "" test -f "$RY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: asserts tag == VERSION before creating anything" 0 "" \
|
||||
grep -qF 'GITHUB_REF_NAME" != "$ver"' "$RY"
|
||||
check "release.yml: the mismatch creates NOTHING (exit 1)" 0 "" \
|
||||
grep -qF 'creating nothing' "$RY"
|
||||
check "release.yml: the body comes from the shared extraction script" 0 "" \
|
||||
grep -qF '.github/scripts/release-notes.sh' "$RY"
|
||||
check "release.yml: the release is bound to the pushed tag (--verify-tag)" 0 "" \
|
||||
grep -qF -- '--verify-tag' "$RY"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# release.yml, the merge door (#96) — merging the release-labeled PR IS the
|
||||
# release. Same daemon-free discipline: the gate, the four asserts, and the
|
||||
# same-job tag+publish are grep-pinned, fail-closed.
|
||||
# ---------------------------------------------------------------------------
|
||||
check "release.yml: the tag-push trigger is still present (manual fallback)" 0 "" \
|
||||
grep -qF 'tags: ["**"]' "$RY"
|
||||
# The merge door rides pushes to MAIN, not pull_request events: a fork PR
|
||||
# run gets a read-only GITHUB_TOKEN (permissions: cannot raise it), and
|
||||
# every ceremony PR this org merges is cross-repo from the bot fork — the
|
||||
# tag create would 403 after green asserts (#97 round 1). The label — the
|
||||
# operator's intent — is read via the API off the merge commit's PR.
|
||||
check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \
|
||||
grep -qF 'branches: [main]' "$RY"
|
||||
check "release.yml: the doors split on the ref — tags to the tag door..." 0 "" \
|
||||
grep -qF "startsWith(github.ref, 'refs/tags/')" "$RY"
|
||||
check "release.yml: ...main to the merge door" 0 "" \
|
||||
grep -qF "github.ref == 'refs/heads/main'" "$RY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: the release label is read via the API off the merge commit" 0 "" \
|
||||
grep -qF 'commits/$GITHUB_SHA/pulls' "$RY"
|
||||
check "release.yml: a transition without a labeled PR refuses" 0 "" \
|
||||
grep -qF "no merged, release-labeled PR is behind this commit" "$RY"
|
||||
check "release.yml: assert — VERSION at the merge commit is non--dev" 0 "" \
|
||||
grep -qF '*-dev)' "$RY"
|
||||
check "release.yml: assert — VERSION changed IN THIS PR (first parent vs merge)" 0 "" \
|
||||
grep -qF 'git show HEAD^1:VERSION' "$RY"
|
||||
check "release.yml: assert — no existing tag for the version" 0 "" \
|
||||
grep -qF 'git/ref/tags/' "$RY"
|
||||
check "release.yml: assert — no existing release for the version" 0 "" \
|
||||
grep -qF 'gh release view' "$RY"
|
||||
check "release.yml: BOTH doors extract notes via the shared script" 0 "2" \
|
||||
grep -cF 'bash .github/scripts/release-notes.sh' "$RY"
|
||||
check "release.yml: every failing assert creates NOTHING (both doors)" 0 "5" \
|
||||
grep -cF 'creating nothing' "$RY"
|
||||
check "release.yml: the merge door creates the tag ref via the API..." 0 "" \
|
||||
grep -qF 'ref=refs/tags/' "$RY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: ...at the MERGE commit" 0 "" \
|
||||
grep -qF 'sha=$MERGE_SHA' "$RY"
|
||||
check "release.yml: BOTH doors publish bound to an existing tag (--verify-tag)" 0 "2" \
|
||||
grep -cF -- '--verify-tag' "$RY"
|
||||
check "release.yml: tag + publish share one job (the anti-recursion shape)" 0 "" \
|
||||
grep -qF 'anti-recursion' "$RY"
|
||||
# The decide step tells the label's two meanings apart (LABELS.md gives
|
||||
# `release` to release-flow WORK as well as to the ceremony PR — the PR
|
||||
# that added the merge door included): work under the label no-ops GREEN —
|
||||
# in the -dev steady state and in the post-release window (bare, unchanged,
|
||||
# already released) — while every half-ceremony refuses. Pin each verdict
|
||||
# and the gating output.
|
||||
check "release.yml: decide — dev-tree work no-ops green (not a red run per infra PR)" 0 "" \
|
||||
grep -qF "release-flow work under the release label, not a ceremony" "$RY"
|
||||
check "release.yml: decide — a -dev endstate is always work (the bump PR no-ops green)" 0 "" \
|
||||
grep -qF "a dev tree is by definition not a release" "$RY"
|
||||
check "release.yml: decide — post-release-window work no-ops green" 0 "" \
|
||||
grep -qF "release-flow work merged in the post-release window" "$RY"
|
||||
check "release.yml: decide — bare, unchanged, never released refuses to guess" 0 "" \
|
||||
grep -qF "Refusing to guess" "$RY"
|
||||
check "release.yml: decide gates every later merge-door step on ceremony=yes" 0 "4" \
|
||||
grep -cF "if: steps.decide.outputs.ceremony == 'yes'" "$RY"
|
||||
# The release re-arms main itself: the post-release -dev bump is arithmetic,
|
||||
# not judgment, so it rides the same job — direct push, PR fallback.
|
||||
check "release.yml: the release bumps main to the next -dev itself" 0 "" \
|
||||
grep -qF "bump main to the next -dev" "$RY"
|
||||
check "release.yml: ...with a PR fallback when the direct push is refused" 0 "" \
|
||||
grep -qF "opening the bump PR instead" "$RY"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# changelog-armed.sh (#108) — the changelog has a heading for the NEXT entry.
|
||||
#
|
||||
# The drift this catches produces no conflict and no error: the ceremony
|
||||
# stamps '## Unreleased' away, and a PR authored before the release merges its
|
||||
# entry cleanly into the section that just shipped. box has no top-section
|
||||
# guard at all today — the two checks above pin only that the 0.6.0 and 0.5.0
|
||||
# sections still extract, which a disarmed main passes happily.
|
||||
#
|
||||
# BOTH states are constructed as real trees and the real script is run against
|
||||
# them, because the failure mode of the naive fix is precisely a state
|
||||
# mismatch: an unconditional '## Unreleased' requirement is green on main and
|
||||
# false on the ceremony PR's own tree, which is why rig#44 and
|
||||
# heavy-duty/cast#108 both had to revert one. A test that only drives the
|
||||
# -dev state would have shipped that bug again.
|
||||
# ---------------------------------------------------------------------------
|
||||
ARMED="$ROOT/.github/scripts/changelog-armed.sh"
|
||||
check "changelog-armed: runnable bash" 0 "" bash -n "$ARMED"
|
||||
|
||||
# tree <dir> <version> <changelog-body...> — a two-file tree to run against
|
||||
tree() {
|
||||
local d="$WORK/$1" v="$2"; shift 2
|
||||
mkdir -p "$d"
|
||||
printf '%s\n' "$v" > "$d/VERSION"
|
||||
{ echo "# Changelog"; echo; printf '%s\n' "$@"; } > "$d/CHANGELOG.md"
|
||||
echo "$d"
|
||||
}
|
||||
armed() { bash "$ARMED" "$1/CHANGELOG.md" "$1/VERSION"; }
|
||||
|
||||
# --- the -dev steady state: armed is the only legal shape ------------------
|
||||
T="$(tree dev-armed 0.7.1-dev '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a -dev tree with '## Unreleased' on top passes" 0 "agrees" armed "$T"
|
||||
T="$(tree dev-disarmed 0.7.1-dev '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a -dev tree WITHOUT it fails — the #108 drift, caught" 1 "MUST carry" armed "$T"
|
||||
check "armed: ...and the failure says how to fix it (re-arm)" 1 "re-arm" armed "$T"
|
||||
check "armed: ...naming the issue and its origin" 1 "heavy-duty/rig#66" armed "$T"
|
||||
|
||||
# --- the ceremony PR: bare VERSION, BOTH arrangements legal ----------------
|
||||
# This is the pair that the reverted guards got wrong. Neither may fail, or
|
||||
# the release PR cannot go green and the ceremony is unshippable.
|
||||
T="$(tree rel-stamped 0.7.1 '## 0.7.1 — 2026-07-19' '' '- **This release**')"
|
||||
check "armed: a bare VERSION with its OWN stamped section on top passes" 0 "agrees" armed "$T"
|
||||
T="$(tree rel-rearmed 0.7.1 '## Unreleased' '' '## 0.7.1 — 2026-07-19' '' '- **This release**')"
|
||||
check "armed: ...and so does the RE-ARMED ceremony tree (the shape #108 asks for)" \
|
||||
0 "agrees" armed "$T"
|
||||
# The one bare-VERSION arrangement that is wrong: a stamp naming another
|
||||
# version. release.yml would publish a body that is not this release's.
|
||||
T="$(tree rel-wrong 0.7.1 '## 0.7.0 — 2026-07-19' '' '- **Some other release**')"
|
||||
check "armed: a bare VERSION under someone ELSE's stamped section fails" 1 "wrong number" armed "$T"
|
||||
|
||||
# --- the HALF-ceremony: the gap the two bare-VERSION clauses leave ---------
|
||||
# VERSION bumped to the release, '## Unreleased' still populated on top, and
|
||||
# the section for that version never stamped at all. The wrong-number test
|
||||
# above is false on its FIRST clause here and short-circuits, so before
|
||||
# heavy-duty/rig#67's rule this tree passed the guard and was refused instead
|
||||
# by release.yml — at publish time, after the merge, on main, with the release
|
||||
# already half-shipped. Caught here one step earlier, by running the same
|
||||
# extraction release.yml runs.
|
||||
T="$(tree rel-half 0.8.0 '## Unreleased' '' '- **A pending entry**' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a bare VERSION whose section was never stamped fails (half-ceremony)" \
|
||||
1 "no non-empty section" armed "$T"
|
||||
check "armed: ...and names the stamp as MISSING, not misnumbered" \
|
||||
1 "MISSING, not misnumbered" armed "$T"
|
||||
# The wording is the whole point of the separate branch: an operator sent to
|
||||
# fix a version number that is already correct will not find the real problem.
|
||||
not_wrong_number() { ! armed "$1" 2>&1 | grep -qF 'wrong number'; }
|
||||
check "armed: ...and not as the wrong-number case, which has a different fix" \
|
||||
0 "" not_wrong_number "$T"
|
||||
# A section that exists but carries no prose is the same failure: release.yml
|
||||
# would publish an empty body, which is what release-notes.sh already refuses.
|
||||
T="$(tree rel-empty 0.7.1 '## 0.7.1 — 2026-07-19' '' '## 0.7.0 — 2026-07-19' '' '- **Shipped**')"
|
||||
check "armed: a bare VERSION whose section is stamped but EMPTY fails" \
|
||||
1 "no non-empty section" armed "$T"
|
||||
|
||||
# --- degenerate trees refuse rather than pass by accident ------------------
|
||||
T="$(tree no-sections 0.7.1-dev 'Prose and no headings at all.')"
|
||||
check "armed: a changelog with no '## ' section at all fails" 1 "no '## ' section at all" armed "$T"
|
||||
check "armed: a missing changelog refuses by path" 1 "no such file" \
|
||||
bash "$ARMED" "$WORK/nope.md" "$ROOT/VERSION"
|
||||
check "armed: a missing VERSION refuses by path" 1 "no such file" \
|
||||
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/nope-version"
|
||||
mkdir -p "$WORK/empty-ver"; : > "$WORK/empty-ver/VERSION"
|
||||
check "armed: an empty VERSION refuses" 1 "is empty" \
|
||||
bash "$ARMED" "$ROOT/CHANGELOG.md" "$WORK/empty-ver/VERSION"
|
||||
|
||||
# --- and the tree under test, which is the assertion that actually fires ---
|
||||
check "armed: THIS tree's VERSION and CHANGELOG.md agree" 0 "agrees" \
|
||||
bash "$ARMED" "$ROOT/CHANGELOG.md" "$ROOT/VERSION"
|
||||
|
||||
# The guard is only a guard if CI runs it, and the ceremony is only re-armed
|
||||
# if the ceremony step says so. Fail-closed pins on both, since a guard nobody
|
||||
# invokes and a step nobody wrote are the two ways this reverts silently.
|
||||
check "ci.yml: runs the changelog-armed guard" 0 "" \
|
||||
grep -qF 'changelog-armed.sh' "$ROOT/.github/workflows/ci.yml"
|
||||
check "CONTRIBUTING: the ceremony re-arms '## Unreleased' after stamping" 0 "" \
|
||||
grep -qF 'Stamping is two edits, not one' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING: ...and names the guard that enforces it" 0 "" \
|
||||
grep -qF 'changelog-armed.sh' "$ROOT/CONTRIBUTING.md"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# latest_release_tag — extracted from install.sh (the source-the-pure-function
|
||||
# trick) and driven against a shim curl. The shim serves the ONE seam the
|
||||
# function uses: -w '%{redirect_url}' on the releases/latest probe.
|
||||
# ---------------------------------------------------------------------------
|
||||
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
|
||||
cat > "$SHIMDIR/curl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# Fake curl for the release drills: answers the releases/latest probe with
|
||||
# $FAKE_REDIRECT on stdout (the -w '%{redirect_url}' seam) — or fails with
|
||||
# $FAKE_CURL_RC (network down) — and serves downloads (-o <file>) by copying
|
||||
# $FAKE_TARBALL when the URL is $FAKE_SERVE_URL, else exit 22 (curl's own
|
||||
# 404-under--f code). Every URL is appended to $FAKE_CURL_LOG so a test can
|
||||
# assert exactly what was asked for, and in what order.
|
||||
url="" out=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o|--output) out="$2"; shift 2 ;;
|
||||
-o|--output) out="$2"; shift 2 ;;
|
||||
-w|--write-out) shift 2 ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
-*) shift ;;
|
||||
*) url="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[ -n "${FAKE_CURL_LOG:-}" ] && printf '%s\n' "$url" >> "$FAKE_CURL_LOG"
|
||||
|
|
@ -317,105 +51,77 @@ chmod +x "$SHIMDIR/curl"
|
|||
|
||||
TAGFN="$(mktemp)"
|
||||
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
|
||||
check "latest_release_tag: extracted from install.sh (guards the awk)" 0 "releases/latest" cat "$TAGFN"
|
||||
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$TAGFN"
|
||||
check "latest_release_tag: extracted from install.sh" 0 "releases/latest" cat "$TAGFN"
|
||||
check "latest_release_tag: extracted function is valid bash" 0 "" bash -n "$TAGFN"
|
||||
|
||||
ltag() { # ltag <redirect_url> [curl_rc]
|
||||
ltag() {
|
||||
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
|
||||
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
|
||||
}
|
||||
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
|
||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0"
|
||||
check "resolve: a -dev-style tag survives verbatim" 0 "0.7.0-rc1" \
|
||||
check "resolve: a pre-release tag survives verbatim" 0 "0.7.0-rc1" \
|
||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.7.0-rc1"
|
||||
check "resolve: a repo with NO releases (redirect to /releases) fails" 1 "" \
|
||||
check "resolve: a repo with no releases fails" 1 "" \
|
||||
ltag "https://github.com/heavy-duty/box/releases"
|
||||
check "resolve: no redirect at all fails" 1 "" ltag ""
|
||||
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \
|
||||
check "resolve: no redirect fails" 1 "" ltag ""
|
||||
check "resolve: a curl failure fails" 1 "" \
|
||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
|
||||
rm -f "$TAGFN"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The three channels, driven through REAL install.sh runs (#83): default =
|
||||
# latest release, BOX_REF=<tag> = pinned, BOX_REF=<branch> = dev. The shim
|
||||
# curl serves a fabricated release tarball shaped exactly like GitHub's (one
|
||||
# top-level directory), and its log proves WHICH URLs the installer asked
|
||||
# for. FAKE_TARBALL carries VERSION 9.9.9 so nothing collides with the tree
|
||||
# under test.
|
||||
# ---------------------------------------------------------------------------
|
||||
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
|
||||
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
|
||||
echo "9.9.9" > "$SRC/VERSION"
|
||||
printf '9.9.9\n' > "$SRC/VERSION"
|
||||
tar -C "$WORK" -czf "$WORK/gh.tar.gz" box-9.9.9
|
||||
|
||||
ninst() { # ninst <box_home> <box_bin> [VAR=val ...] — install.sh, shim network
|
||||
ninst() {
|
||||
local h="$1" b="$2"; shift 2
|
||||
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
|
||||
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
||||
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
|
||||
}
|
||||
|
||||
# --- channel 1: the default is the latest RELEASE ---------------------------
|
||||
H1="$WORK/h1"; B1="$WORK/b1"; L1="$WORK/c1.log"
|
||||
check "default channel: resolves and installs the latest release" 0 "latest release: 9.9.9" \
|
||||
check "default channel: installs the latest release" 0 "latest release: 9.9.9" \
|
||||
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
|
||||
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
|
||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
||||
check "default channel: the download is the TAG tarball" 0 "" \
|
||||
check "default channel: downloads the tag tarball" 0 "" \
|
||||
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
|
||||
check "default channel: it never asked for a branch" 1 "" \
|
||||
grep -q "refs/heads" "$L1"
|
||||
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
|
||||
check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1"
|
||||
check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \
|
||||
cat "$H1/versions/9.9.9/INSTALLED_FROM"
|
||||
check "default channel: the install answers through the chain" 0 "box 9.9.9" \
|
||||
check "default channel: installed binary answers" 0 "box 9.9.9" \
|
||||
env HOME="$FAKEHOME" "$B1/box" --version
|
||||
|
||||
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
|
||||
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
|
||||
check "pinned channel: BOX_REF=<tag> installs that tag" 0 "done" \
|
||||
check "pinned channel: installs the requested tag" 0 "done" \
|
||||
ninst "$H2" "$B2" BOX_REF=9.9.9 FAKE_CURL_LOG="$L2" \
|
||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/tags/9.9.9.tar.gz"
|
||||
check "pinned channel: no releases/latest probe (a pin resolves nothing)" 1 "" \
|
||||
check "pinned channel: skips latest-release resolution" 1 "" \
|
||||
grep -q "releases/latest" "$L2"
|
||||
|
||||
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
|
||||
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
|
||||
check "dev channel: BOX_REF=main falls back tag -> branch" 0 "trying it as a branch" \
|
||||
check "dev channel: falls back from tag to branch" 0 "trying it as a branch" \
|
||||
ninst "$H3" "$B3" BOX_REF=main FAKE_CURL_LOG="$L3" \
|
||||
FAKE_SERVE_URL="https://github.com/heavy-duty/box/archive/refs/heads/main.tar.gz"
|
||||
check "dev channel: the tag was tried FIRST" 0 "refs/tags/main.tar.gz" \
|
||||
head -1 "$L3"
|
||||
check "dev channel: then the branch" 0 "" \
|
||||
check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3"
|
||||
check "dev channel: then downloads the branch" 0 "" \
|
||||
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
|
||||
|
||||
# --- the failure is LOUD, never a silent fall-through to main ---------------
|
||||
H4="$WORK/h4"; B4="$WORK/b4"; L4="$WORK/c4.log"
|
||||
check "resolution failure: REFUSES, naming the probe URL" 1 "could not resolve the latest release" \
|
||||
check "resolution failure: names the latest-release probe" 1 "could not resolve the latest release" \
|
||||
ninst "$H4" "$B4" FAKE_CURL_RC=6 FAKE_CURL_LOG="$L4"
|
||||
check "resolution failure: ...and the way out (BOX_REF)" 1 "BOX_REF" \
|
||||
check "resolution failure: names BOX_REF as the override" 1 "BOX_REF" \
|
||||
ninst "$H4" "$B4" FAKE_CURL_RC=6
|
||||
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \
|
||||
grep -q "archive/" "$L4"
|
||||
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions"
|
||||
check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \
|
||||
check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4"
|
||||
check "resolution failure: installs nothing" 1 "" test -e "$H4/versions"
|
||||
check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \
|
||||
ninst "$H4" "$B4" BOX_REF=no-such-ref
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The -dev convention (#83): main's VERSION carries -dev between releases, so
|
||||
# a dev install lands beside releases in versions/ instead of impersonating
|
||||
# one — and the docs keep the promises this PR makes.
|
||||
# ---------------------------------------------------------------------------
|
||||
check "CONTRIBUTING documents the post-release -dev bump" 0 "" \
|
||||
grep -q -- '-dev' "$ROOT/CONTRIBUTING.md"
|
||||
check "CONTRIBUTING documents the release ritual (tag == VERSION)" 0 "" \
|
||||
grep -qi 'release' "$ROOT/CONTRIBUTING.md"
|
||||
check "README documents the default (latest release) channel" 0 "" \
|
||||
grep -qF 'latest release' "$ROOT/README.md"
|
||||
check "README documents the pinned channel" 0 "" \
|
||||
grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
|
||||
check "README documents the dev channel" 0 "" \
|
||||
grep -qF 'BOX_REF=main' "$ROOT/README.md"
|
||||
check "README documents the latest-release channel" 0 "" grep -qF 'latest release' "$ROOT/README.md"
|
||||
check "README documents the pinned channel" 0 "" grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
|
||||
check "README documents the dev channel" 0 "" grep -qF 'BOX_REF=main' "$ROOT/README.md"
|
||||
|
||||
echo "---"
|
||||
echo "$PASS passed, $FAIL failed"
|
||||
|
|
|
|||
Loading…
Reference in a new issue