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
|
# 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.
|
# only: sync-labels stays off in labels.yml, so a hand-applied scope survives.
|
||||||
"scope:cli":
|
"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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: shellcheck
|
||||||
# -x follows `source`/`.` directives; box has no lib split today, but the
|
# -x follows `source`/`.` directives; box has no lib split today, but the
|
||||||
# flag costs nothing and keeps the invocation identical to rig's.
|
# flag costs nothing and keeps the invocation identical to rig's.
|
||||||
# globstar so a script in a new subdirectory is linted without anyone
|
# globstar so a script in a new subdirectory is linted without anyone
|
||||||
# remembering to edit this list; bin/* covers the extensionless entrypoint
|
# 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.
|
# (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: |
|
run: |
|
||||||
shopt -s globstar
|
shopt -s globstar dotglob
|
||||||
files=(bin/* **/*.sh)
|
files=(bin/* **/*.sh)
|
||||||
printf 'shellcheck: %s\n' "${files[@]}"
|
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[@]}"
|
shellcheck -x "${files[@]}"
|
||||||
- name: cli tests
|
- name: cli tests
|
||||||
run: bash test/cli.sh
|
run: bash test/cli.sh
|
||||||
- name: labels state-machine tests
|
|
||||||
run: bash test/labels-reconcile.sh
|
|
||||||
- name: release-flow tests
|
- name: release-flow tests
|
||||||
run: bash test/release.sh
|
run: bash test/release.sh
|
||||||
# The changelog is ARMED for the next entry (#108). Its own step rather
|
# 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
|
# tree, not about the release machinery, so when it goes red the log
|
||||||
# says which check found the drift without anyone reading a suite.
|
# says which check found the drift without anyone reading a suite.
|
||||||
- name: changelog is armed for the next entry
|
- 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
|
# 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
|
# 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
|
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:
|
on:
|
||||||
schedule:
|
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||||
- cron: "*/15 * * * *"
|
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||||
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
|
||||||
pull_request_target:
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
scope:
|
labels:
|
||||||
if: github.event_name == 'pull_request_target'
|
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.1.0
|
||||||
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
|
|
||||||
|
|
|
||||||
217
.github/workflows/release.yml
vendored
217
.github/workflows/release.yml
vendored
|
|
@ -1,213 +1,18 @@
|
||||||
name: release
|
name: release
|
||||||
# The release publisher — two doors into the same act (#83, #96):
|
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||||
#
|
|
||||||
# * 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.
|
|
||||||
on:
|
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:
|
push:
|
||||||
# The merge door rides pushes to MAIN, not pull_request events, for one
|
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||||
# load-bearing reason the first review round caught (#97): a workflow
|
# never be skipped by a shape filter that didn't match
|
||||||
# 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.
|
|
||||||
branches: [main]
|
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:
|
permissions:
|
||||||
contents: write # create the tag ref + gh release create + the bump push
|
contents: write # tag ref create + release create + the bump push
|
||||||
# Two consumers (a declared permissions: block zeroes every unspecified
|
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||||
# scope): the decide step's label read (commits/<sha>/pulls) and the bump
|
issues: write # --label on that fallback PR rides the issues API
|
||||||
# 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
|
|
||||||
|
|
||||||
jobs:
|
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:
|
release:
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.1.0
|
||||||
runs-on: ubuntu-latest
|
with:
|
||||||
steps:
|
version-source: file
|
||||||
- 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"
|
|
||||||
|
|
|
||||||
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
|
## 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
|
## 0.8.0 — 2026-07-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Merging the release PR IS the release — and the release re-arms main
|
- Merging the release PR is the release, and the release re-arms main itself
|
||||||
itself** (#96) — the 0.7.0 ceremony ended in an absence: the release PR
|
(#96)
|
||||||
merged with four approvals and nothing happened, correctly, because
|
|
||||||
publishing hung off a separate, manual, silent-when-forgotten tag push —
|
|
||||||
a failure shape with no error and no red X. The ship decision already
|
|
||||||
lives in the release PR (the one PR whose whole diff is "the version
|
|
||||||
leaves `-dev`"), so `release.yml` now fires on pushes to main
|
|
||||||
(fork-sourced ceremony PRs get a read-only token on `pull_request`
|
|
||||||
events), reading the transition from the push itself: `event.before` to
|
|
||||||
the pushed head. A decide step answers four states — release-flow *work*
|
|
||||||
merged under the `release` label (`-dev` endstates, the post-release
|
|
||||||
window) no-ops green with a NOTICE; the two genuinely ambiguous bare
|
|
||||||
states refuse loudly; a true transition then requires a merged,
|
|
||||||
`release`-labeled PR behind the commit (read via the API — the label is
|
|
||||||
the operator's declared intent) before anything is created. Then, in the
|
|
||||||
same job, it tags the merge commit via the API, publishes — and bumps
|
|
||||||
main to `X.Y.(Z+1)-dev` itself, direct push with a loud open-a-PR
|
|
||||||
fallback, so no follow-up bump PR exists on the paved road. Same-job on
|
|
||||||
purpose: a `GITHUB_TOKEN`-created tag triggers no workflows, which is
|
|
||||||
also what makes double-publish impossible. The tag-push path stays
|
|
||||||
unchanged as the documented manual fallback and backfill (it shipped
|
|
||||||
0.7.0 itself). `test/release.sh` grep-pins the gate, every decide
|
|
||||||
verdict, the single `on.push` key, and the same-job tag+publish+re-arm
|
|
||||||
in the same daemon-free, fail-closed style.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let
|
- The release ceremony re-arms `CHANGELOG.md`, and CI refuses to let main sit
|
||||||
`main` sit disarmed** (#108) — the ceremony stamps `## Unreleased` into
|
disarmed (#108, heavy-duty/rig#67)
|
||||||
`## X.Y.Z — DATE` by hand, and nothing put the heading back, so `main`
|
- Ctrl-D at a confirmation prompt aborts out loud instead of exiting in
|
||||||
sat with no `## Unreleased` from the release until the next PR that
|
silence (#111)
|
||||||
happened to re-create one. A PR authored *before* the release wrote its
|
- `box restore` asks before it destroys, in the row's own words rather than
|
||||||
entry under `## Unreleased`; with that heading gone, git lands the entry
|
`rm`'s (#105)
|
||||||
under whatever now occupies the position — **the section that just
|
- `box-firewall` could hand a UFW host the no-UFW firewall, ~2% of the time
|
||||||
shipped** — and it merges **cleanly**. No conflict, no error, no red X:
|
(#102)
|
||||||
the one signal an author would trust is absent exactly when the outcome
|
- A missing firewall log now diagnoses itself (#102)
|
||||||
is wrong, and the changelog credits a released version with a change it
|
- `box grant` provisions an `incus-admin` member instead of refusing them
|
||||||
does not contain until a human reads the file. Confirmed in the sibling
|
(#99)
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
## 0.7.0 — 2026-07-19
|
## 0.7.0 — 2026-07-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **The installer defaults to the latest release, and releases publish
|
- The installer defaults to the latest release, and releases publish
|
||||||
themselves** (#83) — `curl | bash` used to hand out whatever `main` was at
|
themselves (#83)
|
||||||
that second: the 0.6.0 release was a bookmark, not a package, and two
|
- `setup-host` auto-picks a free subnet — nested box-in-box with zero flags
|
||||||
operators "on 0.6.0" could be running different trees. `install.sh` now
|
(#80)
|
||||||
resolves the latest release tag by following GitHub's `releases/latest`
|
- `setup-host` refuses a claimed subnet, and `BOX_SUBNET` picks another (#80)
|
||||||
redirect (one HEAD request — no API, no token, no rate-limit pain) and
|
- `box doctor` knows the #80 signature: a gateway held as a local address, and
|
||||||
downloads that tag's tarball; a failed resolution refuses loudly, naming
|
duplicate connected routes for the uplink subnet
|
||||||
`BOX_REF` as the way out — it never hangs and never silently falls back to
|
- The `staging` template — a server-class, creds-free seed (#81)
|
||||||
`main`. A set `BOX_REF` is tried as a tag first, then as a branch, so one
|
- The `BOX_BOOTSTRAP_ROLE` template key, auto-run at mint (#81)
|
||||||
knob yields three channels: default = latest release, `BOX_REF=0.6.0` =
|
- The rig pin point: `RIG_REPO` / `RIG_REF` (#81)
|
||||||
pinned, `BOX_REF=main` = dev. A new `release.yml` (on a bare `X.Y.Z` tag
|
- Server-posture template keys `BOX_REQUIRE_VM` and `BOX_AUTOSTART` (#81)
|
||||||
push — the `0.6.0` tag set the no-`v` precedent) asserts the tag names the
|
- The template test suite discovers `templates/*/` instead of hardcoding the
|
||||||
tree's own `VERSION` (a mismatch fails loudly and creates nothing) and
|
list (#81)
|
||||||
publishes the GitHub release with that version's `CHANGELOG.md` section as
|
- `box export` / `box import` — a box's state that survives the box and the
|
||||||
the body (`.github/scripts/release-notes.sh` — the curated prose, not the
|
host (#70)
|
||||||
generated PR list; no assets, the source tarball for the tag IS the
|
- Versioned installs at `<root>/versions/<v>`, with `box versions` and
|
||||||
package). And `main`'s `VERSION` now carries `-dev` between releases
|
`box use` (#66)
|
||||||
(this PR: `0.6.1-dev`): the versioned layout names install trees after
|
- A real uninstall: `box uninstall [<version>] [--all] [--purge-host]`, ending
|
||||||
`VERSION`, so a `main` install without the bump would land in
|
in an absence assert
|
||||||
`versions/0.6.0` and impersonate the released tree. `test/release.sh`
|
- `BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree, and CI's
|
||||||
drives all of it offline — the extraction against fixtures and the real
|
rehearsal drills the uninstall to zero residue
|
||||||
changelog, the resolution and every channel against a shim curl.
|
- `test/cli.sh` drives real installs against throwaway roots and a fake incus
|
||||||
- **`setup-host` auto-picks a free subnet — nested box-in-box with zero
|
(154 checks)
|
||||||
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).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Thin templates — box mints, rig converges** (#81, companion rig#31) —
|
- Thin templates — box mints a creds-free seed, rig's bootstrap roles converge
|
||||||
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the
|
the tenant content (#81, heavy-duty/rig#31)
|
||||||
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.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **A wedged `incus launch` fails loudly, not forever — the mint's launch
|
- A wedged `incus launch` fails loudly, not forever: the launch phase is
|
||||||
phase is narrated and time-boxed** (#93) — twice in the 2026-07-19
|
narrated and time-boxed (#93)
|
||||||
release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached),
|
- UFW's gateway carve-out converges with the bridge, and the doctor can see it
|
||||||
the child `incus launch` under `box new` hung with *no server-side
|
(#86)
|
||||||
operation*: `incus operation list` empty, the instance never created, the
|
- The boot-time gateway fallback is gone — an unaddressed bridge leaves the
|
||||||
daemon journal quiet — one wedge ran 56 minutes before being killed by
|
persisted UFW rules alone (#86)
|
||||||
hand, and an immediate retry of the identical command succeeded in
|
- `revoke --purge` re-checks the incus-user state, and stats it through
|
||||||
minutes, both times. `box new` inherited that as an indefinite silent
|
`$SUDO`
|
||||||
hang, indistinguishable from a cold mint working. It now prints
|
- A wedged `$BINDIR/box` no longer blocks installing
|
||||||
`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.
|
|
||||||
|
|
||||||
## 0.6.0 — 2026-07-18
|
## 0.6.0 — 2026-07-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **The restricted tier: multi-user hosts** (#74, redesigning #72) — an admin
|
- The restricted tier: `box grant` / `box revoke` give a user their own boxes
|
||||||
runs `box grant <user>` and that user gets their own boxes on the same
|
on the shared hardened `boxnet` (#74)
|
||||||
hardened `boxnet`, seeing nobody else's; `box revoke <user>` takes it back
|
- CI runs the multi-user rehearsal on a real Incus
|
||||||
(`--purge` deletes their world, and asserts the absence). The tier rides
|
- Global / root install — one world-readable tree at `/opt/box` (#71)
|
||||||
incus-user, whose defaults miss box's contract three measured ways (Debian
|
- CI and a test suite: `.github/workflows/ci.yml` and `test/cli.sh`
|
||||||
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".
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **`box restore` never worked against Incus 6** — the command table
|
- `box restore` never worked against Incus 6 — it dispatched `incus restore`,
|
||||||
dispatched `incus restore`, a subcommand that does not exist (Incus 6
|
which does not exist
|
||||||
spells it `incus snapshot restore`), so every restore died on "unknown
|
- `box tmux` works on every template — tmux is in each template's package list
|
||||||
command". Found by #74's rehearsal exercising the full lifecycle as a
|
(#65)
|
||||||
restricted user; fixed for every tier, and the rehearsal + a grep-guard in
|
- `box setup-host` finishes in one run, re-execing itself under
|
||||||
`test/cli.sh` now hold it.
|
`sg incus-admin` (#63)
|
||||||
- **`box tmux` works on every template** (#65) — `box tmux` runs
|
- `setup-host` works as root, with or without `sudo`
|
||||||
`tmux new-session` _inside_ the box, but the templates did not install tmux, so
|
- `setup-host` grants `incus-admin` to the human, not to root
|
||||||
it failed with `tmux: command not found`. `tmux` is now in each template's
|
- `box-firewall.service` reports its state honestly, via `RemainAfterExit=yes`
|
||||||
cloud-init package list (`blank`/`claude`/`codex`/`grok`).
|
- `setup-host`'s apt calls can no longer hang on the dpkg lock
|
||||||
|
|
||||||
- **`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.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **`drill.sh` proves the new contract instead of masking it** — the drill ran
|
- `drill.sh` asserts the post-install stack instead of building it itself
|
||||||
`setup-host` itself right after installing, so the stack existed by its own
|
- `install.sh` asks, sets up the host, and no-ops on re-run (#64)
|
||||||
hand and a run passed identically whether or not `install.sh` had done a
|
- `install.sh` never overwrites an existing install
|
||||||
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.
|
|
||||||
|
|
||||||
## 0.5.0 — 2026-07-15
|
## 0.5.0 — 2026-07-15
|
||||||
|
|
||||||
|
|
@ -658,61 +170,27 @@ installer retires — but nothing current carries the old name.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes,
|
- `codex` and `grok` templates
|
||||||
creds-free like every template. The template mechanic (image + user +
|
- `box expose <box> <port> [<host-port>]` — a loopback-only door to a port
|
||||||
resources, never a network or a `security.*` key) now has three tenants
|
inside a box
|
||||||
beside `blank`, and the drill mints all of them cold.
|
- Inline resource overrides on `new`: `--cpu`, `--memory`, `--disk` (#57)
|
||||||
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only
|
- Host lifecycle as verbs: `box setup-host`, `box teardown-host`,
|
||||||
door to a port inside a box, for seeing a dev server in your browser. The
|
`box migrate-host`
|
||||||
listen side is always the host's `127.0.0.1` (no flag to widen it), the door
|
- The `.box/` recipe convention, renamed from `.claudebox/` (both spellings
|
||||||
is per-port, `--list`/`--remove` manage it, and `box info` shows open
|
read)
|
||||||
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).
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **VM mints no longer hang at GRUB** — Incus defaults VMs to Secure Boot on,
|
- VM mints no longer hang at GRUB — boxes launch with
|
||||||
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
|
`security.secureboot=false`
|
||||||
shim signature" forever. Boxes now launch with `security.secureboot=false`;
|
- `box expose` actually delivers packets
|
||||||
the VM boundary, not boot attestation, is the box threat model.
|
- Firewall rules converge on upgrade instead of pinning a host to the release
|
||||||
- **`box expose` actually delivers packets** — a trilogy of drill-found
|
that first ran there
|
||||||
absences: the NAT proxy needs the box's boxnet lease pinned as a static
|
- Failed mints tell you why
|
||||||
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not
|
- `grok` installs the binary it actually ships
|
||||||
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).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Debrand complete** — env vars, install dir, docs, template descriptions
|
- Debrand complete — env vars, install dir, docs, template descriptions and
|
||||||
and the README all say `box`; the install URL is
|
the README all say `box`; the install URL is `heavy-duty/box`
|
||||||
`heavy-duty/box` (GitHub redirects the old one, `BOX_REPO` overrides).
|
- The drill grew from 47 to 84 checks
|
||||||
- **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).
|
|
||||||
|
|
|
||||||
196
CONTRIBUTING.md
196
CONTRIBUTING.md
|
|
@ -1,143 +1,83 @@
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
How change lands in this repo. The short version: PRs are born as drafts,
|
This repository is governed by
|
||||||
three reviewer bots take the first rounds, a human takes the last word — and
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Agents read
|
||||||
labels tell you where everything is without opening anything.
|
[`.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
|
## Review panel
|
||||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`),
|
|
||||||
and include a `CHANGELOG.md` entry under `## Unreleased` when the change
|
The review panel is:
|
||||||
deserves one.
|
|
||||||
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
- `claude-bot-andresmgsl`
|
||||||
bots on purpose.
|
- `codex-bot-andresmgsl`
|
||||||
3. **When it's ready**: mark ready-for-review and request all three bots —
|
- `grok-bot-andresmgsl`
|
||||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
- `kimi-bot-andresmgsl`
|
||||||
They poll roughly every 15 minutes.
|
|
||||||
4. **Rounds are answered whole.** Wait until all three have reviewed, then
|
Every PR needs a current-head verdict from the whole panel minus its author.
|
||||||
answer the entire round in a **single reply**, push the fixes, and
|
`dan-claude-bot` is triage-only and is never a reviewer. Draft PRs remain
|
||||||
re-request the bots that didn't approve. Prefer verification over
|
invisible to the panel; when ready, request every eligible reviewer.
|
||||||
argument: a test settles what a comment thread can't.
|
|
||||||
5. **Reviews end in a verdict.** A reviewer — bot or human — either
|
## Code and verification
|
||||||
**approves** or **requests changes**, never a bare comment. A
|
|
||||||
comment-only review is a non-verdict: it doesn't say whether the round
|
- Bash executables use `set -euo pipefail`; test harnesses use `set -u`
|
||||||
passed, and the state machine (and anyone scanning the board) has to
|
because they assert failing commands.
|
||||||
guess. The verdict carries *blockingness only*, the body carries the
|
- Keep shellcheck clean. Run `bash test/cli.sh` and `bash test/release.sh`;
|
||||||
feedback: non-blocking nits ride an **approval** and the author addresses
|
CI also runs the Incus multi-user rehearsal.
|
||||||
them at their discretion; anything blocking — including a question that
|
- Match whole versions: `0.7.0` must never match `0.7.0-rc1`.
|
||||||
gates the verdict — is **request changes**, saying what unblocks it. The
|
- Comments preserve the incident that bought a rule, including its issue
|
||||||
reconciler treats a comment-only review as not-approved, so commenting
|
number.
|
||||||
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
|
## Changelog
|
||||||
judgment belongs to the **author** — escalate by requesting the
|
|
||||||
maintainer's review (step 6), and the reconciler flips the label on that
|
Every behavior-changing PR adds one concise line under `## Unreleased`,
|
||||||
request, because an explicit request is a fact it can trust.
|
above the shipped heading below it. Cite the issue or PR. Never replace or
|
||||||
6. **When the round passes, the author hands the PR to the maintainer** by
|
duplicate a shipped heading; the shared armed and monotonic guards enforce
|
||||||
requesting their review — that request is what flips `state:needs-human`.
|
both halves of this rule.
|
||||||
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.
|
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
A release is a PR, and merging it ships it
|
The release ceremony, merge and tag doors, version stamps, guard semantics,
|
||||||
([#96](https://github.com/heavy-duty/box/issues/96), building on
|
and recovery paths are defined by
|
||||||
[#83](https://github.com/heavy-duty/box/issues/83)):
|
[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`
|
Box uses the `file` version backend and has no artifact hook: for this
|
||||||
from `X.Y.Z-dev` and stamps the `## Unreleased` section with version +
|
pure-Bash tree, GitHub’s source tarball for the tag is the package, and
|
||||||
date (feature PRs land their changelog entry as part of the PR, so the
|
`install.sh` downloads exactly that. `VERSION`, `CHANGELOG.md`, and
|
||||||
section is already written).
|
`drills/<version>.md` remain box-owned release inputs.
|
||||||
|
|
||||||
**Stamping is two edits, not one — the second is re-arming.** After
|
### What a box drill proves
|
||||||
rewriting `## Unreleased` into `## X.Y.Z — DATE`, put an **empty
|
|
||||||
`## Unreleased` back at the top**, immediately above the section you just
|
|
||||||
stamped:
|
|
||||||
|
|
||||||
```markdown
|
The box drill is the 85-probe VM isolation contract: it exercises the trust
|
||||||
## Unreleased
|
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.
|
A known gap remains from box#81: released box templates still default
|
||||||
Between the stamp and the next re-creation of that heading, `main` has no
|
`RIG_REF` to `main`, so a later mint may consume a rig revision other than
|
||||||
`## Unreleased`. A PR authored *before* the release wrote its entry under
|
the one drilled. This conversion does not change that behavior or claim the
|
||||||
that heading; with the heading gone, git lands the entry under whatever
|
gap is closed.
|
||||||
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)).
|
|
||||||
|
|
||||||
CI enforces the arming rule with
|
## Scope labels
|
||||||
[.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.
|
|
||||||
|
|
||||||
This PR is where the release ritual hangs:
|
- `scope:cli` — `bin/box`, the command surface
|
||||||
the full drill on real hardware, recorded in
|
- `scope:installer` — `install.sh`, versioned installs, upgrade/uninstall
|
||||||
[drill/RUNS.md](drill/RUNS.md) — CI proves the tier's semantics on every
|
- `scope:host` — host setup, teardown, firewall, and isolation stack
|
||||||
PR, a release still proves the boundary.
|
- `scope:tiers` — grant/revoke and multi-user boundaries
|
||||||
2. **The maintainer's merge IS the release.**
|
- `scope:templates` — template and profile seeds
|
||||||
[release.yml](.github/workflows/release.yml) fires on the merged,
|
- `scope:drill` — rehearsals, doctor, and run evidence
|
||||||
`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.
|
|
||||||
|
|
|
||||||
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
|
|
||||||
```
|
|
||||||
201
README.md
201
README.md
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||||
network-isolated Incus box from a **template**; the coding-agent templates
|
network-isolated Incus box from a **template**; the coding-agent templates
|
||||||
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
|
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
|
||||||
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
(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
|
converges**: the template is a thin seed, and the agent tooling lands via a
|
||||||
creds-free `rig bootstrap` role auto-run at mint
|
creds-free `rig bootstrap` role auto-run at mint
|
||||||
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
|
([#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
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box new --name work --template claude # a creds-free coding-agent box (~10 min cold)
|
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
|
box shell work # enter as the template's user
|
||||||
```
|
```
|
||||||
|
|
||||||
Pick whichever coding-agent template you like — `claude`, `codex`, `grok` — or
|
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
|
||||||
`blank` for none. Inside the box, authenticate as needed. The `claude` template
|
`grok-box`, `kimi-box` — or `blank` for none. Inside the box, authenticate as needed. The
|
||||||
looks like this; the others follow the same shape with their own login step:
|
`claude-box` template looks like this; the others follow the same shape with
|
||||||
|
their own login step:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
claude # then run /login — copy the URL (press c), open it
|
claude # then run /login — copy the URL (press c), open it
|
||||||
|
|
@ -239,12 +240,13 @@ No coding agent is special — each is one template among several, and adding
|
||||||
another is just another directory. What ships today:
|
another is just another directory. What ships today:
|
||||||
|
|
||||||
| Template | What it becomes |
|
| Template | What it becomes |
|
||||||
| --------- | ---------------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------ |
|
||||||
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
||||||
| `claude` | Claude Code, creds-free — where this project started |
|
| `claude-box` | Claude Code, creds-free — where this project started |
|
||||||
| `codex` | OpenAI Codex CLI, creds-free |
|
| `codex-box` | OpenAI Codex CLI, creds-free |
|
||||||
| `grok` | xAI Grok CLI, creds-free |
|
| `grok-box` | xAI Grok CLI, creds-free |
|
||||||
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
| `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**
|
**Templates are thin seeds; rig does the becoming**
|
||||||
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
|
([#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
|
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
|
||||||
joins a tailnet or admits credentials — and after cloud-init settles, box
|
joins a tailnet or admits credentials — and after cloud-init settles, box
|
||||||
auto-runs the template's **creds-free** tenant role inside the guest
|
auto-runs the template's **creds-free** tenant role inside the guest
|
||||||
(`rig bootstrap claude` / `codex` / `grok` / `staging`,
|
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `kimi-box` / `staging-box`,
|
||||||
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI,
|
[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
|
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
|
role — convergent and idempotent, so the same command re-run later converges
|
||||||
an *existing* box to a newer spec (`box shell <box>` →
|
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,
|
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
|
||||||
from rig's roles, instead of copy-pasted per template.
|
from rig's roles, instead of copy-pasted per template.
|
||||||
|
|
||||||
**Anything that joins or admits stays operator-run.** The `staging` box's
|
**Anything that joins or admits stays operator-run.** The `staging-box`
|
||||||
tailnet workload join holds a pre-auth key, so box only prints it as the
|
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` — and
|
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
|
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
|
||||||
split, kept).
|
split, kept).
|
||||||
|
|
||||||
|
|
@ -279,9 +284,9 @@ carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
|
||||||
environment:
|
environment:
|
||||||
|
|
||||||
```sh
|
```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 \
|
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,
|
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
|
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||||
snapshot labels and the `--from` line to clone one.
|
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`
|
## Survive the host: `box export` / `box import`
|
||||||
|
|
||||||
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
|
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 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 templates # list the templates this install can mint
|
||||||
box list # list your boxes
|
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 shell <box> # enter as the template's user
|
||||||
box exec <box> -- <cmd...> # run a command in the box
|
box exec <box> -- <cmd...> # run a command in the box
|
||||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||||
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||||
box restore <box> <snap> [--force]
|
box restore <box> <snap> [--force]
|
||||||
# roll back to a snapshot — destructive, asks first
|
# 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]
|
box export <box> [<file>] [--instance-only]
|
||||||
# one portable file (snapshots incl.) — survives rm & host
|
# one portable file (snapshots incl.) — survives rm & host
|
||||||
box import <file> [--name <box>]
|
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,
|
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
||||||
dev/test only.
|
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
|
## Boxes are just Incus instances
|
||||||
|
|
||||||
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
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
|
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
|
||||||
cpu=""; memory=""; disk=""; instance_only=0
|
cpu=""; memory=""; disk=""; instance_only=0
|
||||||
inst="" # the resolved Incus instance, set by the 'box' precondition
|
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
|
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
|
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':
|
# 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
|
# 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^"
|
"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^"
|
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
||||||
"list^[--json]^^List your boxes^fn:cmd_list^"
|
"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^"
|
"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^"
|
"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^"
|
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
||||||
|
|
@ -185,7 +202,7 @@ OPTIONS
|
||||||
|
|
||||||
EXAMPLES
|
EXAMPLES
|
||||||
# mint a claude box and log in inside it — the tool never handles your token
|
# 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
|
box shell work # then: run 'claude', then /login
|
||||||
|
|
||||||
# log in once, reuse forever: checkpoint the authed box, clone from it
|
# log in once, reuse forever: checkpoint the authed box, clone from it
|
||||||
|
|
@ -242,13 +259,13 @@ help_cmd() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
new) cat <<'EOF'
|
new) cat <<'EOF'
|
||||||
Mint a box. Without --from, launches a fresh box from a template (default:
|
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
|
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,
|
one of its snapshots — login state, git creds and clones carry over,
|
||||||
isolation is preserved, and the clone knows its template's user without
|
isolation is preserved, and the clone knows its template's user without
|
||||||
being told.
|
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
|
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
|
role inside it ('rig bootstrap <role>', rig#31): that role installs the
|
||||||
agent CLI / server posture and the agent-context file. rig is preinstalled
|
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 —
|
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
|
||||||
a launch that overruns it fails loudly instead of hanging forever (#93).
|
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 scratch # blank, the default
|
||||||
box new --name work --template claude
|
box new --name work --template claude-box
|
||||||
box new --name lean --template claude --cpu 2 --memory 3GiB
|
box new --name lean --template claude-box --cpu 2 --memory 3GiB
|
||||||
box new --name feature --from work/authed
|
box new --name feature --from work/authed
|
||||||
EOF
|
EOF
|
||||||
;;
|
;;
|
||||||
|
|
@ -366,6 +414,19 @@ label defaults to manual-<epoch>; 'box info <box>' shows the labels you
|
||||||
have.
|
have.
|
||||||
|
|
||||||
box snapshot work authed
|
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
|
EOF
|
||||||
;;
|
;;
|
||||||
restore) cat <<'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
|
||||||
box restore work authed --force
|
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,
|
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
|
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
|
differs, the same move migrate-host makes re-homing a legacy box
|
||||||
· a fresh machine identity (reset_identity, exactly like a clone), so its
|
· a fresh machine identity (reset_identity, exactly like a clone), so its
|
||||||
DHCP lease can never collide with the box it was exported from
|
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,
|
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
|
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
|
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.
|
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 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
|
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -1002,8 +1109,16 @@ load_template() {
|
||||||
# environment must die on the host, never execute in the guest. bash's =~
|
# 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
|
# anchors to the whole string — a multi-line value cannot sneak one clean
|
||||||
# line past it the way a line-oriented grep would.
|
# 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() {
|
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._-]+$ ]] \
|
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|
||||||
|| die "RIG_REPO must look like owner/repo: $repo"
|
|| die "RIG_REPO must look like owner/repo: $repo"
|
||||||
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
|
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
|
||||||
|
|
@ -1026,6 +1141,144 @@ cmd_templates() {
|
||||||
echo "mint one: box new --name <box> --template <template>"
|
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() {
|
cmd_new() {
|
||||||
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
||||||
require_stack
|
require_stack
|
||||||
|
|
@ -1037,10 +1290,78 @@ cmd_new() {
|
||||||
case "$from" in */*) snap="${from#*/}" ;; esac
|
case "$from" in */*) snap="${from#*/}" ;; esac
|
||||||
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
||||||
incus copy "$srcref" "$instance"
|
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"
|
incus start "$instance"
|
||||||
wait_agent "$instance"
|
wait_agent "$instance"
|
||||||
reset_identity "$instance"
|
reset_identity "$instance"
|
||||||
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
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
|
else
|
||||||
local t="${template:-blank}" m extra=()
|
local t="${template:-blank}" m extra=()
|
||||||
load_template "$t"
|
load_template "$t"
|
||||||
|
|
@ -1077,6 +1398,47 @@ cmd_new() {
|
||||||
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
||||||
# knows what it is without ever consulting the template again.
|
# 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
|
# 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
|
# release drill the child 'incus launch' wedged before the create was
|
||||||
# even accepted — 'incus operation list' empty, the instance never
|
# even accepted — 'incus operation list' empty, the instance never
|
||||||
|
|
@ -1095,6 +1457,7 @@ cmd_new() {
|
||||||
--config user.box=1 \
|
--config user.box=1 \
|
||||||
--config user.box.template="$t" \
|
--config user.box.template="$t" \
|
||||||
--config user.box.user="$T_USER" \
|
--config user.box.user="$T_USER" \
|
||||||
|
"${stamp[@]}" \
|
||||||
--config limits.cpu="$T_CPU" \
|
--config limits.cpu="$T_CPU" \
|
||||||
--config limits.memory="$T_MEMORY" \
|
--config limits.memory="$T_MEMORY" \
|
||||||
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
|
--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.
|
# Not a wedge: incus refused and said why on stderr, right above.
|
||||||
die "incus launch failed (exit $rc)"
|
die "incus launch failed (exit $rc)"
|
||||||
fi
|
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"
|
wait_agent "$instance"
|
||||||
echo "box: waiting for phase-1 (cloud-init)..."
|
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)"
|
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
|
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"
|
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
||||||
fi
|
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,
|
# 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
|
# 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
|
# 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: 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: 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
|
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"
|
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
|
||||||
fi
|
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
|
||||||
fi
|
fi
|
||||||
# The login hint belongs to the claude template — read the EFFECTIVE
|
# The login hint belongs to the claude-box template — read the EFFECTIVE
|
||||||
# template off the instance, so a clone of a claude box gets it too and a
|
# template off the instance, so a clone of a claude-box box gets it too and
|
||||||
# blank box is not told to run a binary it doesn't have.
|
# 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)"
|
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
|
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
|
||||||
if [ "$eff" = claude ]; then
|
if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
|
||||||
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
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: 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
|
else
|
||||||
echo "box: ready — 'box shell $name'."
|
echo "box: ready — 'box shell $name'."
|
||||||
fi
|
fi
|
||||||
|
|
@ -1275,6 +1702,85 @@ cmd_list() {
|
||||||
fi
|
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() {
|
cmd_info() {
|
||||||
local box="${args[0]}" row
|
local box="${args[0]}" row
|
||||||
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
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-}"
|
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
|
||||||
done < <(incus config device list "$inst" 2>/dev/null)
|
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
|
echo
|
||||||
case "${snaps:-0}" in
|
case "${snaps:-0}" in
|
||||||
''|0)
|
''|0)
|
||||||
|
|
@ -1333,13 +1845,18 @@ box_user() {
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
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
|
# 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'
|
# 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
|
# 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
|
# 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() {
|
cmd_tmux() {
|
||||||
local session="${args[1]:-main}"
|
local session="${args[1]:-main}"
|
||||||
case "$session" in
|
case "$session" in
|
||||||
|
|
@ -1445,6 +1962,69 @@ cmd_import() {
|
||||||
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
|
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 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
|
||||||
[ "$tag" = 1 ] || incus config set "$target" user.box=1
|
[ "$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
|
# Placement: the artifact carries its profile list, but the isolation
|
||||||
# contract is THIS host's box-net profile. A box export already says
|
# 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
|
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# box design
|
# box design
|
||||||
|
|
||||||
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
||||||
with a coding agent installed** (`claude`, `codex`, `grok`, or `blank` for
|
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
|
||||||
none). It is infrastructure, not a project provisioner.
|
`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
|
See issue #3 for the full reframe and rationale. This doc captures the durable
|
||||||
design decisions.
|
design decisions.
|
||||||
|
|
@ -43,6 +43,33 @@ snapshots, not a secrets store:
|
||||||
|
|
||||||
Log in once → snapshot → spin up authed boxes from it.
|
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
|
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` /
|
clone still lives on the same host. The off-host mechanism is `box export` /
|
||||||
`box import` (#70) — one portable backup tarball, snapshots included by
|
`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
|
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
|
(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
|
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
|
rides along deliberately — and because scrubbing a disk image is a promise
|
||||||
tarball surgery cannot keep, export shouts that the file is a credential
|
tarball surgery cannot keep, export shouts that the file is a credential
|
||||||
instead of pretending to sanitize it.
|
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,
|
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
|
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
|
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` /
|
template's creds-free tenant role after cloud-init (`rig bootstrap claude-box`
|
||||||
`codex` / `grok` / `staging`), which installs the agent CLI or server
|
/ `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
|
||||||
posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
`-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
|
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
|
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
|
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
|
holds a key (the staging-box tenant's workload join) stays operator-run
|
||||||
`box shell`; box prints it as a next step and never sees the key. The seed's
|
through `box shell`; box prints it as a next step and never sees the key. The
|
||||||
rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
|
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),
|
`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
|
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
|
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
|
`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
|
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
|
`claude-box`, `codex-box`, `grok-box`, and `kimi-box` templates each ship a
|
||||||
**nothing** about your project. There is no `install` step and no host-run
|
CLI agent. The tool knows **nothing** about your project. There is no `install` step and no
|
||||||
setup script.
|
host-run setup script.
|
||||||
|
|
||||||
A project makes itself easy to stand up inside a box by shipping an optional
|
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
|
`.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.
|
# Read from inside a box WITHOUT ever hanging the drill.
|
||||||
#
|
#
|
||||||
# Two traps, both hit for real:
|
# Two traps, both hit for real:
|
||||||
# · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
|
# · 'box exec' crosses a login-user shell boundary. Fine for a person,
|
||||||
# all). Fine for a person, needless machinery for a probe.
|
# needless machinery for a probe.
|
||||||
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
|
||||||
# inheriting the exec session's stdout keeps the substitution open forever,
|
# 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
|
# 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
|
fi
|
||||||
inf "host setup complete"
|
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
|
# 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
|
# 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
|
# 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)"
|
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
|
||||||
if [ "$ram" -lt 20 ]; then
|
if [ "$ram" -lt 20 ]; then
|
||||||
export BOX_MEMORY=3GiB BOX_CPU=2
|
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
|
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
|
fi
|
||||||
|
|
||||||
KVM=0; [ -e /dev/kvm ] && KVM=1
|
KVM=0; [ -e /dev/kvm ] && KVM=1
|
||||||
|
|
@ -492,10 +492,10 @@ fi
|
||||||
|
|
||||||
# --- templates: the mint surface is itself a surface to test ----------------
|
# --- templates: the mint surface is itself a surface to test ----------------
|
||||||
tpl_missing=""
|
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"
|
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
|
||||||
done
|
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"
|
|| no "templates listing is missing:$tpl_missing"
|
||||||
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
|
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"
|
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
|
||||||
|
|
@ -551,19 +551,22 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The generic mechanic (metadata, placement, user, isolation parity) is proven
|
# 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
|
# still has to prove is its own payload: the CLI installs, lands on the
|
||||||
# non-interactive exec PATH, and answers --version. One mint each.
|
# 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
|
for t in codex grok; do
|
||||||
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
|
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"
|
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" ] \
|
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
|
||||||
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $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
|
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"
|
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
|
||||||
else
|
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)"
|
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.
|
||||||
# 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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
printf '\n minting a claude box (cold, ~10 min)…\n'
|
printf '\n minting a claude-box box (cold, ~10 min)…\n'
|
||||||
t0=$SECONDS
|
t0=$SECONDS
|
||||||
if mint_box /tmp/mint-drill.log --name drill --template claude; then
|
if mint_box /tmp/mint-drill.log --name drill --template claude-box; then
|
||||||
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
|
ok "box new --name drill --template claude-box ($((SECONDS - t0))s)"
|
||||||
else
|
else
|
||||||
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
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
|
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)"
|
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
|
# 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
|
# 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
|
# NON-exposed port still obeys the ingress drop — the feature must not
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,31 @@ fi
|
||||||
rm -f "$mintlog"
|
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 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"
|
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)" \
|
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" \
|
&& 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"
|
|| 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
|
# 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"
|
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
|
||||||
done
|
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
|
for net in boxnet claudenet; do
|
||||||
while sudo ufw status numbered | grep -q "on $net"; do
|
while :; do
|
||||||
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
|
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"
|
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
|
||||||
done
|
done
|
||||||
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
|
# 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() {
|
rehome_one() {
|
||||||
local b="$1" st
|
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; }
|
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
|
# 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.
|
# 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; }
|
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
|
||||||
|
|
||||||
# 2. Stop, reassign the profile (this is the network move), restart. Incus
|
# 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
|
# 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
|
# incus-user state directory too — it was purged for releases without being
|
||||||
# re-checked, which is exactly the gap this block exists to close.
|
# 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=""
|
leftover=""
|
||||||
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
|
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 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"
|
&& leftover="$leftover cert:incus-user-$uid"
|
||||||
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
|
||||||
&& leftover="$leftover /var/lib/incus/users/$uid"
|
&& 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
|
if [ "$yes" -eq 1 ]; then
|
||||||
echo "(confirmed non-interactively: --yes/BOX_YES)"
|
echo "(confirmed non-interactively: --yes/BOX_YES)"
|
||||||
else
|
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
|
# EOF (Ctrl-D) refuses, out loud: unguarded, errexit would end the run on
|
||||||
# this line and the 'aborted' below would never print (#111).
|
# this line and the 'aborted' below would never print (#111).
|
||||||
read -rp "Continue? [y/N] " a || { echo "aborted"; exit 1; }
|
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"
|
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 --------------------------------------
|
# --- migrate a pre-0.7.0 flat install --------------------------------------
|
||||||
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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
|
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
|
||||||
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
|
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
|
# 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"
|
mkdir -p "$BINDIR"
|
||||||
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
|
||||||
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
|
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
|
||||||
fi
|
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"
|
||||||
# Whether ANY version was installed before this run — read before we add one.
|
migrated_from="$flat_ver"
|
||||||
# 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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- temp workspace --------------------------------------------------------
|
# --- temp workspace --------------------------------------------------------
|
||||||
|
|
@ -386,6 +407,15 @@ esac
|
||||||
# 'box setup-host' re-applies stack changes deliberately, after an upgrade.
|
# '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
|
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
|
||||||
# up by hand); BOX_YES answers "yes".
|
# 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_ok=""
|
||||||
setup_declined=""
|
setup_declined=""
|
||||||
if [ "$had_install" -eq 1 ]; then
|
if [ "$had_install" -eq 1 ]; then
|
||||||
|
|
@ -396,7 +426,7 @@ elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
|
||||||
setup_declined=1
|
setup_declined=1
|
||||||
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
|
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 "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
|
setup_declined=1
|
||||||
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
|
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
|
# </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.
|
# 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
|
# 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.
|
# 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
|
setup_ok=1
|
||||||
else
|
else
|
||||||
warn "host setup did not complete — box is installed, the host is not ready."
|
warn "host setup did not complete — box is installed, the host is not ready."
|
||||||
|
|
@ -422,3 +452,12 @@ elif [ -n "$setup_declined" ]; then
|
||||||
else
|
else
|
||||||
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
|
log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
|
||||||
fi
|
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,
|
# '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:
|
# 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="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
|
# 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.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -14,4 +18,4 @@ BOX_USER="claude"
|
||||||
BOX_CPU="4"
|
BOX_CPU="4"
|
||||||
BOX_MEMORY="8GiB"
|
BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
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 —
|
# 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
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
# 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
|
# 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,
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
# once, instead of being copy-pasted per template.
|
# once, instead of being copy-pasted per template.
|
||||||
|
|
@ -21,7 +21,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# 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
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# 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,
|
# '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:
|
# 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="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
|
# 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.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -14,4 +18,4 @@ BOX_USER="codex"
|
||||||
BOX_CPU="4"
|
BOX_CPU="4"
|
||||||
BOX_MEMORY="8GiB"
|
BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
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 —
|
# 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
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
# 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
|
# 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,
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
# once, instead of being copy-pasted per template.
|
# once, instead of being copy-pasted per template.
|
||||||
|
|
@ -21,7 +21,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# 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
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# 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,
|
# '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:
|
# 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="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
|
# 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.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -14,4 +18,4 @@ BOX_USER="grok"
|
||||||
BOX_CPU="4"
|
BOX_CPU="4"
|
||||||
BOX_MEMORY="8GiB"
|
BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
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 —
|
# 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
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
# docker, no context-file heredoc. Everything this box becomes comes from
|
# 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
|
# 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,
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
# once, instead of being copy-pasted per template.
|
# once, instead of being copy-pasted per template.
|
||||||
|
|
@ -21,7 +21,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# 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
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# 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
|
# 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
|
# 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:
|
# 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="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
|
# 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.
|
# profile is the placement contract and no template can weaken it.
|
||||||
|
|
@ -21,4 +26,4 @@ BOX_MEMORY="8GiB"
|
||||||
BOX_DISK="60GiB"
|
BOX_DISK="60GiB"
|
||||||
BOX_REQUIRE_VM="1"
|
BOX_REQUIRE_VM="1"
|
||||||
BOX_AUTOSTART="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),
|
# 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,
|
# and rig — nothing that joins a tailnet or admits credentials, no docker,
|
||||||
# no sshd config, no keys. The server posture comes from
|
# 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
|
# 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.
|
# #69 designed it — box never sees the key.
|
||||||
users:
|
users:
|
||||||
- name: ops
|
- name: ops
|
||||||
|
|
@ -22,7 +22,7 @@ packages:
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
runcmd:
|
runcmd:
|
||||||
# Preinstall rig so the box can converge — and re-converge — via
|
# 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
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
# 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 ]
|
|
||||||
352
test/release.sh
352
test/release.sh
|
|
@ -1,22 +1,10 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# The release flow (#83), proven offline. Run: bash test/release.sh
|
# Box-specific release-channel coverage. Shared release/guard machinery lives
|
||||||
#
|
# in heavy-duty/ceremony and is tested there; this file drives real install.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.
|
|
||||||
set -u
|
set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
PASS=0 FAIL=0
|
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() {
|
check() {
|
||||||
local desc="$1" want="$2" substr="$3"; shift 3
|
local desc="$1" want="$2" substr="$3"; shift 3
|
||||||
local out rc
|
local out rc
|
||||||
|
|
@ -34,264 +22,10 @@ check() {
|
||||||
echo "ok: $desc"; PASS=$((PASS + 1))
|
echo "ok: $desc"; PASS=$((PASS + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
NOTES="$ROOT/.github/scripts/release-notes.sh"
|
|
||||||
WORK="$(mktemp -d)"
|
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"
|
SHIMDIR="$WORK/shim"; mkdir -p "$SHIMDIR"
|
||||||
cat > "$SHIMDIR/curl" <<'SHIM'
|
cat > "$SHIMDIR/curl" <<'SHIM'
|
||||||
#!/usr/bin/env bash
|
#!/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=""
|
url="" out=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -317,105 +51,77 @@ chmod +x "$SHIMDIR/curl"
|
||||||
|
|
||||||
TAGFN="$(mktemp)"
|
TAGFN="$(mktemp)"
|
||||||
awk '/^latest_release_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAGFN"
|
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: extracted from install.sh" 0 "releases/latest" cat "$TAGFN"
|
||||||
check "latest_release_tag: the extracted function is valid bash" 0 "" bash -n "$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 \
|
FAKE_REDIRECT="$1" FAKE_CURL_RC="${2:-0}" REPO=heavy-duty/box \
|
||||||
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
|
PATH="$SHIMDIR:$PATH" bash -c ". '$TAGFN'; latest_release_tag"
|
||||||
}
|
}
|
||||||
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
|
check "resolve: reads the tag off the redirect" 0 "0.6.0" \
|
||||||
ltag "https://github.com/heavy-duty/box/releases/tag/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"
|
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"
|
ltag "https://github.com/heavy-duty/box/releases"
|
||||||
check "resolve: no redirect at all fails" 1 "" ltag ""
|
check "resolve: no redirect fails" 1 "" ltag ""
|
||||||
check "resolve: a curl failure (network down) fails, never hangs on prose" 1 "" \
|
check "resolve: a curl failure fails" 1 "" \
|
||||||
ltag "https://github.com/heavy-duty/box/releases/tag/0.6.0" 6
|
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"
|
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||||
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
|
SRC="$WORK/box-9.9.9"; mkdir -p "$SRC/bin"
|
||||||
cp "$ROOT/bin/box" "$SRC/bin/box"; chmod +x "$SRC/bin/box"
|
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
|
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
|
local h="$1" b="$2"; shift 2
|
||||||
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
|
env HOME="$FAKEHOME" PATH="$SHIMDIR:$PATH" \
|
||||||
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
||||||
FAKE_TARBALL="$WORK/gh.tar.gz" "$@" bash "$ROOT/install.sh"
|
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"
|
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" \
|
ninst "$H1" "$B1" FAKE_CURL_LOG="$L1" \
|
||||||
FAKE_REDIRECT="https://github.com/heavy-duty/box/releases/tag/9.9.9" \
|
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"
|
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"
|
grep -qF "archive/refs/tags/9.9.9.tar.gz" "$L1"
|
||||||
check "default channel: it never asked for a branch" 1 "" \
|
check "default channel: never asks for a branch" 1 "" grep -q "refs/heads" "$L1"
|
||||||
grep -q "refs/heads" "$L1"
|
check "default channel: records the resolved tag" 0 "heavy-duty/box@9.9.9" \
|
||||||
check "default channel: INSTALLED_FROM records the RESOLVED tag" 0 "heavy-duty/box@9.9.9" \
|
|
||||||
cat "$H1/versions/9.9.9/INSTALLED_FROM"
|
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
|
env HOME="$FAKEHOME" "$B1/box" --version
|
||||||
|
|
||||||
# --- channel 2: BOX_REF=<tag> pins a release --------------------------------
|
|
||||||
H2="$WORK/h2"; B2="$WORK/b2"; L2="$WORK/c2.log"
|
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" \
|
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"
|
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"
|
grep -q "releases/latest" "$L2"
|
||||||
|
|
||||||
# --- channel 3: BOX_REF=<branch> is the dev channel -------------------------
|
|
||||||
H3="$WORK/h3"; B3="$WORK/b3"; L3="$WORK/c3.log"
|
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" \
|
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"
|
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" \
|
check "dev channel: tries the tag first" 0 "refs/tags/main.tar.gz" head -1 "$L3"
|
||||||
head -1 "$L3"
|
check "dev channel: then downloads the branch" 0 "" \
|
||||||
check "dev channel: then the branch" 0 "" \
|
|
||||||
grep -qF "archive/refs/heads/main.tar.gz" "$L3"
|
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"
|
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"
|
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
|
ninst "$H4" "$B4" FAKE_CURL_RC=6
|
||||||
check "resolution failure: downloaded NOTHING (no silent main)" 1 "" \
|
check "resolution failure: downloads nothing" 1 "" grep -q "archive/" "$L4"
|
||||||
grep -q "archive/" "$L4"
|
check "resolution failure: installs nothing" 1 "" test -e "$H4/versions"
|
||||||
check "resolution failure: nothing was installed" 1 "" test -e "$H4/versions"
|
check "unknown ref names both attempted channels" 1 "neither a tag nor a branch" \
|
||||||
check "a ref that is neither tag nor branch dies naming both" 1 "neither a tag nor a branch" \
|
|
||||||
ninst "$H4" "$B4" BOX_REF=no-such-ref
|
ninst "$H4" "$B4" BOX_REF=no-such-ref
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
check "README documents the latest-release channel" 0 "" grep -qF 'latest release' "$ROOT/README.md"
|
||||||
# The -dev convention (#83): main's VERSION carries -dev between releases, so
|
check "README documents the pinned channel" 0 "" grep -qF 'BOX_REF=0.6.0' "$ROOT/README.md"
|
||||||
# a dev install lands beside releases in versions/ instead of impersonating
|
check "README documents the dev channel" 0 "" grep -qF 'BOX_REF=main' "$ROOT/README.md"
|
||||||
# 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"
|
|
||||||
|
|
||||||
echo "---"
|
echo "---"
|
||||||
echo "$PASS passed, $FAIL failed"
|
echo "$PASS passed, $FAIL failed"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue