Compare commits
No commits in common. "93608e8d7815505f041aaa479251cb14adc8e24d" and "7f8a0e08852837475505f404985a1251a2c3a8a1" have entirely different histories.
93608e8d78
...
7f8a0e0885
50 changed files with 3218 additions and 4217 deletions
|
|
@ -1,49 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
# 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 build at a time.** You hold at most one issue on which you are
|
||||
writing or revising a deliverable — finish or release that work before
|
||||
starting new work. The rule counts build work in flight, not claims: a
|
||||
claim does not consume the slot while it is **parked**, meaning the next
|
||||
move belongs to someone else. Exactly five shapes qualify:
|
||||
1. the issue carries `needs-ruling`, its escalation names a decider, and
|
||||
its `Blocked:` line stops the remaining work;
|
||||
2. the deliverable is in a review round where every outstanding verdict
|
||||
belongs to someone else — either the round is awaiting its first
|
||||
verdicts, or it was answered whole and the non-approvers re-requested
|
||||
(the review round, steps 1–2). This is the *live* round; shape 4 is
|
||||
the *passed* one — they are sequential and do not overlap;
|
||||
3. every remaining acceptance criterion is operator-owned, stated as such
|
||||
by triage on the issue;
|
||||
4. the deliverable is **handed off** — the round passed, no `blocker:*`
|
||||
stands, and you set `state:needs-human` per Handoff (below). The
|
||||
remaining move is the human's merge.
|
||||
5. the claim is **held by directive** — triage or the operator has told
|
||||
you to stop, the direction names what the hold waits on, and that thing
|
||||
is not yours to move. This is not "waiting for a good moment": somebody
|
||||
else has decided the work must not proceed, and only they end it.
|
||||
And it ends the same way it started: **on the labels.** When the queue
|
||||
labels and any prose — an issue body header, a triage comment, an
|
||||
operator's comment — disagree about whether a hold stands, the most
|
||||
recent queue-label event by the hold's owner governs, and the prose is
|
||||
stale until someone corrects it. So before standing down *or* standing
|
||||
up on a hold, read the issue's **label events**
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not only its
|
||||
comments: an operator may lift by label alone, and on 2026-07-24 did,
|
||||
twice, on [#149](https://github.com/heavy-duty/ceremony/issues/149)
|
||||
and [#151](https://github.com/heavy-duty/ceremony/issues/151). Acting
|
||||
on the labels against stale prose, say so in the claim — name the
|
||||
events you read, their timestamps and their actor, and invite the
|
||||
correction if the read is wrong;
|
||||
[the 14:11:45Z claim on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070781295)
|
||||
is the exemplar. Refusing is not a resting place either:
|
||||
[*"I am not claiming through that contradiction"*](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070776624)
|
||||
was a correct instinct and an incomplete move — the next step is to
|
||||
read the events, state what they say, and then claim or stand down on
|
||||
that, or, if the events genuinely do not resolve it, say so on the
|
||||
issue and pick the next `ready` issue rather than idling on this one.
|
||||
Not parked — these are what the rule defends against: waiting on
|
||||
yourself, waiting on CI, or waiting for a good moment. An issue you have
|
||||
simply stopped working on is not parked either — that is abandonment,
|
||||
and its move is unchanged: unassign and restore `ready` (Claiming,
|
||||
below).
|
||||
The 2026-07-23 board is why the rule counts work and not claims: one
|
||||
builder correctly held
|
||||
[#15](https://github.com/heavy-duty/ceremony/issues/15) (`offsite`,
|
||||
round answered whole, one verdict outstanding) and
|
||||
[#16](https://github.com/heavy-duty/ceremony/issues/16) (`needs-ruling`
|
||||
hard block, triage said hold) parked beside the one active build,
|
||||
[#73](https://github.com/heavy-duty/ceremony/issues/73).
|
||||
|
||||
## 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 unless `offsite`
|
||||
records that its PR lives in another repository.
|
||||
- **A park is declared, never inferred.** When your claim enters a parked
|
||||
shape (Picking, above), say so in a comment on that issue, naming what it
|
||||
waits on and who owns the next move. No new label: the comment is
|
||||
activity, so it feeds the same reclaim clock the `needs-ruling`
|
||||
([#52](https://github.com/heavy-duty/ceremony/issues/52)) and `offsite`
|
||||
([#68](https://github.com/heavy-duty/ceremony/issues/68)) exemptions
|
||||
already guard — a parked claim nobody can name is an abandoned one.
|
||||
Shape 4 alone is exempt from the separate comment: the handoff round
|
||||
summary plus the `state:needs-human` write *is* its declaration — both
|
||||
halves are already there, what the claim waits on (the merge) and who
|
||||
owns the next move (the human), and both are visible to any scan as a
|
||||
`labeled` event with the summary beside it. No second comment is owed
|
||||
on the issue. Every other shape still declares as above.
|
||||
- **Pick up `attention` before anything else.** On your claim, first post a
|
||||
short pickup comment and remove `attention`; the removal is the ack. A
|
||||
demand on a parked claim is usually its unpark, so take the slot back under
|
||||
the existing rule below rather than leaving the demand parked. A demand
|
||||
that *is* the park is different: the pickup comment is the declaration,
|
||||
so one comment does both jobs, and the demand does not take the slot back.
|
||||
- **A directed hold keeps its bookkeeping visible.** The PR carries `blocked`
|
||||
with a comment naming what it waits on; the issue stays `claimed` and
|
||||
carries `attention` until the builder acknowledges it. Nobody unassigns
|
||||
the issue, and the 48-hour reclaim does not fire because the claim has an
|
||||
open PR. Unparking follows the existing rule below.
|
||||
- **Unparking is a claim like any other.** When the wait ends, the parked
|
||||
issue is work again and takes the slot. If you are already active
|
||||
elsewhere, finish or release that work first, and say which you did on
|
||||
both issues — the slot is still one. Nothing counts claims per builder
|
||||
and no reconciler path enforces any of this: `claim_decision()` sees one
|
||||
issue at a time by construction, and no such machinery should be built
|
||||
expecting it to have been specified here. The discipline is the
|
||||
declaration, not a counter.
|
||||
- **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. `Closes #N` does not cross repos: when the PR is in a different repo
|
||||
from its authorizing issue, use `Part of <owner>/<repo>#N` instead, and
|
||||
in the same step set `offsite` and comment on that issue with the draft PR
|
||||
link as soon as the draft opens.
|
||||
Triage closes the authorizing issue by hand when its acceptance criteria
|
||||
are met; at that handoff the builder reports whether the cross-repo PR
|
||||
merged or closed and clears `offsite` in the same comment. The cross-repo
|
||||
merge never closes the authorizing issue. This codifies the linkage
|
||||
builders already used on rig#112 and ceremony #13/#16 rather than adding a
|
||||
new review obligation.
|
||||
`Closes #N` also does not survive a post-merge criterion: when the issue's
|
||||
body states that an acceptance criterion can only be checked after the
|
||||
merge — a live proof of a workflow trigger, a released-artifact check,
|
||||
anything whose subject does not exist until the change is on the base
|
||||
branch — the same-repo PR uses `Refs #N` instead, and triage closes the
|
||||
issue by hand on the evidence, exactly as it does for cross-repo work.
|
||||
The issue body is what says so; you never judge which issues qualify, and
|
||||
absent that instruction `Closes #N` remains the default. The exception was
|
||||
bought the hard way: #143 carried `Closes #137` as doctrine then required,
|
||||
and the merge closed #137 with its post-merge criterion unmet (#151).
|
||||
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 writes one fragment, `changelog.d/<issue>.md`,
|
||||
named for the authorizing issue (`<repo>-<issue>.md` when the work is
|
||||
cross-repo) — the exact prose that will be published, nothing else: `- `
|
||||
bullets, and in a grouped repo the `### Added` / `### Changed` /
|
||||
`### Fixed` headings inside the fragment, creating a rarer kind only when
|
||||
a change genuinely is one. Never edit `CHANGELOG.md` for an entry — the
|
||||
release PR assembles the section from the fragments (#112); the monotonic
|
||||
guard still refuses anything that deletes a shipped heading.
|
||||
- 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 panel is the roster
|
||||
of the repo the **PR** is in, minus you — never the roster of the repo the
|
||||
issue is in. The PR repo's `.github/labels.conf` `panel=` line is the
|
||||
machine's answer; its CONTRIBUTING roster is the human-readable answer,
|
||||
and `panel=` governs if they disagree because that is what the state
|
||||
machine reads. If the PR repo names no roster, ask triage on the
|
||||
authorizing issue before marking ready-for-review; do not guess. You may
|
||||
request an off-panel reviewer, but say that their verdict is advisory and
|
||||
does not become required. On rig#112 this distinction mattered: requesting
|
||||
codex and grok was correct for rig's panel even though ceremony's bench was
|
||||
larger, and the doctrine had not said which roster governed.
|
||||
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 — silence and force-forward are not options. A panel deadlock
|
||||
is one kind of human-owned decision; use the ruling ask below
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
|
||||
## The ruling ask
|
||||
|
||||
Set `needs-ruling` whenever a decision belongs to a human: org policy,
|
||||
published artifacts, secrets, prod, or any choice whose cost lands outside
|
||||
the PR. A panel deadlock is one instance, not the definition. The builder is
|
||||
the accountable flag-setter on a PR and consolidates the decision into one
|
||||
comment rather than forwarding several reviewers' phrasings
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
|
||||
Keep at most these five lines above the fold and put all other analysis
|
||||
inside the fold. The field labels are fixed because the ruling machinery
|
||||
checks for them ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50)):
|
||||
|
||||
```text
|
||||
🧭 needs-ruling — <the decision, one line>
|
||||
Options: A — <one clause> B — <one clause>
|
||||
Recommend: A, because <one clause>.
|
||||
Blocked: <what stops; what continues meanwhile>
|
||||
Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
|
||||
<details><summary>Analysis</summary>…everything else…</details>
|
||||
```
|
||||
|
||||
The options must be exhaustive and mutually exclusive; more than three means
|
||||
the question is not ready. `Recommend:` is mandatory — omitting it hands the
|
||||
whole problem to the human. `Blocked:` names both what stops and what
|
||||
continues. Write a timed `Default:` only when you are affirmatively confident
|
||||
the decision is reversible inside the PR before merge. Unsure is not a tie:
|
||||
it is a hard block. Published artifacts, secrets, prod, and org policy are
|
||||
hard blocks by construction ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
|
||||
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
|
||||
event**, not its `Default:` deadline or the last activity
|
||||
([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)):
|
||||
|
||||
- **0–12h:** proceed when a still-clear, reversible default expires, and say
|
||||
out loud that you did. A hard block waits.
|
||||
- **at 12h:** do not fire a stale default. Re-read it against what has landed
|
||||
and ask whether it still holds and whether reasonable doubt remains. If
|
||||
doubt has appeared, make it a hard block.
|
||||
- **at 24h:** proceed regardless, **as a PR**. Pick an option and state in the
|
||||
PR body which way you went and what doubt remains. Nothing merges by this;
|
||||
the human still gates the merge.
|
||||
- **past 24h:** hand the choice to triage. Triage picks the option, records it
|
||||
as a decision, and remains accountable; the operator can overturn it at
|
||||
merge.
|
||||
|
||||
A re-flag starts a fresh ladder. The ladder applies whatever `Default:` says,
|
||||
including a hard block, and an active back-and-forth still climbs it. This is
|
||||
different from the 7-day nudge, which resets on real activity. The machine
|
||||
observes both clocks but never sets, clears, or decides `needs-ruling`.
|
||||
|
||||
The label stays until agreement is *reached*, not until the maintainer
|
||||
replies. The setter records the ruling, removes the label, and returns the
|
||||
item to its flow in the same comment ([LABELS.md](LABELS.md)).
|
||||
|
||||
## 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. The claim is now parked as shape 4 (Picking, above) — the handoff
|
||||
you just posted is its declaration, and your build slot is free. Address
|
||||
what comes back (`state:addressing`) and re-hand-off the same way.
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
# 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, reconciled by the work-queue sweep). 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, or a ruling is pending |
|
||||
| `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 work-queue sweep enforces the invariant a board scan relies on: every open issue is either
|
||||
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
|
||||
`blocked`. It flags conflicts rather than guessing intent. A `claimed` issue
|
||||
with no open PR and no activity for 48 hours is reclaimed by the sweep: it
|
||||
comments, unassigns the stale owner, and restores `ready`.
|
||||
|
||||
## 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) |
|
||||
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
|
||||
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
|
||||
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
|
||||
| `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 |
|
||||
|
||||
`needs-ruling` marks where the human's turn is when the pending thing is a
|
||||
*decision*, not a merge ([#50 D1–D14](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
It applies to any human-owned decision — org policy, published artifacts,
|
||||
secrets, prod, or any choice whose cost lands outside the work. A panel
|
||||
deadlock is one instance, not the definition (D11). It is not
|
||||
`state:needs-human`: that label means exactly "this PR could be merged right
|
||||
now", and the retired `state:needs-rebase` is the family's proof that a
|
||||
label meaning two things lies about both. It is not a `blocker:*` either:
|
||||
every blocker names work the *builder* owes, a ruling is owed by the human —
|
||||
and the flag must live on issues too, where blockers do not exist. On issues
|
||||
it coexists with the queue labels (the one-of-three invariant above ignores
|
||||
it); its color is the light shade of `state:needs-human`'s, so the human
|
||||
axis reads as one family. It is a state, not a signal: set only with the
|
||||
[canonical escalation contract](BUILDER.md#the-ruling-ask) (D12). A bare
|
||||
flag is noise. The comment carries exhaustive, mutually exclusive options
|
||||
(at most three), a mandatory recommendation, what stops and what continues,
|
||||
and either a default affirmatively known to be reversible inside the PR or
|
||||
`none — hard block`. Unsure is a block; published artifacts, secrets, prod,
|
||||
and org policy are hard blocks by construction (D13).
|
||||
|
||||
The ruling ladder runs from the current episode's `needs-ruling` **`labeled`
|
||||
event** (D13–D14):
|
||||
|
||||
- **0–12h:** a clear, reversible decision may proceed when its stated default
|
||||
expires, saying out loud that it did; anything with reasonable doubt waits
|
||||
as a hard block.
|
||||
- **at 12h:** the setter re-reads the default against what has landed and asks
|
||||
whether it still holds and whether doubt remains. A stale default does not
|
||||
fire; new doubt makes it a hard block.
|
||||
- **at 24h:** the builder proceeds regardless, **as a PR**, stating the option
|
||||
chosen and the doubt that remains. Nothing merges by this; the human still
|
||||
gates the merge.
|
||||
- **past 24h:** triage picks the option, records it as a decision, and remains
|
||||
accountable. The operator may overturn it at merge.
|
||||
|
||||
A re-flag starts a new ladder. The rungs apply whatever `Default:` says,
|
||||
including a hard block. Active discussion still climbs the ladder; by
|
||||
contrast, the separate 7-day nudge resets on real activity. The machine
|
||||
observes the rungs but never sets, clears, or decides `needs-ruling`.
|
||||
|
||||
The flag stays up until agreement is *reached* — a human reply alone does not
|
||||
clear it — and its setter closes it out: records the ruling as a decision in
|
||||
one comment, removes the label, and returns the item to its flow in that same
|
||||
comment, never as a side effect. If the human disagrees that agreement was
|
||||
reached, the label goes back on. The reconciler refuses `state:needs-human`
|
||||
while it stands (the PR falls to `state:addressing` — the ball on the PR is
|
||||
the builder's, who carries the ruling in), and the staleness sweep skips it,
|
||||
because waiting on a human is legitimately quiet. Quiet, but not unwatched
|
||||
(#52, both surfaces): a flag set with no escalation comment from its setter
|
||||
is called out by the sweep — comment-only, scoped to the labeled event, the
|
||||
label never removed — and a ruling with no real activity for 7 days draws a
|
||||
comment-only nudge addressed to the decider, linking the escalation. The
|
||||
nudge carries no marker on purpose: the comment is itself activity, so it
|
||||
resets its own window and never repeats within a quiet week. Label churn is
|
||||
not activity — the clock reads comments, reviews and commits, or the sweep
|
||||
would reset itself.
|
||||
|
||||
`offsite` is issue-only and records that a claimed issue's deliverable lives
|
||||
in another repository, where a closing reference cannot make a local open PR
|
||||
visible to the sweep (#68). The builder sets it in the same step that posts
|
||||
the cross-repo draft link, then clears it at handoff in the same comment that
|
||||
reports whether that PR merged or closed. The machine reads the flag and
|
||||
never writes it. It stops only the claim-reclaim clock: missing assignees are
|
||||
still flagged, queue-label conflicts and missing queue state are still
|
||||
repaired, and epic-completion and PR-side stale behavior are unchanged. The
|
||||
sweep tells the assignee once when every visible cross-referenced PR has
|
||||
closed; it only tells, and never clears the flag or changes the claim.
|
||||
|
||||
`attention` is issue-only and says a demand is parked on an issue for its
|
||||
assignee. Anyone who needs that assignee's hands — triage, the operator, or a
|
||||
sibling agent — sets it. The assignee alone clears it, as the first act of
|
||||
pickup together with a short comment; that removal is the acknowledgement
|
||||
and re-arms the flag for the next demand. If the session dies before the ack,
|
||||
the still-visible flag launches the next pickup instead. An unanswered flag
|
||||
is auditable evidence on the board.
|
||||
|
||||
The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
|
||||
with `needs-ruling`, and never substitutes for queue state. It pauses no
|
||||
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
|
||||
unanswered `attention` is exactly the silence the 48-hour reclaim should
|
||||
take. It is hand-set doctrine only: nothing in `actions/` sets, clears,
|
||||
reads, or validates it, and no reconciler enforces the assignee requirement.
|
||||
An `attention` issue without an assignee is therefore a board bug, not a
|
||||
demand; anyone may assign it or remove the flag.
|
||||
|
||||
The three signals are mutually distinct: `attention` means an assignee owes
|
||||
a move; `needs-ruling` means a human owes a decision under
|
||||
[the escalation contract and ladder](BUILDER.md#the-ruling-ask); and a bare
|
||||
`@`-mention is an FYI that demands nothing and remains perfectly fine. A
|
||||
demand that is itself a human decision carries `needs-ruling`, never both.
|
||||
This distinction records the
|
||||
[#16 missed-ruling incident](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
|
||||
and why the rejected mention poll is not returning: ordinary thread traffic
|
||||
re-arms mentions, but only the writer can declare that a move is owed (#83).
|
||||
|
||||
## Scope — which surface? (PRs and issues, any number)
|
||||
|
||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
|
||||
set is per-repo: PRs get theirs from changed paths via the labels workflow's
|
||||
scope job — an additive write only, so a label applied by hand or by an agent
|
||||
while the machine runs always survives it (#130) — and issues get theirs from
|
||||
triage. This file never enumerates a set — it is mirrored
|
||||
byte-identically into every governed repo, and any list it carried would be
|
||||
true in one repo and false in the rest (#104). The set for the repo you are
|
||||
standing in lives in the two places that are true wherever you read them: its
|
||||
`.github/labels.conf` (the definitions, one `name|color|description` row per
|
||||
scope) and its own `CONTRIBUTING.md`, beside the other repo-specific facts.
|
||||
|
||||
## 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. The sweep warns when the core taxonomy declares a label the
|
||||
repository lacks. The same workflow reconciles issue-flow labels on issue
|
||||
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
|
||||
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
|
||||
deleted at bootstrap — a `question` is a discussion, not an issue.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# .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).
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
# 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.
|
||||
- **Name what you could not verify, in the verdict body.** Say which checks
|
||||
you could not run and why, and what you relied on instead: CI, reading, or
|
||||
a narrower probe. An unstated environment gap reads as coverage — exactly
|
||||
the blind spot Kimi's [crew report](https://github.com/heavy-duty/crew/blob/main/kimi-bot-andresmgsl/assessment.md)
|
||||
identified for boxes without `node` or `shellcheck`.
|
||||
- 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`, its
|
||||
cross-repo `Part of <owner>/<repo>#N`, or its `Refs #N` when the issue
|
||||
body marks a criterion post-merge, names your spec. That last shape is
|
||||
not a defect: the issue directs it, triage owns that close, and a
|
||||
request-changes on the "missing" keyword enforces the bug the shape
|
||||
exists to fix — `Closes #137` closed its issue with a post-merge
|
||||
criterion unmet (#151). 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.
|
||||
- **Verify a pinned consumer at its pin, not ceremony's `main`.** Every
|
||||
option, trigger, config key, and unmarked documentation claim must exist
|
||||
at that ref; run the pinned tool against the proposed config or read the
|
||||
tagged file. On [box#164](https://github.com/heavy-duty/box/pull/164),
|
||||
`0.1.0`'s `load_config` rejected `triage-actors=...` with
|
||||
`malformed label row` and `exit=1`. CI green on a conversion PR proves
|
||||
nothing about the new config: the base branch's workflow is what ran.
|
||||
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".
|
||||
|
||||
## Where you review
|
||||
|
||||
- **A review request on you is your authorization** in any `heavy-duty` repo
|
||||
and on any fleet member's fork. You need no separate permission and do not
|
||||
wait for the repo to appear on a list: review is reversible
|
||||
read-plus-comment work, and the requester already decided it should happen.
|
||||
- **A request is authorization, not panel membership.** Convergence is
|
||||
measured against the target repo's `panel=` roster minus the author. If you
|
||||
are requested off-panel, post the verdict anyway and say in its body that
|
||||
it is advisory; neither your silence nor your request-changes is a gate the
|
||||
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
|
||||
rig#112 showed why authorization and membership must not be conflated.
|
||||
- **Being requested is a wake condition of its own.** It is how work in a
|
||||
repo you have never heard of reaches you; a repo list finds only work in
|
||||
repos somebody thought to list.
|
||||
|
||||
## How you work the queue
|
||||
|
||||
- **Your queue is the API, not the search index.** Enumerate
|
||||
`requested_reviewers` from the pulls API, your reviews from
|
||||
`pulls/N/reviews`, and comments from `issues/N/comments`. Search lag left
|
||||
cast#143, incubator#25, and box#164 waiting, as Claude's
|
||||
[crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/assessment.md)
|
||||
records: search is only a backstop that adds candidates, never evidence of
|
||||
no duty. `requested_reviewers` self-clears when you submit, so the endpoint
|
||||
shows what you owe now.
|
||||
- **Every write is one-shot, keyed to (you, PR, head SHA).** Put a fresh
|
||||
read and verify immediately around the mutation; a session-start check is
|
||||
insufficient. If verification says it landed, stop even when the CLI
|
||||
looked unhappy. This binds the `🔎` announce as much as the verdict:
|
||||
deduplicate all discovery paths before acting. Duplicate verdicts on
|
||||
[#26](https://github.com/heavy-duty/ceremony/pull/26),
|
||||
[#29](https://github.com/heavy-duty/ceremony/pull/29), and
|
||||
[#39](https://github.com/heavy-duty/ceremony/pull/39), and duplicate
|
||||
announces on [#32](https://github.com/heavy-duty/ceremony/pull/32), bought
|
||||
the rule; do not answer a double-post with a third comment.
|
||||
- **Review each head in a throwaway checkout; keep the main clone clean.**
|
||||
Use a detached worktree per PR head and remove it after the verdict. A
|
||||
crashed build corrupted Claude's build clone in 2026-07-22
|
||||
([crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/knowledge.md));
|
||||
running another tree in the clone you keep risks the whole box.
|
||||
|
||||
## 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.
|
||||
- Flag an unowned decision when it belongs to a human: org policy, published
|
||||
artifacts, secrets, prod, or any choice whose cost lands outside the PR. A
|
||||
disagreement within the panel is one instance, not the definition
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Argue a
|
||||
panel disagreement in the PR with evidence until one side concedes or the
|
||||
builder escalates; two reviewers pulling a builder in opposite directions
|
||||
without resolution is a panel failure, not a builder failure.
|
||||
`needs-ruling` is set by the **builder**, never by you: one accountable
|
||||
flag-setter per PR hands the human one consolidated question. State the
|
||||
unowned decision precisely enough for the builder to write
|
||||
[the canonical ruling ask](BUILDER.md#the-ruling-ask), including what
|
||||
stops and what continues ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50);
|
||||
[LABELS.md](LABELS.md)).
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
# 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 pending thing is a decision only a human owns — org
|
||||
policy, published artifacts, secrets, prod, or any choice whose cost lands
|
||||
outside the work. A panel deadlock is one instance, not the definition
|
||||
([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Say
|
||||
precisely what the decision is, name the decider, and use
|
||||
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
|
||||
including its options, recommendation, blocked/continues statement, and
|
||||
reversible-only default rules ([#50 D12–D13](https://github.com/heavy-duty/ceremony/issues/50)).
|
||||
The discussion is where humans decide; wait there. When the decision
|
||||
blocks something already on the board — an existing issue, or minted work
|
||||
a discussion's ruling gates — set `needs-ruling` on it too, so the board
|
||||
shows where the human's turn is; the issue keeps its queue label.
|
||||
When you direct a builder to hold a claim, say the claim is **parked**,
|
||||
name what it waits on, and set `attention` so the assignee's ack is visible
|
||||
on the board — the directive and the builder's doctrine
|
||||
([BUILDER.md](BUILDER.md#claiming)) must use one word.
|
||||
Immediately before asserting label-borne state in prose — a hold, a
|
||||
claim, a queue state, whether in a comment, a body header, or a
|
||||
`needs-ruling` ask — re-read that issue's **label events**
|
||||
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
|
||||
comments: the answer often arrives as a label with no comment, and a
|
||||
write that re-read only the thread races it. Both 2026-07-24 failures —
|
||||
[a header correction on #149](https://github.com/heavy-duty/ceremony/issues/149#issuecomment-5070758613)
|
||||
asserting a hold 58 seconds after its lift, and
|
||||
[a `needs-ruling` ask on #151](https://github.com/heavy-duty/ceremony/issues/151#issuecomment-5070768876)
|
||||
the operator's label events had answered 132 seconds earlier — are this
|
||||
sentence's absence.
|
||||
Past 24 hours from the current episode's `labeled` event, if the ruling
|
||||
still stands and doubt remains, it is triage's duty to pick the option the
|
||||
builder proceeds on, record that pick as a decision, and stay accountable
|
||||
for it; the operator may overturn it at merge
|
||||
([#50 D13–D14](https://github.com/heavy-duty/ceremony/issues/50)). You set
|
||||
the flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
|
||||
agreement is reached, record the ruling as a decision in one comment,
|
||||
remove the label, and return the issue to its flow in that same comment;
|
||||
when that ruling or any directive or answered builder question delivers
|
||||
the assignee's next move in prose, set `attention` in the same comment.
|
||||
This is not a substitute for minting work or for `needs-ruling`.
|
||||
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.
|
||||
A criterion that can only be checked after the merge must carry its own
|
||||
mechanism, in the criterion itself: that it is post-merge, that triage
|
||||
owns the close, and that the PR references the issue with `Refs #N`
|
||||
rather than `Closes #N`. A criterion that survives the merge only if
|
||||
someone remembers to reopen the issue is an incomplete criterion — #137's
|
||||
amended body is the worked example, reopened by hand after `Closes #137`
|
||||
closed it with the criterion unmet (#151).
|
||||
- **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. Name a cross-repo dependency the same way with its
|
||||
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
|
||||
cannot resolve it, so triage verifies it and flips the issue by hand.
|
||||
- **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
|
||||
|
||||
- **Dedup before minting** — search issues *and* closed issues; extend or
|
||||
reopen before duplicating.
|
||||
- The issue-flow sweep flips `blocked` → `ready` when every named dependency
|
||||
lands, and flags a blocked issue whose dependency declaration is unreadable.
|
||||
- The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
|
||||
+ no activity → comment, unassign, restore `ready`.
|
||||
- Automation never guesses intent. Resolve the conflict comments it leaves on
|
||||
malformed queue states, and close or extend completed epics when nudged.
|
||||
- **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.
|
||||
- **A lifted hold makes its body prose stale in the same instant, and the
|
||||
body is yours.** The "stays true" bar above extends past the labels to
|
||||
the prose that describes them: when a hold lifts, correcting the body
|
||||
header that described it is your move in the same tick — not the
|
||||
builder's, and not left for the next reader to diff. On
|
||||
[#149](https://github.com/heavy-duty/ceremony/issues/149) the lift
|
||||
arrived by label alone and the body said held for the next five and a
|
||||
half minutes; two builders read that window to opposite conclusions.
|
||||
|
||||
## 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.
|
||||
20
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
20
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
|
|
@ -1,20 +0,0 @@
|
|||
# Copied from heavy-duty/ceremony@0.3.0 .github/DISCUSSION_TEMPLATE/ideas.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
|
||||
# Light on purpose: discussions are where ambiguity is ALLOWED — a form
|
||||
# that demands rigor at the door defeats the room's purpose (heavy-duty/ceremony#24,
|
||||
# decision 4). If these prompts fight the flow in practice, delete them
|
||||
# before adding fields to them.
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What's the itch?
|
||||
description: >-
|
||||
Vague is fine — a bug, an idea, a "we should…". Triage turns this
|
||||
into work (or an answer); you don't have to.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What would "done" feel like?
|
||||
description: Optional — a sketch of the outcome, if you can already see it.
|
||||
validations:
|
||||
required: false
|
||||
16
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
16
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
|
|
@ -1,16 +0,0 @@
|
|||
# Copied from heavy-duty/ceremony@0.3.0 .github/DISCUSSION_TEMPLATE/q-a.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
|
||||
# Light on purpose — same reasoning as ideas.yml (heavy-duty/ceremony#24, decision 4):
|
||||
# optional prompts only, delete before hardening.
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What's the question?
|
||||
description: Ask it plainly — context and links help, none are required.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What have you tried or read already?
|
||||
description: Optional — saves the answerer a lap.
|
||||
validations:
|
||||
required: false
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
14
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,14 +0,0 @@
|
|||
# Copied from heavy-duty/ceremony@0.3.0 .github/ISSUE_TEMPLATE/config.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
|
||||
# The new-issue interception (heavy-duty/ceremony#24, decision 1): interception over
|
||||
# instruction — prose alone has already proven insufficient everywhere else
|
||||
# in this org. With blank issues disabled and exactly one form, a "New
|
||||
# issue" click shows the discussion link first; that auto-suggest is native
|
||||
# GitHub behavior, no automation needed.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ideas, bugs, questions — start a Discussion
|
||||
url: https://github.com/heavy-duty/rig/discussions
|
||||
about: >-
|
||||
Humans (and agents) never file issues here — discussions are where
|
||||
intent lives. Triage converges every discussion to an outcome, and
|
||||
only triage mints issues (.ceremony/TRIAGE.md).
|
||||
76
.github/ISSUE_TEMPLATE/work-order.yml
vendored
76
.github/ISSUE_TEMPLATE/work-order.yml
vendored
|
|
@ -1,76 +0,0 @@
|
|||
# Copied from heavy-duty/ceremony@0.3.0 .github/ISSUE_TEMPLATE/work-order.yml (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
|
||||
# The issue contract (.ceremony/TRIAGE.md "The issue contract") as a form, so every
|
||||
# minted issue lands with the same bones and a later parser (heavy-duty/ceremony#18) can key on
|
||||
# stable section headings. Consistency, not a gate: the form applies no
|
||||
# labels — queue labels are triage's explicit act (.ceremony/LABELS.md), and heavy-duty/ceremony#18's
|
||||
# sweep is what catches non-triage authors, so the form must not pre-judge
|
||||
# that (heavy-duty/ceremony#24, decision 2).
|
||||
name: Work order (triage only)
|
||||
description: >-
|
||||
The issue contract (.ceremony/TRIAGE.md) as a form. Only triage mints issues —
|
||||
everyone else starts a Discussion. Triage may still compose by hand when
|
||||
the form fights it: `gh issue create --body-file` bypasses forms and stays
|
||||
legitimate for the triage identity.
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >-
|
||||
**The title names the deliverable** — "lib/version.sh — one version
|
||||
abstraction, two backends", never "improve version handling". The
|
||||
bar for everything below, stated once: a competent builder who has
|
||||
read only this issue and the repo can succeed (.ceremony/TRIAGE.md). Labels
|
||||
(type, `scope:*`, `ready`/`blocked`) are your explicit act after
|
||||
minting — this form applies none.
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: >-
|
||||
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.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: spec
|
||||
attributes:
|
||||
label: Spec
|
||||
description: >-
|
||||
Decisions made, not options listed. If the spec still has an open
|
||||
question, the issue is not ready to exist.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: tasks
|
||||
attributes:
|
||||
label: Tasks
|
||||
description: The steps, checkboxed, in order.
|
||||
placeholder: "- [ ] the first step"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: acceptance-criteria
|
||||
attributes:
|
||||
label: Acceptance criteria
|
||||
description: >-
|
||||
Checkboxed, verifiable, and honest — these become the builder's
|
||||
definition of done and the reviewer's review spec, verbatim.
|
||||
placeholder: "- [ ] a verifiable outcome"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: test-plan
|
||||
attributes:
|
||||
label: Test plan
|
||||
description: What proves it, including the cases that must fail.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: dependencies
|
||||
attributes:
|
||||
label: Dependencies
|
||||
description: >-
|
||||
`Blocked by #N` / `Blocks #N`, and `Part of #E` when an epic
|
||||
organizes it — or state "None".
|
||||
validations:
|
||||
required: true
|
||||
21
.github/labeler.yml
vendored
21
.github/labeler.yml
vendored
|
|
@ -21,24 +21,3 @@
|
|||
"scope:installer":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["install.sh"]
|
||||
"scope:platform":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["commands/platform.sh"]
|
||||
"scope:drill":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["drill/**", "drills/**"]
|
||||
"scope:docs":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "README.md"
|
||||
- "CONTRIBUTING.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/DISCUSSION_TEMPLATE/**"
|
||||
- ".github/pull_request_template.md"
|
||||
"scope:labels":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- ".github/labels.conf"
|
||||
- ".github/labeler.yml"
|
||||
- ".github/workflows/labels.yml"
|
||||
|
|
|
|||
12
.github/labels.conf
vendored
12
.github/labels.conf
vendored
|
|
@ -1,12 +0,0 @@
|
|||
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl
|
||||
triage-actors=dan-claude-bot
|
||||
scope:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
|
||||
scope:users|C5DEF5|users-* — class model, apply/status, close-root
|
||||
scope:runner|C5DEF5|runner-* — GitHub runner lifecycle
|
||||
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
|
||||
scope:db|C5DEF5|db.sh — dump/restore
|
||||
scope:installer|C5DEF5|install.sh — how rig lands on a machine
|
||||
scope:platform|C5DEF5|rig platform — machine facts and identity, converged or not
|
||||
scope:drill|C5DEF5|drill/ and drills/ — the harness, its legs and its records
|
||||
scope:docs|C5DEF5|README, CONTRIBUTING, docs/ — the prose contract
|
||||
scope:labels|C5DEF5|the labels workflow, the reconciler, labels.conf and labeler.yml
|
||||
21
.github/pull_request_template.md
vendored
21
.github/pull_request_template.md
vendored
|
|
@ -1,21 +0,0 @@
|
|||
<!-- Copied from heavy-duty/ceremony@0.3.0 .github/pull_request_template.md (heavy-duty/ceremony#24). Hand-maintained: no guard checks this against ceremony.
|
||||
Short on purpose: a template longer than a small PR gets deleted by
|
||||
reflex, not read (heavy-duty/ceremony#24). One issue, one PR — draft while building. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
<!-- The issue's checkboxes, VERBATIM — reviewers diff this against the
|
||||
issue. Check them honestly as you go; a criterion you can't check goes back
|
||||
to the issue for triage to amend, not silently unshipped. -->
|
||||
|
||||
## Changelog
|
||||
|
||||
- [ ] One `changelog.d/<issue>.md` fragment containing the exact publishable
|
||||
prose — or no behavior change, stated here.
|
||||
|
||||
## Round log
|
||||
|
||||
<!-- Append each round's summary here: what changed, what was verified.
|
||||
Rounds are answered whole — one reply covering every point. -->
|
||||
244
.github/scripts/changelog-monotonic.sh
vendored
Executable file
244
.github/scripts/changelog-monotonic.sh
vendored
Executable file
|
|
@ -0,0 +1,244 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# changelog-monotonic.sh [<base-ref>] [<changelog>] — assert that no SHIPPED
|
||||
# release heading was DELETED by this branch: the set of '^## X.Y.Z' headings
|
||||
# on HEAD must be a SUPERSET of the set at the merge base.
|
||||
#
|
||||
# The failure it exists to catch (#98; ported from heavy-duty/box#122, which
|
||||
# was caught in review of box#118) leaves no trace. An author adding an entry
|
||||
# under '## Unreleased' REPLACES the line below it instead of inserting above
|
||||
# it:
|
||||
#
|
||||
# -## 0.2.0 — 2026-07-19
|
||||
# +## Unreleased
|
||||
# +
|
||||
# +### Fixed
|
||||
# +
|
||||
# +- **An entry**
|
||||
#
|
||||
# git merges that cleanly — it is a one-line edit inside a file nobody has
|
||||
# touched concurrently — so there is no conflict and no signal. 0.2.0's whole
|
||||
# body is now sitting under '## Unreleased', and 0.2.0 has no section at all.
|
||||
#
|
||||
# The arming rule is green on exactly that tree, correctly. changelog_armed()
|
||||
# in test/release.sh asks only whether the TOP section agrees with VERSION,
|
||||
# and deleting '## 0.2.0' leaves '## Unreleased' on top. It is not wrong, it
|
||||
# is narrow — it guards ONE heading, the one a PR is about to write under.
|
||||
# This guards the REST of the file, the part no single tree can be asked
|
||||
# about at all, because "a heading disappeared" is not a property of a tree —
|
||||
# it is a property of a DIFF.
|
||||
#
|
||||
# The damage surfaces at the next release, in changelog_section()
|
||||
# (.github/scripts/release-lib.sh), which anchors on the heading:
|
||||
#
|
||||
# awk -v ver="$2" '
|
||||
# /^## / { if (found) exit; found = ($2 == ver); next }
|
||||
# ...
|
||||
#
|
||||
# No heading, no section — and release.yml's "refusing to publish an empty
|
||||
# release" assert is the first thing that notices, one whole release too late.
|
||||
#
|
||||
# The rule, and why it needs no tuning: release headings are APPEND-ONLY. The
|
||||
# ceremony (CONTRIBUTING, "Releases") adds one and never removes one; nothing
|
||||
# else in the documented flow touches them. So SUPERSET is exact — it has no
|
||||
# legitimate violation to carve an exception for. The stamp is covered for
|
||||
# free: rewriting '## Unreleased' -> '## X.Y.Z — DATE' ADDS X.Y.Z and removes
|
||||
# no X.Y.Z heading, because 'Unreleased' is not one. '## Unreleased' is
|
||||
# deliberately NOT in the set this guards — the arming rule owns that heading,
|
||||
# keyed on VERSION, and the ceremony legitimately consumes it.
|
||||
#
|
||||
# A file of its own, NOT a clause inside test/release.sh's arming check, for
|
||||
# three reasons. Its input is different (a git history, not two files). Its
|
||||
# degradation is different (no base ref is a SKIP, not a failure). And the
|
||||
# arming rule is driven by test/release.sh against constructed VERSION +
|
||||
# CHANGELOG.md trees that are not git repos at all — folding a git-dependent
|
||||
# assert into it would make every one of those cases either skip or lie.
|
||||
# Same discipline as release-lib.sh: its own file so a test can drive it.
|
||||
|
||||
base_ref="${1:-${CHANGELOG_MONOTONIC_BASE:-origin/main}}"
|
||||
changelog="${2:-CHANGELOG.md}"
|
||||
|
||||
# Fail-closed switch: CI sets it, so a SKIP that would be a sensible local
|
||||
# degradation becomes a red run there instead. A guard that can silently
|
||||
# stop guarding is the failure shape this whole family of checks exists to
|
||||
# refuse, so the skip path is loud and CI refuses to take it at all.
|
||||
strict="${CHANGELOG_MONOTONIC_STRICT:-0}"
|
||||
|
||||
skip() {
|
||||
if [ "$strict" = "1" ]; then
|
||||
echo "changelog-monotonic: $* — and CHANGELOG_MONOTONIC_STRICT=1, so this is a FAILURE, not a skip." >&2
|
||||
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
|
||||
echo " (Uniqueness on HEAD already passed; it is containment that cannot run.)" >&2
|
||||
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "changelog-monotonic: containment SKIPPED — $*"
|
||||
echo " (Uniqueness on HEAD already ran and passed — only the deleted-heading"
|
||||
echo " half needs the history. In CI this same condition is a hard failure.)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
[ -f "$changelog" ] || { echo "changelog-monotonic: no such file: $changelog" >&2; exit 1; }
|
||||
|
||||
# The set of RELEASE headings: '## <token> ...' where <token> looks like a
|
||||
# version. Field $2, the same split changelog_section() uses, so the two
|
||||
# cannot disagree about what a section header is. 'Unreleased' fails the
|
||||
# shape and is excluded by construction.
|
||||
headings_raw() {
|
||||
awk '
|
||||
/^## / && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+/ { print $2 }
|
||||
'
|
||||
}
|
||||
headings() { headings_raw | sort -u; }
|
||||
|
||||
# --- uniqueness on HEAD (the box#118 class) ----------------------------------
|
||||
# Containment catches a DELETED heading. It cannot catch a DUPLICATED one: the
|
||||
# duplicate is head-side SURPLUS, and `comm -23` (base minus head) is blind to
|
||||
# extras on the head side — with or without `sort -u`, base {0.2.0} minus head
|
||||
# {0.2.0, 0.2.0} is empty. Multiset comparison does not close it either, for
|
||||
# the same reason. The assert that does is uniqueness of version headings ON
|
||||
# HEAD, kept alongside containment rather than replacing it.
|
||||
#
|
||||
# This is the shape box#118's bad rebase produced: two `## 0.2.0 — 2026-07-19`
|
||||
# headings with an incoming entry between them. Every other guard stays green
|
||||
# — conflict markers absent, the arming rule happy (the top section is still
|
||||
# right), tests and shellcheck clean.
|
||||
#
|
||||
# rig's symptom differs from box's, and the difference matters. box's
|
||||
# release-notes.sh RE-ARMS its grab on every matching '## ' line, so a
|
||||
# duplicate makes it ABSORB whatever sits between the copies. rig's
|
||||
# changelog_section() has `if (found) exit`, so it stops dead at the second
|
||||
# copy instead: a duplicate TRUNCATES. The published body is only what sits
|
||||
# BETWEEN the two headings, and everything under the second copy — the real
|
||||
# body of that release — is silently dropped. Different symptom, same class:
|
||||
# no conflict, no red run, discovered only by a human reading the published
|
||||
# notes.
|
||||
#
|
||||
# Nothing legitimate repeats a version heading: the ceremony stamps a NEW
|
||||
# version, and 'Unreleased' fails the version shape and never reaches here.
|
||||
dupes="$(headings_raw < "$changelog" | sort | uniq -d)"
|
||||
if [ -n "$dupes" ]; then
|
||||
{
|
||||
echo "changelog-monotonic: $changelog has DUPLICATE release heading(s):"
|
||||
echo
|
||||
printf '%s\n' "$dupes" | sed 's/^/ ## /'
|
||||
echo
|
||||
cat <<EOF
|
||||
Each version heading must appear exactly once. A repeat splits one release
|
||||
into two same-named sections, and changelog_section() stops at the FIRST
|
||||
'## ' line after the one it matched — so the published body for that version
|
||||
is only what sits BETWEEN the copies, and the real body under the second
|
||||
copy is dropped from the release notes entirely.
|
||||
|
||||
This is the box#118 shape: an entry meant for '## Unreleased' was inserted
|
||||
after a shipped heading, and the heading re-added below it. The fix is one
|
||||
heading, with the entry above it under '## Unreleased':
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$dupes" | head -1) — DATE <- exactly once
|
||||
|
||||
Quick check on any changelog-touching rebase:
|
||||
|
||||
diff <(git show origin/main:$changelog | grep '^## ') <(grep '^## ' $changelog)
|
||||
EOF
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- everything below needs the HISTORY --------------------------------------
|
||||
# Uniqueness is settled. What follows is containment, which compares HEAD
|
||||
# against the merge base and therefore genuinely depends on the base ref, the
|
||||
# merge base, and the base blob. Each of those can be unavailable for reasons
|
||||
# that are not the author's fault (a shallow clone, a fork checkout without the
|
||||
# upstream remote, the commit that first adds the changelog), so each degrades
|
||||
# rather than failing — which is exactly why the uniqueness half must NOT live
|
||||
# down here (#98; fixed upstream in heavy-duty/box#143, where rig's copy of
|
||||
# this script came from). It asks nothing of the history, and gating it behind
|
||||
# these conditions let a duplicate exit 0 on a message about deletion.
|
||||
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|
||||
|| skip "not inside a git work tree, so there is no history to compare against"
|
||||
|
||||
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|
||||
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
|
||||
|
||||
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
|
||||
[ -n "$merge_base" ] \
|
||||
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
|
||||
|
||||
# The changelog may not exist at the merge base at all (the commit that adds
|
||||
# it). Nothing to have deleted, so nothing to assert.
|
||||
base_file="$(git show "$merge_base:$changelog" 2>/dev/null || true)"
|
||||
[ -n "$base_file" ] || {
|
||||
echo "changelog-monotonic: $changelog does not exist at the merge base ($(git rev-parse --short "$merge_base")) — nothing could have been deleted (uniqueness on HEAD already passed)."
|
||||
exit 0
|
||||
}
|
||||
|
||||
base_headings="$(printf '%s\n' "$base_file" | headings)"
|
||||
head_headings="$(headings < "$changelog")"
|
||||
|
||||
# comm -23: lines in the base set that are NOT in the head set — exactly the
|
||||
# headings this branch removed.
|
||||
missing="$(comm -23 <(printf '%s\n' "$base_headings") <(printf '%s\n' "$head_headings"))"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
{
|
||||
echo "changelog-monotonic: this branch DELETES release heading(s) from $changelog:"
|
||||
echo
|
||||
printf '%s\n' "$missing" | sed 's/^/ ## /'
|
||||
echo
|
||||
cat <<EOF
|
||||
Present at the merge base ($(git rev-parse --short "$merge_base")), absent on HEAD.
|
||||
|
||||
Release headings are APPEND-ONLY. The ceremony adds one (CONTRIBUTING,
|
||||
"Releases"); nothing ever legitimately removes one. So this is not a
|
||||
judgement call — it is a defect, and almost always the same one (#98): an
|
||||
entry written under '## Unreleased' REPLACED the heading below it instead of
|
||||
being inserted ABOVE it. The shipped section's body is now sitting under
|
||||
'## Unreleased', and the version it belonged to has no section at all.
|
||||
|
||||
Nothing else will say so. git merges that edit cleanly — no conflict, no
|
||||
signal — and the arming rule stays green, because the TOP section is still
|
||||
the right one for this VERSION. The damage surfaces at the NEXT release,
|
||||
when changelog_section() cannot find the section it extracts by heading and
|
||||
release.yml refuses to publish an empty release — one whole release late.
|
||||
|
||||
The fix is to put the heading back and INSERT above it, never over it:
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Your entry**
|
||||
|
||||
## $(printf '%s\n' "$missing" | head -1) — DATE <- untouched, still here
|
||||
|
||||
If you are genuinely renaming a released version, that is a rewrite of
|
||||
history this guard is meant to stop; say so in the PR and change the guard
|
||||
deliberately, in its own commit.
|
||||
EOF
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
count="$(printf '%s\n' "$base_headings" | grep -c . || true)"
|
||||
head_count="$(printf '%s\n' "$head_headings" | grep -c . || true)"
|
||||
|
||||
# The success line has two honest forms, because this step now runs on two
|
||||
# shapes of event. On a push to main the merge base IS HEAD: containment
|
||||
# compared the file against itself and asserted nothing, and deletion is
|
||||
# undetectable on that event by construction. Reporting "all N still present"
|
||||
# there would be the same dishonesty the skip messages were fixed for in #98 —
|
||||
# a log claiming a check that did no work. Uniqueness is the half that actually
|
||||
# ran, so that is the half the line names.
|
||||
if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then
|
||||
echo "changelog-monotonic: containment vacuous (the merge base IS HEAD, so nothing could have been deleted between them) — uniqueness on HEAD checked $head_count release heading(s)."
|
||||
else
|
||||
echo "changelog-monotonic: all $count release heading(s) at the merge base ($(git rev-parse --short "$merge_base")) are still present in $changelog"
|
||||
fi
|
||||
166
.github/scripts/drill-recorded.sh
vendored
Executable file
166
.github/scripts/drill-recorded.sh
vendored
Executable file
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# drill-recorded.sh [<drills-dir>] [<version-file>] — assert that the version
|
||||
# this tree is about to ship has a DRILL RECORD at <drills-dir>/<version>.md.
|
||||
#
|
||||
# defaults: drills VERSION
|
||||
#
|
||||
# CONTRIBUTING ("Releasing") says a release carries a real-hardware drill.
|
||||
# Nothing enforced it, so no release in this family has ever had one: the
|
||||
# ceremony is four correct mechanical steps — bump VERSION, stamp the
|
||||
# changelog, re-arm, merge — and every one of them is checked by a script,
|
||||
# while the one step that costs an afternoon on real hardware was checked by
|
||||
# a reviewer remembering. Reviewers remember exactly as long as the release is
|
||||
# interesting, which is never at 0.4.3. A bot finally blocked on it; this is
|
||||
# that block, moved into CI where it does not depend on anyone's attention.
|
||||
#
|
||||
# ONE FILE PER VERSION, which is what this script is now mostly about. The
|
||||
# first cut of this guard kept every record as a section inside one
|
||||
# drill/RUNS.md, and paid for it: it needed an awk extractor that matched a
|
||||
# literal '## Release drill — ' prefix, tolerated an optional ' — DATE' tail,
|
||||
# compared the version WHOLE so that '0.3.0-rc1' could not answer for '0.3.0',
|
||||
# and then separately insisted the extracted body hold a non-blank line. Every
|
||||
# one of those rules existed only because records shared a file. Both sibling
|
||||
# repos shipped a DEFECT out of that complexity during review — a
|
||||
# `sed '/./,$!d'` extractor where `.` matches a space, so a heading plus one
|
||||
# tab satisfied the gate (box#149, cast#138), and heading-grammar drift on the
|
||||
# other side. Splitting the records makes nearly all of it unrepresentable:
|
||||
# `0.3.0.md` and `0.3.0-rc1.md` are simply different files, there is no
|
||||
# heading to parse and no grammar to drift, and the whole-version comparison
|
||||
# is done by the filesystem.
|
||||
#
|
||||
# PER-REPO, and that is the load-bearing design decision. The obvious
|
||||
# alternative — have rig ask box's repo whether the drill ran — cannot fail
|
||||
# safely: the lookup needs a network call, a token, and a checkout that may be
|
||||
# a fork, and every one of those failure modes lands on "could not read", which
|
||||
# a naive implementation spells `|| true` and reads as PASS. That is exactly
|
||||
# the UNREADABLE-vs-NONE bug #90 fixed one layer up (an unreadable check rollup
|
||||
# reading as "nothing is failing"), and re-introducing it in the release gate
|
||||
# would be worse: it degrades to green on precisely the tree that ships. So rig
|
||||
# records rig's own legs in rig's own repo, and this script reads a file that
|
||||
# is either in the checkout or is not.
|
||||
#
|
||||
# The directory is `drills/`, NOT `.drills/`. A dot-directory is invisible to
|
||||
# every glob that has not set `dotglob`, which is how #70 here and box#116 /
|
||||
# box#118 all happened: a file that exists but that no sweep can see is worse
|
||||
# than no file, because it reads as covered.
|
||||
#
|
||||
# What it asserts is a RECORD, not a RESULT — and that is deliberate, not a
|
||||
# weakness. A gate that demanded "the drill passed" would have to parse
|
||||
# somebody's prose for a verdict, and would leave a maintainer who consciously
|
||||
# ships without a full drill (a doc-only release, a hardware outage) with no
|
||||
# move except deleting the check. Requiring a record means the waiver is
|
||||
# WRITTEN DOWN, in a file named for the version it applies to, in a commit a
|
||||
# reviewer sees. Skipping stays possible; skipping silently does not.
|
||||
#
|
||||
# Vacuous on a `-dev` tree, which is why it needs no trigger scoping in
|
||||
# ci.yml (unlike changelog-monotonic.sh, whose input is a diff): every ordinary
|
||||
# PR carries a `-dev` VERSION and passes without a drill record existing at
|
||||
# all. The check has something to say on exactly one tree — the release
|
||||
# ceremony PR — and that is the tree it must be impossible to merge without.
|
||||
|
||||
drills="${1:-drills}"
|
||||
version_file="${2:-VERSION}"
|
||||
|
||||
# An unreadable version file is an ERROR, never a silent pass. There is no
|
||||
# version to be lenient about, so leniency here could only mean "ship
|
||||
# unevidenced" — the exact degradation the per-repo decision above exists to
|
||||
# avoid.
|
||||
[ -f "$version_file" ] || {
|
||||
echo "drill-recorded: no such file: $version_file" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
version="$(tr -d '[:space:]' < "$version_file")"
|
||||
[ -n "$version" ] || {
|
||||
echo "drill-recorded: $version_file is empty — there is no version to check a drill against." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The -dev half. A development tree is not shipping anything, so there is
|
||||
# nothing to evidence; saying so out loud (rather than exiting 0 in silence)
|
||||
# is the #98 lesson — a guard that prints nothing is indistinguishable from a
|
||||
# guard that did nothing.
|
||||
case "$version" in
|
||||
*-dev)
|
||||
echo "drill-recorded: VERSION is $version — a development tree has nothing to assert (the drill gates a RELEASE, and this is not one)."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
record="$drills/$version.md"
|
||||
|
||||
# WHITESPACE IS NOT A RECORD. This is the one surviving piece of the rule set
|
||||
# the old section-parsing guard needed, and it survives because it is the one
|
||||
# part that splitting the files does not make unrepresentable: an empty file,
|
||||
# or a file holding only spaces, tabs and newlines, exists at the right path
|
||||
# and is still no evidence. It is the same property box#149 and cast#138 both
|
||||
# got wrong with `sed '/./,$!d'` (`.` matches a space), where a record of one
|
||||
# tab shipped an evidence-free release. `grep -q '[^[:space:]]'` is the whole
|
||||
# check now, with no extractor in front of it to get wrong.
|
||||
#
|
||||
# The negated form below, matching box's and cast's twins exactly, so there is
|
||||
# no divergence between the three to explain.
|
||||
#
|
||||
# It also avoids a real `set -e` hazard, which is worth naming precisely
|
||||
# because an earlier draft of this comment named it BACKWARDS. A bare
|
||||
# `[ -f "$record" ] && grep -q ... "$record"` mid-script does NOT abort when
|
||||
# the file is missing: the left-hand side of `&&` is exempt from errexit, so a
|
||||
# miss simply continues. What DOES abort is the other case — the file exists
|
||||
# and `grep` finds nothing, i.e. exactly the whitespace-only record this guard
|
||||
# is here to refuse. The script would die on its most interesting input,
|
||||
# before printing the message that explains it.
|
||||
#
|
||||
# Verified rather than reasoned about:
|
||||
# bash -ec '[ -f /nonexistent ] && r=yes; echo reached' -> prints, exit 0
|
||||
# bash -ec 'f=$(mktemp); echo " " >"$f"
|
||||
# [ -f "$f" ] && grep -q "[^[:space:]]" "$f"
|
||||
# echo reached' -> silent, exit 1
|
||||
#
|
||||
# Caught by all three reviewers on #104. The lesson is the same one #149 and
|
||||
# cast#138 taught: this family's comments get read as contracts, so a comment
|
||||
# that misstates the semantics is a defect even when the code is correct.
|
||||
if [ ! -f "$record" ] || ! grep -q '[^[:space:]]' "$record"; then
|
||||
{
|
||||
echo "drill-recorded: VERSION is $version, and there is no drill record at $record."
|
||||
echo
|
||||
cat <<EOF
|
||||
This tree is a release ceremony tree — VERSION is bare, so merging it ships
|
||||
$version. CONTRIBUTING ("Releasing") requires that release to carry a real
|
||||
hardware drill, recorded in a file named for the version, exactly:
|
||||
|
||||
$drills/$version.md
|
||||
|
||||
One file per version, so the name IS the match: a record for
|
||||
$version-rc1 lives at a different path and does not count. The file must
|
||||
hold at least one non-whitespace character — an empty file, or one of only
|
||||
spaces and tabs, is not a record.
|
||||
|
||||
Two ways to unblock, and both are a commit on this PR:
|
||||
|
||||
1. RUN THE DRILL and record it. What ran, on what hardware, the numbers,
|
||||
and what failed. rig's drill asserts CONVERGENCE — a machine reaches
|
||||
its role, idempotently — against a PINNED set of candidate refs
|
||||
(RIG_REPO/RIG_REF and BOX_REF are mint-time variables, so the run pins
|
||||
the commits under test). Drilling the candidate IS drilling the
|
||||
release, since a release PR's diff is VERSION + CHANGELOG.md and
|
||||
nothing executable differs. Cite the run ID and the other repos' SHAs.
|
||||
The three repos' drills are independent — rig's does not wait on box's.
|
||||
|
||||
2. RECORD AN EXPLICIT MAINTAINER WAIVER in that same file, saying who
|
||||
waived it and why. This guard asks for a RECORD, not a passing result,
|
||||
so a deliberate skip is allowed — it just has to be visible and
|
||||
reviewable rather than silent.
|
||||
|
||||
See $drills/README.md for what a record should contain.
|
||||
|
||||
Do not delete this step to get green. A release that cannot say what was
|
||||
drilled is the state this check exists to end.
|
||||
EOF
|
||||
} >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lines="$(grep -c . "$record" || true)"
|
||||
echo "drill-recorded: $record records a drill for $version ($lines non-blank line(s))."
|
||||
486
.github/scripts/labels-reconcile.sh
vendored
Normal file
486
.github/scripts/labels-reconcile.sh
vendored
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
#!/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)
|
||||
BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested)
|
||||
# Labels this machine used to own and no longer does. Cleared on sight so a
|
||||
# retirement heals the board instead of stranding a label nothing recomputes.
|
||||
RETIRED=(state:needs-rebase)
|
||||
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
|
||||
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
|
||||
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
||||
|
||||
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
|
||||
# UNREADABLE is the absence of the key itself, which is what a failed fetch
|
||||
# leaves behind — distinct from a present-but-empty rollup, which honestly
|
||||
# means this PR has no checks. Collapsing the two let an API hiccup present
|
||||
# as "nothing is failing", i.e. as mergeable-by-a-human: the same
|
||||
# unknown-certified-as-green shape as the bug this machine exists to stop.
|
||||
# The caller skips the PR entirely rather than labelling on facts it did not
|
||||
# read; blocking on it instead would flap the whole board on one bad call.
|
||||
# The rollup mixes two node types with two different closed enums: CheckRun
|
||||
# carries `conclusion` (CheckConclusionState), StatusContext carries `state`
|
||||
# (StatusState). Rather than list the outcomes that block — the version that
|
||||
# shipped in this PR's first round listed four, and ERROR, CANCELLED and
|
||||
# STALE fell through its `else` into SUCCESS — this lists the outcomes that
|
||||
# DON'T, and treats everything else as blocking.
|
||||
#
|
||||
# That direction is the point. An outcome we do not recognise is one we
|
||||
# cannot certify as mergeable, and certifying the unrecognised as green is
|
||||
# the exact shape of #136. The cost of being wrong is symmetric in form and
|
||||
# not in consequence: a false FAILURE parks the PR on the agent, who looks;
|
||||
# a false SUCCESS invites a human to merge a tree that will not merge.
|
||||
jq -r '
|
||||
if (has("statusCheckRollup") | not) then "UNREADABLE" else
|
||||
|
||||
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
|
||||
# is not a failed one, and path-filtered jobs skip constantly here.
|
||||
["SUCCESS", "NEUTRAL", "SKIPPED"] as $passing
|
||||
# "" covers a StatusContext still reported with no state at all.
|
||||
| ["", "PENDING", "IN_PROGRESS", "QUEUED", "WAITING", "REQUESTED", "EXPECTED"] as $waiting
|
||||
|
||||
# A re-run does not evict the run it superseded — the rollup keeps both.
|
||||
# This PR proved it: its own tip carried a CANCELLED `scope` (15:19:39)
|
||||
# beside the SUCCESS `scope` (15:19:45) that replaced it, same workflow.
|
||||
# Once CANCELLED blocks, judging every entry would strand this very PR in
|
||||
# needs-rebase forever, so collapse each context to its newest entry first.
|
||||
# Key on workflow + name because a bare job name is only unique within its
|
||||
# workflow.
|
||||
#
|
||||
# Dating a run is the subtle part, and getting it wrong restores the bug.
|
||||
# A run still in flight has no completion, but `gh` does not omit the
|
||||
# field: its Go struct marshals the zero time as "0001-01-01T00:00:00Z",
|
||||
# which is a string, so `//` will not fall through it. Ordering on
|
||||
# completion therefore sorted the LIVE re-run to the bottom and let `last`
|
||||
# pick the very run it superseded — reporting the old SUCCESS while a
|
||||
# replacement was still running, which is #136 again.
|
||||
#
|
||||
# So: date a run by when it BEGAN, discarding both spellings of absent
|
||||
# (null, and the zero sentinel) and falling back only if it never recorded
|
||||
# a beginning. NOT by the newest stamp of any kind: `max` compares the
|
||||
# completion of a finished run against the start of a live one, which are
|
||||
# different quantities and not an ordering on runs. A run cancelled by the
|
||||
# concurrency group does not stop the instant its replacement starts — the
|
||||
# runner has to wind down — so predecessor.completedAt > successor.startedAt
|
||||
# is the ordinary case, and `max` dated the dead predecessor newer than the
|
||||
# live run that replaced it, narrowing both failures above without closing
|
||||
# them. The list is already in preference order, so `first` IS that rule.
|
||||
#
|
||||
# An entry that carries no usable timestamp at all sorts LAST rather than
|
||||
# first — something we cannot date is most likely the thing just created,
|
||||
# and treating it as newest keeps an undateable in-flight run from being
|
||||
# discarded in favour of a stale success. Every ambiguity resolves toward
|
||||
# "not settled".
|
||||
| [ (.statusCheckRollup // [])[]
|
||||
| { ctx: [.workflowName // "", .name // .context // ""],
|
||||
at: ([.startedAt, .createdAt, .completedAt]
|
||||
| map(select(type == "string" and . != ""
|
||||
and (startswith("0001-01-01") | not)))
|
||||
| first // ""),
|
||||
outcome: ((.conclusion // .state // "") | ascii_upcase) } ]
|
||||
| group_by(.ctx)
|
||||
| map(sort_by([(.at == ""), .at]) | last | .outcome) as $latest
|
||||
|
||||
| if ($latest | length) == 0 then "NONE"
|
||||
elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE"
|
||||
elif (($latest - $passing) | length) > 0 then "PENDING"
|
||||
else "SUCCESS" end
|
||||
|
||||
end'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
blockers() { # → the blocker:* labels this PR should carry, one per line
|
||||
# The second axis. These are FACTS ABOUT THE BRANCH, and they are mutually
|
||||
# independent — a PR can be conflicted and red and unasked at once — so they
|
||||
# are a set, not an ordering. That is the whole point of splitting them out
|
||||
# of state:*: every precedence bug this machine has had (needs-human
|
||||
# surviving a conflict, MISSING swallowing STALE) came from projecting
|
||||
# independent facts onto one totally-ordered label. A set has no precedence
|
||||
# to get wrong.
|
||||
#
|
||||
# UNKNOWN mergeability is deliberately NOT a conflict: GitHub reports it for
|
||||
# about a minute after every merge while it recomputes, and flapping every
|
||||
# open PR on each merge would be worse than the bug. Same for a failed read
|
||||
# of either fact — both default to the "do not know" value, which blocks
|
||||
# nothing. An unset global (an older fixture, a failed fetch) must never
|
||||
# invent a verdict it did not read.
|
||||
case "${MERGEABLE:-UNKNOWN}" in CONFLICTING) echo blocker:conflict ;; esac
|
||||
case "${CHECKS:-NONE}" in FAILURE) echo blocker:ci-red ;; esac
|
||||
|
||||
# Nobody is on the hook for a verdict somebody still owes. Distinct from
|
||||
# bots-reviewing, which says a request is live and an answer is coming:
|
||||
# here the round is stalled because no one was ever asked, and the board
|
||||
# said "waiting on the bots" for the 48h it took `stale` to notice.
|
||||
# A draft is exempt (the bots ignore drafts by design), and so is an
|
||||
# explicit human request — a maintainer claiming a PR early is deliberate,
|
||||
# not a dropped ball.
|
||||
if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then
|
||||
local b v owed=false any_requested=false
|
||||
for b in "${BOTS[@]}"; do
|
||||
requested "$b" && any_requested=true
|
||||
# MISSING and STALE are both verdicts this head does not have: nobody
|
||||
# reviewed it, or everybody reviewed something else. The agent owes an
|
||||
# ask either way — the stale round is if anything the worse of the two,
|
||||
# since it has approvals on the page that no longer describe the tree.
|
||||
v="$(bot_verdict "$b")"
|
||||
case "$v" in MISSING | STALE) owed=true ;; esac
|
||||
done
|
||||
if [ "$owed" = true ] && [ "$any_requested" = false ]; then
|
||||
echo blocker:unrequested
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
decide_state() { # → the one state:* label this PR should carry
|
||||
if [ "$DRAFT" = true ]; then echo state:building; return; fi
|
||||
|
||||
local s
|
||||
s="$(round_state)"
|
||||
|
||||
# The one rule joining the two axes: state:needs-human means a human could
|
||||
# merge this RIGHT NOW, so it requires a clear branch. Any blocker at all
|
||||
# means the work is the agent's — whatever the review round says — and the
|
||||
# blocker label says which work it is. Nothing else in this function reads
|
||||
# the branch, which is what keeps the ordering below purely about reviews.
|
||||
if [ "$s" = state:needs-human ] && [ -n "$(blockers)" ]; then
|
||||
echo state:addressing; return
|
||||
fi
|
||||
echo "$s"
|
||||
}
|
||||
|
||||
round_state() { # → the state the REVIEW ROUND alone implies; knows no branch facts
|
||||
local b verdicts=""
|
||||
for b in "${BOTS[@]}"; do
|
||||
if requested "$b"; then echo state:bots-reviewing; return; fi
|
||||
done
|
||||
# Collect the WHOLE round before applying any precedence. Deciding inside
|
||||
# the loop let BOTS order pick the winner: a MISSING returned immediately,
|
||||
# so a STALE belonging to a later bot was never even read, and the mixed
|
||||
# round (one approval staled by a push, another bot yet to review) came out
|
||||
# needs-human — the #136 headline shape, with zero reviews bound to the head.
|
||||
for b in "${BOTS[@]}"; do
|
||||
verdicts="$verdicts $(bot_verdict "$b")"
|
||||
done
|
||||
case "$verdicts" in
|
||||
# STALE = a verdict for an older head. Unlike MISSING, this outranks the
|
||||
# human request: every approval it covers was invalidated by a push, so
|
||||
# NOBODY has reviewed this tree. Handing that to the human is the #136 case
|
||||
# where everything reads green — mergeable, CI passing, "waiting on the
|
||||
# human" — over code no reviewer has seen. The agent owes a re-request.
|
||||
# Checked before MISSING because "unfinished" must not swallow "and also
|
||||
# stale": a round that is both is a push that outran the re-requests, not
|
||||
# a maintainer deliberately claiming the PR early.
|
||||
*STALE*) echo state:addressing; return ;;
|
||||
esac
|
||||
case "$verdicts" in
|
||||
# No verdict at all from some bot, and nothing staled. An explicit human
|
||||
# request still outranks an unfinished round — a maintainer pulling a PR
|
||||
# to themselves early is a deliberate act, and the original precedence.
|
||||
#
|
||||
# Otherwise it is the AGENT's ball, not the bots'. The loop above already
|
||||
# returned for every live bot request, so reaching here with a MISSING
|
||||
# means somebody owes a verdict and nobody was asked for one — the round
|
||||
# is not running. Calling that bots-reviewing was the lie that let a
|
||||
# forgotten PR read "waiting on the reviewers" for the 48h it took the
|
||||
# stale sweep to notice. blocker:unrequested says why.
|
||||
*MISSING*)
|
||||
if requested "$HUMAN"; then echo state:needs-human; return; fi
|
||||
echo state:addressing; return ;;
|
||||
esac
|
||||
# an explicit human request outranks the remaining bot outcomes — 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
|
||||
case "$verdicts" in
|
||||
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
|
||||
*BLOCK* | *FEEDBACK*) 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|No blockers, all bots approve — waiting on the human reviewer
|
||||
blocker:conflict|B60205|Does not merge — the branch conflicts and the agent owes a rebase
|
||||
blocker:ci-red|B60205|A check is failing — the agent owes a fix (not a rebase)
|
||||
blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked for one
|
||||
merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here)
|
||||
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:bootstrap|C5DEF5|bootstrap — hardening a pristine server into a node
|
||||
scope:users|C5DEF5|users-* — class model, apply/status, close-root
|
||||
scope:runner|C5DEF5|runner-* — GitHub runner lifecycle
|
||||
scope:coolify|C5DEF5|coolify-* — Coolify and backup install
|
||||
scope:db|C5DEF5|db.sh — dump/restore
|
||||
scope:installer|C5DEF5|install.sh — how rig lands on a machine
|
||||
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 both axes ----
|
||||
# state:* is exclusive (everything but $desired comes off); blocker:* is a
|
||||
# set (each one on or off on its own); RETIRED always comes off. One edit
|
||||
# call for all of it, so a PR never flickers through a half-applied board.
|
||||
local want_blockers add=""
|
||||
want_blockers="$(blockers)"
|
||||
|
||||
remove=""
|
||||
for s in "${STATES[@]}"; do
|
||||
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
|
||||
done
|
||||
for s in "${RETIRED[@]}"; do
|
||||
if has_label "$s"; then remove="$remove,$s"; fi
|
||||
done
|
||||
for s in "${BLOCKERS[@]}"; do
|
||||
if grep -qxF "$s" <<<"$want_blockers"; then
|
||||
has_label "$s" || add="$add,$s"
|
||||
else
|
||||
has_label "$s" && remove="$remove,$s"
|
||||
fi
|
||||
done
|
||||
add="${add#,}"
|
||||
remove="${remove#,}"
|
||||
|
||||
# Never NAME a label the repo does not have. `gh issue edit --add-label`
|
||||
# rejects the WHOLE call on one unknown name — nothing is applied — so a
|
||||
# single missing blocker would take the state convergence down with it, on
|
||||
# exactly the PRs this change exists to fix, surfacing only as a log line.
|
||||
# Batching state and blockers into one edit for anti-flicker is what widened
|
||||
# that blast radius; filtering the add side is what closes it again.
|
||||
# Removals need no filter: they are built from has_label, so the label
|
||||
# provably exists. REPO_LABELS unreadable means no filtering rather than
|
||||
# filtering everything out — a failed read must not silently strip the board.
|
||||
local skip_edit=false
|
||||
if [ -n "${REPO_LABELS:-}" ]; then
|
||||
local kept="" missing="" want
|
||||
for want in ${add//,/ }; do
|
||||
if grep -qxF "$want" <<<"$REPO_LABELS"; then kept="$kept,$want"
|
||||
else missing="$missing $want"; fi
|
||||
done
|
||||
add="${kept#,}"
|
||||
# A missing STATE label skips only the EDIT — never the rest of this
|
||||
# function. Everything below is independent of the state:* taxonomy, and
|
||||
# returning here stranded it: `merge-next` kept claiming "merge this one
|
||||
# next" on a PR the board had moved to the agent, and the stale sweep
|
||||
# stopped running. That is the original false-invitation bug, reintroduced
|
||||
# in the very fix meant to survive a cold-start repo — and a regression
|
||||
# against the old behaviour, which failed the edit and fell through.
|
||||
if ! grep -qxF "$desired" <<<"$REPO_LABELS"; then
|
||||
log "#$n: WARNING: state label '$desired' does not exist — skipping the label edit; dispatch the workflow to bootstrap"
|
||||
skip_edit=true
|
||||
elif [ -n "$missing" ]; then
|
||||
log "#$n: WARNING: missing label(s)$missing — state still converged; dispatch the workflow to bootstrap"
|
||||
fi
|
||||
fi
|
||||
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
|
||||
args=(--add-label "$desired${add:+,$add}")
|
||||
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
||||
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
||||
log "#$n: state -> $desired${add:+ +$add}${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
|
||||
|
||||
# ---- merge-next: cleared, never set ----------------------------------
|
||||
# Queue order is INTENT — which PR should land first is a judgement about
|
||||
# conflicts and dependencies that GitHub knows nothing about, so the
|
||||
# reconciler must not guess it (LABELS.md's rule for `blocked`/`release`).
|
||||
# What it CAN do is stop the label going stale the way needs-human did:
|
||||
# the moment the PR is no longer the thing a human should merge next, the
|
||||
# claim is removed. Setting it stays with whoever owns the queue.
|
||||
if has_label merge-next && [ "$desired" != state:needs-human ]; then
|
||||
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null
|
||||
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
|
||||
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
|
||||
|
||||
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
|
||||
# add against it, because one unknown name fails the whole edit call.
|
||||
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")"
|
||||
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
|
||||
|
||||
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")]')"
|
||||
# mergeability + the check rollup, the two facts the state machine was
|
||||
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
|
||||
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
|
||||
# this returns the same MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
|
||||
# Failure to read them is NOT fatal and NOT treated as broken — an API
|
||||
# hiccup must never flap every PR into needs-rebase, so both degrade to
|
||||
# the "do not know" value that triggers nothing.
|
||||
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')"
|
||||
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
|
||||
CHECKS="$(checks_state <<<"$GH_VIEW")"
|
||||
# Read failed: leave this PR exactly as it is. Recomputing on facts we
|
||||
# did not read is how an API hiccup turns into a false "merge me" —
|
||||
# and the next tick is 15 minutes away, not 15 hours.
|
||||
if [ "$CHECKS" = UNREADABLE ]; then
|
||||
log "#$n: could not read mergeability/checks — left alone this pass"
|
||||
exit 0
|
||||
fi
|
||||
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
|
||||
23
.github/scripts/release-lib.sh
vendored
Normal file
23
.github/scripts/release-lib.sh
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env bash
|
||||
# Release plumbing shared by .github/workflows/release.yml and the test
|
||||
# harness (test/release.sh) — pure functions, sourced, never executed on
|
||||
# their own (repo precedent: labels-reconcile.sh's decide_state, the
|
||||
# commands/lib/*.sh parsers).
|
||||
|
||||
# changelog_section <file> <version>
|
||||
#
|
||||
# Print the BODY of that version's CHANGELOG.md section: everything between
|
||||
# its heading and the next '## ' heading (or EOF). A release heading is
|
||||
# stamped '## <version> — <date>' and the Unreleased one is bare
|
||||
# '## Unreleased'; the second field is the version either way, so both
|
||||
# shapes match. The heading itself is not printed — the release title
|
||||
# already names the version — and leading blank lines are dropped. Empty
|
||||
# output means "no such section", which release.yml turns into a refusal: a
|
||||
# tag with no changelog entry must not ship an empty release.
|
||||
changelog_section() {
|
||||
awk -v ver="$2" '
|
||||
/^## / { if (found) exit; found = ($2 == ver); next }
|
||||
found && !body && /^[[:space:]]*$/ { next }
|
||||
found { body = 1; print }
|
||||
' "$1"
|
||||
}
|
||||
113
.github/workflows/ci.yml
vendored
113
.github/workflows/ci.yml
vendored
|
|
@ -9,11 +9,16 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# changelog-monotonic and changelog-assembled compare HEAD against
|
||||
# the merge base; a
|
||||
# checkout that cannot resolve it is a hard failure in CI, not
|
||||
# a skip (a guard that can quietly stop guarding is the failure
|
||||
# shape these checks exist to refuse).
|
||||
# fetch-depth: 0, for the changelog-monotonic step below and only
|
||||
# for it. That check is about a DIFF — which release headings the
|
||||
# merge base had — so it needs the base branch's history present,
|
||||
# and the default depth-1 checkout has none of it. An explicit
|
||||
# `git fetch origin <base>` would be narrower, but it has to be
|
||||
# right on both event types and on fork PRs, and getting it subtly
|
||||
# wrong degrades to a SKIP (a guard that silently stops guarding —
|
||||
# the exact failure this repo keeps refusing). Full history on a
|
||||
# pure-bash tree costs a second; the STRICT flag below turns any
|
||||
# remaining skip red rather than green.
|
||||
fetch-depth: 0
|
||||
- name: shellcheck
|
||||
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
|
||||
|
|
@ -38,53 +43,61 @@ jobs:
|
|||
shellcheck -x "${files[@]}"
|
||||
- name: cli tests
|
||||
run: bash test/cli.sh
|
||||
- name: release tests — rig's own surfaces
|
||||
# test/labels-reconcile.sh existed here since #87 but ran nowhere: the
|
||||
# label state machine gates every PR on this repo and its fixtures were
|
||||
# green only when someone remembered to run them by hand. Same step, same
|
||||
# place as heavy-duty/box.
|
||||
- name: labels state-machine tests
|
||||
run: bash test/labels-reconcile.sh
|
||||
- name: release-flow tests
|
||||
run: bash test/release.sh
|
||||
# The drill harness's honesty: refusals, the skip/pass/fail classifier,
|
||||
# the idempotence capture-and-diff, the record emitter. Network-free and
|
||||
# root-free — the live four-leg run is a release's drill, not CI's.
|
||||
- name: drill harness tests — the instrument itself
|
||||
run: bash test/drill.sh
|
||||
# The release guards, doctrine in heavy-duty/ceremony's README (#13's
|
||||
# conversion). Each one's war story — why it exists, what it refuses —
|
||||
# lives with its implementation upstream; the six pins below and the
|
||||
# two workflow callers must always name the same ceremony tag.
|
||||
# No SHIPPED release heading was deleted or DUPLICATED (#98). Its own step
|
||||
# rather than a line inside test/release.sh: that suite drives the arming
|
||||
# rule against constructed VERSION + CHANGELOG.md trees that are not git
|
||||
# repos, and this assert needs a git history — folding it in would make
|
||||
# those cases skip or lie. It is also a DIFFERENT invariant: arming 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.
|
||||
#
|
||||
# changelog-armed: the version-keyed arming rule (rig#66; the
|
||||
# unconditional form rig#44 reverted — this is its correct return).
|
||||
- uses: heavy-duty/ceremony/actions/changelog-armed@0.3.0
|
||||
# changelog-monotonic: no shipped heading deleted or duplicated
|
||||
# (#98, box#122). Strict by default: an unresolvable base ref is red,
|
||||
# never a quiet skip — hence the fetch-depth: 0 above.
|
||||
- uses: heavy-duty/ceremony/actions/changelog-monotonic@0.3.0
|
||||
# changelog-assembled: a release's stamped section must exactly match
|
||||
# the fragments it consumed. Vacuous on non-release PRs.
|
||||
- uses: heavy-duty/ceremony/actions/changelog-assembled@0.3.0
|
||||
# drill-recorded: a release version carries drills/<version>.md
|
||||
# (rig's drill meaning: drills/README.md). Vacuous on -dev trees.
|
||||
- uses: heavy-duty/ceremony/actions/drill-recorded@0.3.0
|
||||
# runner-isolated: PR-triggered workflows never execute unreviewed
|
||||
# branch code on a self-hosted runner.
|
||||
- uses: heavy-duty/ceremony/actions/runner-isolated@0.3.0
|
||||
# docs-sync: the .ceremony/ doctrine mirror is byte-identical to the
|
||||
# pin read from release.yml (ceremony#19) — a hand edit or a
|
||||
# half-done pin bump goes red here.
|
||||
- uses: heavy-duty/ceremony/actions/docs-sync@0.3.0
|
||||
|
||||
# The install LIFECYCLE against a tree install.sh itself produced — the four
|
||||
# beats box and cast already run in CI (#106): install from this checkout,
|
||||
# assert what landed, a converging re-run proven by an EMPTY DIFF (never an
|
||||
# exit code), uninstall --all ending in the absence assert (`! -e` AND
|
||||
# `! -L` — only the second sees a dangling symlink). Separate from `check`
|
||||
# for the same reason db-integration is: fast feedback first. The runner's
|
||||
# real $HOME is the point — no throwaway roots here; the suite refuses to
|
||||
# run where a rig is already installed, so it cannot eat a real install.
|
||||
install:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: the install lifecycle — four beats against a real tree
|
||||
run: bash test/install-lifecycle.sh
|
||||
# NOT pull-request-only, and that is the #98 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.
|
||||
- name: no shipped changelog heading was deleted or duplicated
|
||||
env:
|
||||
CHANGELOG_MONOTONIC_STRICT: '1'
|
||||
run: bash .github/scripts/changelog-monotonic.sh "origin/${{ github.base_ref || github.ref_name }}"
|
||||
# The release this tree would ship has a recorded real-hardware drill
|
||||
# (drills/<version>.md). CONTRIBUTING ("Releasing") has always required one and
|
||||
# nothing enforced it, so no release in this family has ever carried one
|
||||
# — the drill was the single ceremony step checked by a reviewer
|
||||
# remembering rather than by a script.
|
||||
#
|
||||
# Deliberately NOT trigger-scoped, and for the opposite reason to the
|
||||
# step above. That one needs a base ref, so its argument has to be right
|
||||
# on both event types; this one reads two files in the checkout and is
|
||||
# VACUOUS BY CONSTRUCTION on a `-dev` VERSION, which every ordinary PR
|
||||
# and every push to main carries. It has something to say on exactly one
|
||||
# tree — the `release: X.Y.Z` ceremony PR — so an `if:` could only add a
|
||||
# way for that one tree to slip past.
|
||||
#
|
||||
# PER-REPO on purpose: rig reads rig's own record, never box's repo. A
|
||||
# cross-repo lookup fails on a token, a network blip or a fork checkout,
|
||||
# and every one of those lands on "could not read" — which degrades to
|
||||
# green on precisely the tree that ships (the UNREADABLE-vs-NONE shape
|
||||
# #90 fixed).
|
||||
- name: a release version has a recorded drill
|
||||
run: bash .github/scripts/drill-recorded.sh
|
||||
|
||||
# Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and
|
||||
# stands up throwaway containers, and a slow image pull must never delay the
|
||||
|
|
|
|||
84
.github/workflows/labels.yml
vendored
84
.github/workflows/labels.yml
vendored
|
|
@ -1,22 +1,78 @@
|
|||
name: labels
|
||||
# The automation LABELS.md promises, now implemented upstream
|
||||
# (heavy-duty/ceremony#13): scope labeling and the state reconciler live in
|
||||
# the reusable workflow this caller pins. Rig keeps the triggers and
|
||||
# permissions (a called workflow cannot define them), its path map in
|
||||
# .github/labeler.yml, and its panel + scope taxonomy in .github/labels.conf.
|
||||
# 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.
|
||||
#
|
||||
# There is no pull_request_review_target, so a review landing cannot wake this
|
||||
# workflow directly — and the */15 cron is advisory: GitHub deprioritises short
|
||||
# intervals hard enough that a quiet repo goes hours between ticks. So the
|
||||
# handoff wakes the sweep itself: the author sets state:needs-human when handing
|
||||
# the PR to the maintainer (CONTRIBUTING step 6), and `labeled` fires this
|
||||
# workflow, which confirms or corrects that optimistic write within seconds. The
|
||||
# cron stays as the last resort, for the round an agent forgets to hand off.
|
||||
#
|
||||
# This cannot loop: the reconciler's own label writes use GITHUB_TOKEN, and
|
||||
# GitHub does not create workflow runs from GITHUB_TOKEN-triggered events. Agent
|
||||
# writes use a PAT and therefore do trigger — exactly the asymmetry wanted.
|
||||
on:
|
||||
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||
schedule:
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
||||
pull_request_target:
|
||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||
issues:
|
||||
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
|
||||
types:
|
||||
[
|
||||
opened,
|
||||
reopened,
|
||||
ready_for_review,
|
||||
converted_to_draft,
|
||||
synchronize,
|
||||
labeled,
|
||||
unlabeled,
|
||||
]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
statuses: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
labels:
|
||||
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.3.0
|
||||
scope:
|
||||
# Not on labeled/unlabeled: those events change no paths, so labeler has
|
||||
# nothing new to derive — and label churn is precisely what they are.
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action != 'labeled' &&
|
||||
github.event.action != 'unlabeled'
|
||||
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
|
||||
|
|
|
|||
246
.github/workflows/release.yml
vendored
246
.github/workflows/release.yml
vendored
|
|
@ -1,22 +1,238 @@
|
|||
name: release
|
||||
# The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its
|
||||
# README; ceremony#13 is this conversion). Both doors — merge and tag push —
|
||||
# live in the reusable workflow this caller pins; rig keeps only what a
|
||||
# called workflow cannot define: triggers and permissions.
|
||||
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||
# Two ways in, one release out (#47; box#96's design — the merge path — on
|
||||
# top of #32/box#83's tag flow, kept verbatim as the fallback):
|
||||
#
|
||||
# - MERGE (the paved road): a release is a PR — `release: X.Y.Z`, carrying
|
||||
# the `release` label, bumping VERSION and stamping CHANGELOG.md's
|
||||
# Unreleased section — and MERGING it is the ship decision. The
|
||||
# release-on-merge job asserts its way to certainty, then tags the merge
|
||||
# commit and publishes, same job. No separate, silent-when-forgotten
|
||||
# tagging step: a forgotten tag produces no red X, a failed run on main
|
||||
# does — of two unreliabilities, pick the loud one.
|
||||
# - TAG PUSH (the manual fallback and backfill): tag the merge commit bare
|
||||
# `X.Y.Z` (no `v` prefix — box's tag scheme) and push; the release job
|
||||
# below turns it into the GitHub release.
|
||||
#
|
||||
# Either way the body is the changelog section — the curated prose, never
|
||||
# the auto-generated PR list — and no assets are uploaded on purpose: for a
|
||||
# pure-bash tree, GitHub's source tarball for the tag IS the package
|
||||
# (install.sh downloads archive/refs/tags/<tag>).
|
||||
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).
|
||||
# ONE push key, both filters — YAML maps are last-key-wins, so a second
|
||||
# sibling `push:` would silently REPLACE the first and kill a door
|
||||
# (grok's round-2 catch: the tag fallback had stopped triggering).
|
||||
push:
|
||||
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||
# never be skipped by a shape filter that didn't match
|
||||
# Every tag, not a shape filter: a tag that mismatches VERSION must fail
|
||||
# LOUDLY below, not be silently skipped by a pattern that didn't match.
|
||||
tags: ['**']
|
||||
# The merge-is-the-release path (#47) rides pushes to MAIN, not
|
||||
# pull_request events: a pull_request run from a public FORK gets a
|
||||
# READ-ONLY GITHUB_TOKEN — `permissions:` cannot raise that ceiling —
|
||||
# and every ceremony PR this org merges is cross-repo from the bot
|
||||
# fork; the tag create would 403 after green asserts. A push to main
|
||||
# is an in-repo event with the full write token, whoever authored the
|
||||
# PR. The jobs split on the pushed ref.
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write # tag ref create + release create + the bump push
|
||||
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||
issues: write # --label on that fallback PR rides the issues API
|
||||
contents: write # the tag ref, the release publish, the bump push
|
||||
# Two consumers (a declared permissions: block zeroes every unspecified
|
||||
# scope): the decide step's label read (commits/<sha>/pulls) and the bump
|
||||
# fallback's `gh pr create --label`.
|
||||
pull-requests: write
|
||||
# ...and the --label on that fallback PR rides the ISSUES API (labels.yml
|
||||
# grants the same pair for the same reason).
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.3.0
|
||||
with:
|
||||
version-source: file
|
||||
# The tag-push path — a pushed TAG ref. The merge path (a pushed main
|
||||
# head) is release-on-merge below; the two doors split on the ref.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The tag names a tree; the tree names its own version. When they
|
||||
# disagree, creating a release would put a version label on a tree
|
||||
# that is not that version — exactly the lie the release flow exists
|
||||
# to end — so: fail, create nothing.
|
||||
- name: assert the tag matches the tree's VERSION
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if [ "$GITHUB_REF_NAME" != "$ver" ]; then
|
||||
echo "tag '$GITHUB_REF_NAME' != VERSION '$ver' — refusing to create a release for a tree that says it is something else" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: create the release from the changelog section
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
. .github/scripts/release-lib.sh
|
||||
notes="$(changelog_section CHANGELOG.md "$GITHUB_REF_NAME")"
|
||||
if [ -z "$notes" ]; then
|
||||
echo "CHANGELOG.md has no '## $GITHUB_REF_NAME' section — stamp the Unreleased section in the release PR before tagging" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release create "$GITHUB_REF_NAME" --verify-tag \
|
||||
--title "$GITHUB_REF_NAME" --notes "$notes"
|
||||
|
||||
# The merge path (#47; box#96): the `release` label is the intent, the
|
||||
# VERSION transition is the interlock. Four asserts in order, each
|
||||
# fail-loud and creating NOTHING, then tag + publish in this same job.
|
||||
# Same-job is load-bearing: the tag is created with GITHUB_TOKEN via the
|
||||
# API, and GITHUB_TOKEN-created refs do not fire `on: push: tags`
|
||||
# workflows — so the publish MUST live here (nothing else would run), and
|
||||
# the fallback job above CANNOT double-publish off our tag. A manually
|
||||
# pushed tag racing this run is caught by the nothing-exists assert.
|
||||
# NOTE: test/release.sh pins this block by awk-ing from
|
||||
# 'release-on-merge:' to EOF — keep it the last job.
|
||||
release-on-merge:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MERGE_SHA: ${{ github.sha }}
|
||||
BASE_SHA: ${{ github.event.before }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# The pushed head is what ships. Full history so the before-side
|
||||
# VERSION is readable for the interlock below.
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
# The decide step — asserts 1+2 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 — this very PR included.
|
||||
# The version tells them apart. A `-dev` VERSION left UNTOUCHED by the
|
||||
# PR is release-flow work: a green no-op, not a red run on main every
|
||||
# time the flow itself is improved. Everything in between is a
|
||||
# half-ceremony and dies loudly:
|
||||
# -dev, unchanged → work under the label: NOTICE + green no-op
|
||||
# -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,
|
||||
# already released → work merged in the post-release window
|
||||
# (ceremony landed, the -dev bump has not):
|
||||
# NOTICE + green no-op
|
||||
# bare, unchanged,
|
||||
# never released → the label says ship, the tree names an
|
||||
# unshipped version this PR did not mint:
|
||||
# genuinely ambiguous, refuse
|
||||
# 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)"
|
||||
# event.before is all-zeros on a branch-create push; the merge
|
||||
# commit's first parent is main the instant before, either way.
|
||||
if ! git cat-file -e "$BASE_SHA" 2>/dev/null; then BASE_SHA="$(git rev-parse "$MERGE_SHA^1")"; fi
|
||||
base_ver="$(git show "$BASE_SHA:VERSION")"
|
||||
case "$ver" in
|
||||
*-dev)
|
||||
if [ "$base_ver" = "$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' -> '$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" = "$ver" ]; then
|
||||
if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/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." >&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 itself lives on a fork — see the trigger
|
||||
# comment). No release-labeled PR behind this commit = a version
|
||||
# transition nobody declared: refuse.
|
||||
if ! gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \
|
||||
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' | grep -qx true; then
|
||||
echo "VERSION transitioned ('$base_ver' -> '$ver') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR (#47), not a bare push. Refusing." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ceremony=yes" >> "$GITHUB_OUTPUT"
|
||||
# Assert 3 — the changelog names exactly this version, and the one
|
||||
# extractor (shared with the tag job and test/release.sh) gets a
|
||||
# non-empty body out of it. The notes are kept for the publish.
|
||||
- name: assert the changelog section for this version extracts
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
run: |
|
||||
. .github/scripts/release-lib.sh
|
||||
ver="$(cat VERSION)"
|
||||
changelog_section CHANGELOG.md "$ver" > "$RUNNER_TEMP/notes.md"
|
||||
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
|
||||
echo "CHANGELOG.md has no '## $ver' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
# Assert 4 — nothing exists yet, tag or release: a re-run of this job
|
||||
# (or a manual tag that beat it) must refuse, not clobber.
|
||||
- name: assert no tag and no release exist yet (idempotent re-runs)
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
if git ls-remote --exit-code origin "refs/tags/$ver" >/dev/null 2>&1; then
|
||||
echo "tag '$ver' already exists — this release already happened (or is mid-flight on the manual path); refusing to re-release" >&2
|
||||
exit 1
|
||||
fi
|
||||
if gh release view "$ver" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "release '$ver' already exists — refusing to re-release" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Act — tag the merge commit via the API, then publish with the notes
|
||||
# assert 3 extracted. (GITHUB_TOKEN-created tag: no recursive
|
||||
# workflow runs — see the job comment.)
|
||||
- name: tag the merge commit and publish the release
|
||||
if: steps.decide.outputs.ceremony == 'yes'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ver="$(cat VERSION)"
|
||||
gh api -X POST "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" \
|
||||
-R "$GITHUB_REPOSITORY"
|
||||
# The post-release bump, folded into the release act (#47 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; and 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
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
# 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.
|
||||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -4,34 +4,7 @@ History before 0.1.0 lives in git — rig grew its version surface (`VERSION`,
|
|||
`rig --version`, the side-by-side `versions/<v>` install layout; #35/#36)
|
||||
on the way to cutting its first release, and this file starts there.
|
||||
|
||||
## 0.3.1 — 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
- GitHub entry templates route humans to Discussions and prefill triage work orders and pull requests (#123)
|
||||
- Platform, drill, docs and labels changes receive dedicated scope labels (#119)
|
||||
- The `changelog-armed` guard returns, version-keyed (#112, ceremony#13)
|
||||
- The `.ceremony/` doctrine mirror, verified by `docs-sync` on every PR (#112, ceremony#19)
|
||||
- `rig template-lint` validates role definitions; rig-templates CI runs it on every PR (#110)
|
||||
- Drill records cite the rig-templates SHA the converge read (#110)
|
||||
- `kimi-box` joins the box tenant roles — the Kimi CLI agent guest (#109)
|
||||
- CI drills the install lifecycle against a real tree — install from the checkout, converge to an empty diff, uninstall to proven absence (#106)
|
||||
- `drill/drill.sh` — the drill has an instrument: pinned-ref assertion, a mechanical idempotence diff, and a `drills/<version>.md` record emitter (#105)
|
||||
- `rig platform` prints a stable machine `ID`, derived from `/etc/machine-id`, never the raw value (#95)
|
||||
- `rig bootstrap --undo` removes only a tailnet join rig can prove it made (#63)
|
||||
|
||||
### Changed
|
||||
|
||||
- Changelog entries land in per-issue fragments assembled by the release PR (#136)
|
||||
- Release and labels machinery is consumed from heavy-duty/ceremony@0.1.0 by reference — the workflows shrink to caller stubs, the guard scripts and their tests move upstream (#112, ceremony#13)
|
||||
- Agent-tenant definitions live in heavy-duty/rig-templates, pinned in-tree and overridable per mint (`RIG_TEMPLATES_DIR`/`_REF`/`_REPO`); the in-tree case arms are gone, `staging-box` stays (#110)
|
||||
- `bootstrap --host yes` installs a pinned box release instead of `main` (#103)
|
||||
|
||||
### Fixed
|
||||
|
||||
- The quick-start fence names its channel and carries the release command beside it (#149)
|
||||
- The drill's docs no longer claim both installers default to `main` — box installs the `BOX_RELEASE` pin, rig the latest release, and its `--box-ref` example is now a tag (#133)
|
||||
- `kimi-bot-andresmgsl` is on the review panel — the roster predated it joining the bench (#120)
|
||||
## Unreleased
|
||||
|
||||
## 0.3.0 — 2026-07-21
|
||||
|
||||
|
|
|
|||
229
CONTRIBUTING.md
229
CONTRIBUTING.md
|
|
@ -1,41 +1,66 @@
|
|||
# Contributing
|
||||
|
||||
This repo is governed by
|
||||
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
|
||||
read [`.ceremony/AGENTS.md`](.ceremony/AGENTS.md) first** — it routes you to
|
||||
your role file (builder, reviewer, triage), vendored beside it,
|
||||
byte-identical to ceremony at the pin named in
|
||||
[`.github/workflows/release.yml`](.github/workflows/release.yml) and
|
||||
guarded by the `docs-sync` step in CI. The review-round doctrine — drafts,
|
||||
whole-round replies, verdicts, the handoff — lives there and in
|
||||
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); this file keeps only what is
|
||||
genuinely rig's.
|
||||
How change lands in this repo. The short version: PRs are born as drafts,
|
||||
three reviewer bots take the first rounds, a human takes the last word — and
|
||||
labels tell you where everything is without opening anything.
|
||||
|
||||
## The PR loop, rig specifics
|
||||
## The PR loop
|
||||
|
||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
||||
2. **The review panel** (`.github/labels.conf`'s `panel=` line):
|
||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`,
|
||||
`kimi-bot-andresmgsl` —
|
||||
the required verdicts for a PR are the panel minus its author. The
|
||||
maintainer (`danmt`) takes the last word and merges.
|
||||
3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
||||
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
||||
bots on purpose.
|
||||
3. **When it's ready**: mark ready-for-review and request all three bots —
|
||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
||||
They poll roughly every 15 minutes.
|
||||
4. **Rounds are answered whole.** Wait until all three have reviewed, then
|
||||
answer the entire round in a **single reply**, push the fixes, and
|
||||
re-request the bots that didn't approve. Prefer verification over
|
||||
argument: a test settles what a comment thread can't.
|
||||
5. **Reviews end in a verdict.** A reviewer — bot or human — either
|
||||
**approves** or **requests changes**, never a bare comment. A
|
||||
comment-only review is a non-verdict: it doesn't say whether the round
|
||||
passed, and the state machine (and anyone scanning the board) has to
|
||||
guess. The verdict carries *blockingness only*, the body carries the
|
||||
feedback: non-blocking nits ride an **approval** and the author addresses
|
||||
them at their discretion; anything blocking — including a question that
|
||||
gates the verdict — is **request changes**, saying what unblocks it. The
|
||||
reconciler treats a comment-only review as not-approved, so commenting
|
||||
without a verdict only stalls the PR. The machine never reads review
|
||||
bodies: when a comment-only reviewer's line is really an agreement, that
|
||||
judgment belongs to the **author** — escalate by requesting the
|
||||
maintainer's review (step 6), and the reconciler flips the label on that
|
||||
request, because an explicit request is a fact it can trust.
|
||||
6. **When the round passes, the author hands the PR to the maintainer** in
|
||||
three acts, in this order: post the tagged round summary, request the
|
||||
maintainer's review, then set `state:needs-human` yourself — removing the
|
||||
state label it replaces. The review request is what *earns* the label,
|
||||
provided the PR carries **no `blocker:*` label**. A blocker means the work
|
||||
is still yours whatever the round said, so on a conflicted or red PR
|
||||
neither the request nor your own label write will stick — the sweep takes
|
||||
it straight back off. With three formal head-current approvals the labels
|
||||
workflow requests the maintainer automatically; when part of the panel is
|
||||
comment-only, reading their agreement is the author's judgment, so the
|
||||
author makes the request.
|
||||
|
||||
Writing the label by hand is an **optimistic write, not a transfer of
|
||||
ownership**. The machine stays the authority — but because the workflow
|
||||
wakes on `labeled`, the author's own write fires the sweep that validates
|
||||
it, and a handoff that had not earned the label is corrected seconds later.
|
||||
Forgetting the write is not a failure either; it only means the label waits
|
||||
for the cron, which is the lag this replaced.
|
||||
7. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
||||
`bash test/release.sh` locally mirror what CI runs; the db dump/restore
|
||||
round-trip (`test/db-integration.sh`) executes in CI where Docker is
|
||||
present. The release guards (`changelog-armed`, `changelog-monotonic`,
|
||||
`changelog-assembled`, `drill-recorded`, `runner-isolated`, `docs-sync`)
|
||||
run as ceremony's pinned actions.
|
||||
4. **Feature PRs land their changelog entry as part of the PR**: write
|
||||
`changelog.d/<issue>.md` — the release PR assembles those fragments into
|
||||
the release notes verbatim.
|
||||
present.
|
||||
8. **Feature PRs land their changelog entry as part of the PR** (box's
|
||||
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
|
||||
that section becomes the release notes verbatim when a release is cut.
|
||||
|
||||
## Changelog entries
|
||||
|
||||
Every PR that changes behaviour writes one `changelog.d/<issue>.md` fragment.
|
||||
The fragment keeps the relevant `### Added` / `### Changed` / `### Fixed`
|
||||
heading above its entry. One line is the whole rule — if it wraps more than
|
||||
twice in your editor, cut it down.
|
||||
Every PR that changes behaviour adds one line to `## Unreleased`. One line is
|
||||
the whole rule — if it wraps more than twice in your editor, cut it down.
|
||||
|
||||
- **Say what changed, and stop.** Why it was wrong, how it was found, what it
|
||||
cost, what it implies — that belongs in the PR body and the commit message,
|
||||
|
|
@ -66,42 +91,134 @@ Not an entry — that is a PR body:
|
|||
|
||||
## Releasing
|
||||
|
||||
A release is a PR, and merging it is the release. The ceremony — the two
|
||||
doors, the decide table, the stamps, the post-release re-arm — is
|
||||
heavy-duty/ceremony's machinery, consumed by reference:
|
||||
[its README](https://github.com/heavy-duty/ceremony/blob/main/README.md)
|
||||
is the doctrine, `.github/workflows/release.yml` here is the ≤20-line
|
||||
caller pinning it, and the guards run in `ci.yml` from the same pin.
|
||||
Bare `X.Y.Z` tags, no `v`; the tag's source tarball is the package
|
||||
`install.sh` downloads — rig ships no other artifact. Each release deliberately
|
||||
bumps and drills the `BOX_RELEASE` pin in `commands/bootstrap.sh`; it must never
|
||||
float to a moving ref.
|
||||
A release is a PR, and merging it is the release (#47; box#96's design, on
|
||||
top of #32/box#83's tag flow). It takes the ordinary PR loop above, with one
|
||||
extra gate before the handoff:
|
||||
|
||||
What stays rig's is the **drill** — the real-hardware gate before the
|
||||
handoff of a release PR, run by `drill/drill.sh` (#105): `rig bootstrap`
|
||||
converging the machine to its role twice with the second run diffed empty,
|
||||
`test/db-integration.sh`, the runner lifecycle against a fork, a coolify
|
||||
install. Rig's drill asserts **convergence** (a machine reaches its role,
|
||||
idempotently), it runs `--host yes` with `BOX_REF=release/<box-version>` so
|
||||
it exercises the box that will actually ship, and drills that share a
|
||||
substrate share **one run ID** so the per-repo records can be joined after
|
||||
the fact. The full meaning — the fixed candidate-ref pinning that dissolves
|
||||
the box↔rig recursion, the per-version record files, the waiver rule — is
|
||||
[`drills/README.md`](drills/README.md); the `drill-recorded` guard enforces
|
||||
the record on every release tree.
|
||||
**draft → ready → bot round → drill → `state:needs-human` → maintainer merge
|
||||
(which IS the release).**
|
||||
|
||||
The **drill** is a real-hardware run — tenant guests minted and converged via
|
||||
box, `test/db-integration.sh`, the GitHub runner lifecycle against a fork, a
|
||||
coolify install — recorded in **one file per version**:
|
||||
|
||||
```
|
||||
drills/<version>.md
|
||||
```
|
||||
|
||||
named for the version exactly as `VERSION` carries it. See
|
||||
[`drills/README.md`](drills/README.md) for what a record should contain.
|
||||
|
||||
`.github/scripts/drill-recorded.sh` enforces it on every release: a bare
|
||||
`VERSION` with no non-empty `drills/<version>.md` turns CI red, naming the
|
||||
version. It is **not a thing a reviewer has to remember** — that is how every
|
||||
release in this family shipped undrilled until a bot finally blocked on one. On
|
||||
a `-dev` tree it asserts nothing, so it is invisible to ordinary PRs. rig reads
|
||||
rig's own record and never box's repo: a cross-repo lookup fails on a token,
|
||||
a fork checkout or a network blip, and all of those degrade to "pass" —
|
||||
the UNREADABLE-vs-NONE shape #90 fixed.
|
||||
|
||||
One file per version is what keeps the guard small. Records used to share a
|
||||
single log, which forced a heading grammar, an optional-date tail, a
|
||||
whole-version comparison and a non-blank-body rule just to read them back — and
|
||||
both sibling repos shipped a defect out of that complexity in review. Now
|
||||
`0.3.0.md` and `0.3.0-rc1.md` are simply different files.
|
||||
|
||||
**The three repos' drills are INDEPENDENT.** Run them in any order, on any
|
||||
schedule, in separate sittings. What makes that safe is that every drill **pins
|
||||
the same fixed set of candidate refs**: rig's drill runs `--host yes` with
|
||||
`BOX_REF=release/<box-version>`, so it exercises the box that will actually
|
||||
ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it exercises
|
||||
the rig that will actually ship. Both measure the same pair.
|
||||
|
||||
That — not sequencing — is what dissolves the box↔rig recursion. box and rig
|
||||
are mutually recursive (`rig bootstrap … --host yes` installs box and runs
|
||||
box's `setup-host`; box's `box new` seeds converge back through rig's installer
|
||||
at `@RIG_REPO@/@RIG_REF@`), but the refs are static identifiers that exist as
|
||||
soon as the release branches do, long before any drill runs, so a cycle at
|
||||
runtime becomes independent tests against one fixed pair. Within a single drill
|
||||
you naturally bring the substrate up before probing it — a host before a guest
|
||||
— but that is how you run a drill, not an ordering rule between repos.
|
||||
|
||||
Each repo drills in a **different way** and asserts a different thing: rig
|
||||
asserts **convergence** (a machine reaches its role, idempotently), box asserts
|
||||
the **isolation contract** (the VM trust boundary), cast asserts **promotion**
|
||||
(A→B reproduces, the diff is idempotent). Three different exercises sharing a
|
||||
substrate, not three phases of one script — which is exactly why the records
|
||||
are per-repo.
|
||||
|
||||
It drills **candidate refs, not released artifacts.** `RIG_REPO`/`RIG_REF` are
|
||||
mint-time environment variables (default `heavy-duty/rig@main`), so a run pins
|
||||
the exact commits under test. That is what dissolves the chicken-and-egg: no
|
||||
repo has to be released before another can be drilled.
|
||||
|
||||
**Drilling the candidate IS drilling the release.** A release PR's diff is
|
||||
`VERSION` + `CHANGELOG.md` and nothing else — no executable difference exists
|
||||
between the tree that was drilled and the tree that ships.
|
||||
|
||||
Drills that share a substrate share **one run ID**. Each repo records *its own*
|
||||
legs in its own `drills/<version>.md`, citing that run ID and the other two
|
||||
repos' commit SHAs, so the records can be joined after the fact by anyone
|
||||
reading them. The guard still reads only this repo's file — there is no
|
||||
cross-repo lookup anywhere in the gate. Releases do **not** have to be
|
||||
published in a fixed order. If a defect shows up only in the combination:
|
||||
patch, re-drill, re-record. The three releases converge on a set that holds
|
||||
together; they are not required to be right in one pass.
|
||||
|
||||
A **maintainer waiver** is possible — a doc-only release, a hardware outage —
|
||||
but it must be **recorded in `drills/<version>.md` for that version**, saying
|
||||
who waived it and why. The guard asks for a *record*, not a passing result,
|
||||
precisely so that skipping is a deliberate, reviewable commit instead of a
|
||||
silence. Deleting the check is not the move.
|
||||
|
||||
The mechanics:
|
||||
|
||||
1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps
|
||||
`VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased
|
||||
section as `## X.Y.Z — YYYY-MM-DD`. **Then re-arm the file in the same
|
||||
PR**: add a fresh, empty `## Unreleased` immediately above the section
|
||||
you just stamped (#66). Stamping alone *disarms* main — a PR authored
|
||||
before the release and merged after it wrote its entry under
|
||||
`## Unreleased`, and with that heading gone git files the entry under
|
||||
whatever now occupies the position, which is the release that already
|
||||
shipped. It lands cleanly, with no conflict and nothing for the author
|
||||
to notice, so the empty section is the only thing standing between a
|
||||
late merge and a changelog that misattributes a shipped release. No
|
||||
workflow does this for you: `release.yml` re-arms `VERSION`, never the
|
||||
changelog. `test/release.sh` enforces the pairing — whenever `VERSION`
|
||||
ends in `-dev` the top section must be `## Unreleased`. CI green on it,
|
||||
same loop as any PR.
|
||||
2. Merge it — that IS the ship decision. `release.yml`'s
|
||||
`release-on-merge` job asserts, in order, fail-loud, creating nothing:
|
||||
the merged tree's `VERSION` is non-`-dev`; this PR is the one that
|
||||
changed it (a mislabeled ordinary PR fails here); the changelog section
|
||||
for that version extracts non-empty; no tag or release exists yet.
|
||||
Then, same job, it tags the merge commit bare `X.Y.Z` (no `v` prefix —
|
||||
box's tag scheme) and publishes the GitHub release with that section as
|
||||
the body. No assets — the source tarball for the tag is the package
|
||||
`install.sh` downloads.
|
||||
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, loudly). A dev install therefore never impersonates the
|
||||
release in the `versions/<v>` layout. On the *manual* tag path the
|
||||
bump stays yours: open the one-line PR after publishing.
|
||||
|
||||
Manual fallback (and backfill): if the merge-path run fails, fix what it
|
||||
named, then tag the merge commit `X.Y.Z` by hand and push the tag — the
|
||||
original tag-push job still turns any correct tag into the release, and
|
||||
the merge path's nothing-exists-yet assert keeps the two from
|
||||
double-publishing.
|
||||
|
||||
## Labels — who sets what
|
||||
|
||||
The taxonomy and state machine are
|
||||
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); rig's `scope:*` rows live in
|
||||
`.github/labels.conf` (reconciled by the labels caller) and their path map
|
||||
in `.github/labeler.yml`. 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:
|
||||
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 on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
|
||||
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts on PR events (label changes included) and every 15 minutes. Machine-owned, with one exception: the author sets `state:needs-human` at handoff (step 6) and the workflow reconciles it. Otherwise never by hand. Exactly one per PR: *whose ball is it.* |
|
||||
| `blocker:*` | the same workflow, from the same facts — *what is in the way.* Any number per PR, or none. Never by hand: applying one does not stop a merge, and removing one does not unblock anything. Fix the thing and the next sweep drops the label. |
|
||||
| `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. |
|
||||
|
|
|
|||
187
LABELS.md
Normal file
187
LABELS.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# 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, fix, or ask | all bots reviewed and not all approved; or nobody was asked; or a blocker is up | the round-reply is posted and fixes pushed — and any blocker named alongside is cleared |
|
||||
| `state:needs-human` | `#8250DF` | the human reviewer | the PR **could be merged right now**: no blockers, three formal head-current approvals — and the human review is requested | 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.
|
||||
`bots-reviewing` therefore means strictly *a request is live and an answer is
|
||||
coming* — a PR nobody was asked to review is the agent's ball, not the bots'.
|
||||
|
||||
## The second axis: `blocker:*`
|
||||
|
||||
State answers *whose ball is it*. Blockers answer *what is in the way*, and
|
||||
unlike states they are *facts about the branch* — mutually independent, so a
|
||||
PR carries as many as apply.
|
||||
|
||||
| Label | Color | Means | Clears when |
|
||||
|---|---|---|---|
|
||||
| `blocker:conflict` | `#B60205` | GitHub says `CONFLICTING` — the agent owes a **rebase** | it merges cleanly |
|
||||
| `blocker:ci-red` | `#B60205` | a check failed — the agent owes a **fix**, which a rebase will not provide | checks are green |
|
||||
| `blocker:unrequested` | `#E99695` | this head has no verdict from somebody — never reviewed, or staled by a push — and **nobody was asked** for one | reviews are requested |
|
||||
| `blocker:drill-pending` | `#E99695` | a `release` PR whose version has **no drill record** at [`drills/<version>.md`](drills/README.md) — the ceremony is correct but *unevidenced* | the drill is run and recorded, or a maintainer waiver is recorded for that version |
|
||||
|
||||
`blocker:drill-pending` is the one blocker that is not about the code: the
|
||||
branch merges, the checks that read the tree are green, and the release is
|
||||
still not shippable because nothing says it was ever run on real hardware.
|
||||
`.github/scripts/drill-recorded.sh` is the authority — the label just makes
|
||||
the reason legible on the board, so a release PR sitting still reads as
|
||||
"waiting on an afternoon of hardware", not as "forgotten". It only ever
|
||||
appears on a `release` PR: every `-dev` tree satisfies the guard vacuously.
|
||||
|
||||
It is the one `blocker:*` the reconciler does **not** compute — its `BLOCKERS`
|
||||
set is the three above — so it is applied by hand and, being outside that set,
|
||||
is not stripped on the next sweep. (The red check itself still shows up as
|
||||
`blocker:ci-red`; this label says *which* red.)
|
||||
|
||||
**A maintainer account must create this label.** The bot account 403s on label
|
||||
creation, so until someone with push access runs the `gh label create` line
|
||||
below, use plain `blocked` on such a PR — it carries the right meaning
|
||||
(waiting on something else to happen first) and the staleness sweep already
|
||||
exempts it.
|
||||
|
||||
One rule joins the axes: **`state:needs-human` requires zero blockers.** Any
|
||||
blocker means the work is the agent's, whatever the review round says.
|
||||
|
||||
This split exists because the single-label version kept lying. Independent
|
||||
facts were projected onto one totally-ordered label, so one always had to win
|
||||
and the losers vanished off the board: a PR that was *both* conflicted and red
|
||||
could only say one of them, and `needs-rebase` told an agent to rebase when
|
||||
what it actually owed was a bug fix. Precedence between two blockers is not a
|
||||
question a set has to answer, which is why every ordering bug this machine has
|
||||
had — `needs-human` surviving a conflict, `MISSING` swallowing `STALE` — lived
|
||||
on the axis that had to be totally ordered.
|
||||
|
||||
`state:needs-rebase` was the first attempt at this and is **retired**; the
|
||||
reconciler strips it on sight so no PR is left carrying a label nothing
|
||||
recomputes.
|
||||
|
||||
**`state:needs-human` means one thing: a human could merge this right now.**
|
||||
The label is the only signal a maintainer scanning the board (or a phone)
|
||||
actually reads, and one that says "your turn" on an unmergeable PR is worse
|
||||
than no label at all. So beyond the blockers, one review fact also outranks an
|
||||
explicit human request:
|
||||
|
||||
- **nobody reviewed *this* head** — every approval staled by a push → `state:addressing`,
|
||||
because the agent owes a re-request
|
||||
|
||||
That case is more dangerous than any blocker: a blocked PR at least shows an X
|
||||
or a disabled merge button, while a staled-approval PR reads green, mergeable
|
||||
and "waiting on the human" over code no reviewer has seen.
|
||||
|
||||
`UNKNOWN` mergeability is deliberately **not** treated as a conflict. GitHub
|
||||
reports it for about a minute after every merge while it recomputes, and
|
||||
flapping every open PR through `blocker:conflict` on each merge would be worse
|
||||
than the bug this fixes. A failed read of either branch fact degrades to the
|
||||
same "do not know" value, for the same reason.
|
||||
|
||||
An *unfinished* round still yields to an explicit human request — a maintainer
|
||||
pulling a PR to themselves early is a deliberate act. `MISSING` (nobody has
|
||||
reviewed yet) and `STALE` (everyone reviewed something else) are different
|
||||
facts and are treated differently.
|
||||
|
||||
## 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. |
|
||||
| `merge-next` | `#0E8A16` | Head of the merge queue — **merge this one next**. Queue order is *intent* (which PR lands first, given how they conflict), so the reconciler never sets it: you or the agent maintaining the queue do. The reconciler only **clears** it, the moment the PR stops being something a human could merge — so it cannot go stale the way `state:needs-human` did. |
|
||||
|
||||
## Scope — which surface? (PRs and issues, any number)
|
||||
|
||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
|
||||
|
||||
| Label | Covers |
|
||||
|---|---|
|
||||
| `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node |
|
||||
| `scope:users` | `commands/users-*` — the root-door model, apply/status, close-root |
|
||||
| `scope:runner` | `commands/runner-*` — GitHub runner install/remove/repoint/status |
|
||||
| `scope:coolify` | `commands/coolify-*` — Coolify and its backup install |
|
||||
| `scope:db` | `commands/db.sh` — dump/restore and the round-trip proof |
|
||||
| `scope:installer` | `install.sh` — how rig itself lands on a machine |
|
||||
|
||||
## 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 machine-owned, with exactly one exception. 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 PR events (label changes included)
|
||||
plus a 15-minute cron. A hand-moved label is a lie waiting to happen; the
|
||||
workflow asserts the effective state instead.
|
||||
|
||||
The exception is `state:needs-human`, which the author sets at handoff
|
||||
([CONTRIBUTING.md](CONTRIBUTING.md), step 6). That is an optimistic write, not
|
||||
a transfer of ownership: because `pull_request_target: labeled` wakes the
|
||||
workflow, the author's own label write fires the sweep that validates it, and
|
||||
a handoff that had not earned the label is corrected within seconds.
|
||||
|
||||
It exists because the wake signal was missing. There is no
|
||||
`pull_request_review_target` — on fork PRs, which is all of them here,
|
||||
`pull_request_review` runs read-only and cannot label anything — so the moment
|
||||
the label becomes true, the third approval landing, fired nothing at all. What
|
||||
was left was the `*/15` cron, and GitHub deprioritises short intervals hard
|
||||
enough that the delivered rate is closer to hourly. The label could therefore
|
||||
lag the round it described by hours, worst on the quietest repo: every sweep
|
||||
reconciles the whole board, so a busy repo stays fresh by piggybacking on
|
||||
unrelated PR events, while a quiet one depends on the cron most and receives
|
||||
it least. `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 "blocker:conflict" --color B60205 --description "Does not merge — the branch conflicts and the agent owes a rebase" --force
|
||||
gh label create "blocker:ci-red" --color B60205 --description "A check is failing — the agent owes a fix (not a rebase)" --force
|
||||
gh label create "blocker:unrequested" --color E99695 --description "Somebody still owes a verdict and nobody was asked for one" --force
|
||||
# Needs a MAINTAINER account — the bot 403s on label creation. Until it exists, `blocked` stands in.
|
||||
gh label create "blocker:drill-pending" --color E99695 --description "Release PR with no drill record at drills/<version>.md — correct but unevidenced" --force
|
||||
# retired — the reconciler strips it; delete it once no PR carries it
|
||||
# gh label delete "state:needs-rebase"
|
||||
gh label create "state:needs-human" --color 8250DF --description "No blockers, all bots approve — waiting on the human reviewer" --force
|
||||
gh label create "merge-next" --color 0E8A16 --description "Head of the merge queue — merge this one next (set by hand/agent, cleared here)" --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:bootstrap" --color C5DEF5 --description "bootstrap — hardening a pristine server into a node" --force
|
||||
gh label create "scope:users" --color C5DEF5 --description "users-* — root-door model, apply/status, close-root" --force
|
||||
gh label create "scope:runner" --color C5DEF5 --description "runner-* — GitHub runner lifecycle" --force
|
||||
gh label create "scope:coolify" --color C5DEF5 --description "coolify-* — Coolify and backup install" --force
|
||||
gh label create "scope:db" --color C5DEF5 --description "db.sh — dump/restore" --force
|
||||
gh label create "scope:installer" --color C5DEF5 --description "install.sh — how rig lands on a machine" --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
|
||||
```
|
||||
177
README.md
177
README.md
|
|
@ -4,7 +4,7 @@ A CLI that turns a **pristine Debian server into a hardened, tailnet-joined
|
|||
node** — one curl, one command. A second command installs a version-pinned
|
||||
Coolify on a control-plane box. And inside a [box](https://github.com/heavy-duty/box)-minted
|
||||
guest, the same verb converges the **box tenants** — claude-box, codex-box,
|
||||
grok-box, kimi-box, staging-box — from thin, creds-free seeds (see *the box tenants*
|
||||
grok-box, staging-box — from thin, creds-free seeds (see *the box tenants*
|
||||
below).
|
||||
|
||||
Philosophy (shared with [box](https://github.com/heavy-duty/box)):
|
||||
|
|
@ -15,10 +15,7 @@ takes arguments, does its work, and stores no credential, ever.
|
|||
## Install
|
||||
|
||||
```sh
|
||||
# the development tree this README documents:
|
||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | RIG_REF=main bash
|
||||
# the latest release:
|
||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||
```
|
||||
|
||||
This README tracks `main`, so the quick start installs that same development
|
||||
|
|
@ -94,7 +91,7 @@ itself is untouched — what bootstrap converged stays converged.
|
|||
|
||||
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a
|
||||
second run changes nothing. (The box TENANT roles — `claude-box`, `codex-box`,
|
||||
`grok-box`, `kimi-box`, `staging-box` — share the verb but are their own family; the
|
||||
`grok-box`, `staging-box` — share the verb but are their own family; the
|
||||
`-box` suffix says so. See *the box tenants* below.)
|
||||
|
||||
```sh
|
||||
|
|
@ -170,7 +167,7 @@ trait), and every other way that step can fail lands in apply's existing
|
|||
refusal at the end.
|
||||
|
||||
`--users` does **not** reach the box TENANT roles (`claude-box`, `codex-box`,
|
||||
`grok-box`, `kimi-box`, `staging-box`). A tenant is a box-minted *guest*: box auto-runs its bootstrap at
|
||||
`grok-box`, `staging-box`). A tenant is a box-minted *guest*: box auto-runs its bootstrap at
|
||||
mint, non-interactively, with no file to hand it; the guest never joins the
|
||||
tailnet and has no SSH door of its own — you enter with `box shell`, gated by
|
||||
the **host's** `incus` grants, which the host's own users file already
|
||||
|
|
@ -240,33 +237,11 @@ the only shapes it manages — every other role refuses an effective
|
|||
`tag:server` after join, one rule instead of per-role exceptions.
|
||||
|
||||
After the tag verification passes, bootstrap writes `/etc/rig/role` — one
|
||||
line, `role=… root-door=… host=… join=… join-by=…` — recording the
|
||||
**effective** traits, overrides and all, plus whether this run performed the
|
||||
tailnet join. `join-by=rig` means bootstrap called `tailscale up`;
|
||||
`join-by=preexisting` means it found the node already joined. Old markers name
|
||||
neither and are treated as unknown, never as permission to remove a join.
|
||||
Written post-join and cmp-guarded, so a marker never describes a box that
|
||||
failed to become what it claims.
|
||||
|
||||
### `rig bootstrap --undo`
|
||||
|
||||
```sh
|
||||
sudo rig bootstrap --undo
|
||||
```
|
||||
|
||||
Leaves the tailnet and then removes `/etc/rig/role`, but only when the marker
|
||||
says `join-by=rig`. A pre-existing join, an old marker with no provenance, or
|
||||
no marker at all is refused without calling `tailscale logout`; the refusal
|
||||
names the manual repair. Re-running bootstrap writes the current marker shape.
|
||||
|
||||
Undo also refuses while a GitHub runner is installed and points at
|
||||
`rig runner remove`, because restoring the local machine while leaving an
|
||||
off-box runner registration would create a ghost in the repository. If
|
||||
`tailscale logout` fails, the marker stays in place so the command is retryable.
|
||||
|
||||
This is intentionally not a general rollback. It does not uninstall packages,
|
||||
reverse sshd hardening, remove Docker, Node, agent CLIs, or users. Those changes
|
||||
are convergent rather than transactional and cannot be safely inferred away.
|
||||
line, `role=… root-door=… host=… join=…` — recording the **effective** traits,
|
||||
overrides and all, so an overridden role never lies to the commands that read
|
||||
the marker later (`rig users` keys root policy off `root-door=`). Written
|
||||
post-join and cmp-guarded, so a marker never describes a box that failed to
|
||||
become what it claims.
|
||||
|
||||
Immediately after it, bootstrap stamps `/etc/rig/manifest` — **provenance**:
|
||||
which rig converged this box and when (see [`rig
|
||||
|
|
@ -383,17 +358,23 @@ box is present) and **opt-out** (`RIG_SKIP_BOX_INSTALL=1`, plus a graceful skip
|
|||
with a manual-command pointer when curl or the network is missing — box is the
|
||||
host *extra*, so a failed box install never aborts a bootstrap that otherwise
|
||||
succeeded). Source is pinnable with `BOX_REPO` / `BOX_REF` (default
|
||||
`heavy-duty/box@0.9.0`). If `/dev/kvm` is absent, rig warns (a host that exists to
|
||||
`heavy-duty/box@main`). If `/dev/kvm` is absent, rig warns (a host that exists to
|
||||
run VMs should have it) but does not fail — the shape is rehearsed in containers,
|
||||
which legitimately lack it. (The world-readable global install path — box under
|
||||
`/opt/box` readable by every non-root user — depends on box PR #71; until that
|
||||
merges box's root install lands in `/root`.)
|
||||
|
||||
> **The box install is release-pinned.** A rig release carries one box release
|
||||
> pin, so two machines bootstrapped from the same rig install the same box.
|
||||
> `BOX_REPO` / `BOX_REF` remain explicit overrides for development and
|
||||
> pre-release drills; `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host
|
||||
> whose box you manage by hand.
|
||||
> **The box install is unpinned — on purpose, and out loud.** `coolify install`
|
||||
> demands a version pin; the box step tracks a moving `heavy-duty/box@main`.
|
||||
> Not because box self-updates (it doesn't — it has Coolify's shape, not the
|
||||
> runner's) but because there is nothing to pin *to*: box cuts no tags and no
|
||||
> releases, and its installer resolves `refs/heads/<ref>` — branches only — so
|
||||
> a `BOX_REF=v0.5.0` would 404 even if the tag existed. Issue #12's call was
|
||||
> that silently tracking `main` on the box that runs the agents is the option
|
||||
> not to pick — hence this paragraph. `BOX_REPO` / `BOX_REF` are the pin
|
||||
> points the day box cuts a tag (or you point at a frozen branch of your own
|
||||
> fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you
|
||||
> manage by hand.
|
||||
|
||||
`dev-server` is the closed-door VM-hosting shape — `tag:local`, box CLI installed as
|
||||
above, operators entering as themselves (`--root-door open` turns it into the
|
||||
|
|
@ -401,41 +382,13 @@ unattended VM-host appliance) — and `workstation` is the machine at the keyboa
|
|||
end of all the SSH connections: `root-door=closed`, `join=login`, entering the
|
||||
tailnet as *your* device rather than the fleet's.
|
||||
|
||||
### Machine-role templates
|
||||
|
||||
Machine presets can also live in the
|
||||
[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates)
|
||||
registry. A `*-server` directory is a fleet-machine definition; the exact
|
||||
name `workstation` is the deliberate suffix-less exception. Its
|
||||
`template.env` contains exactly the three traits bootstrap's built-in table
|
||||
uses:
|
||||
|
||||
```dotenv
|
||||
ROOT_DOOR="open" # open|closed
|
||||
HOST="no" # yes|no
|
||||
JOIN="authkey" # authkey|login
|
||||
```
|
||||
|
||||
An `install.sh` is optional. When present, bootstrap runs it as root,
|
||||
non-interactively, from the definition directory with `RIG_ROLE` set, after
|
||||
the tailnet join, host setup, role marker prerequisites, and operator
|
||||
convergence. A nonzero exit fails bootstrap and names the role and registry
|
||||
source. The definition owns idempotence, just as bootstrap does.
|
||||
|
||||
Built-in roles and `custom` take precedence over registry names. Any other
|
||||
non-tenant role is looked up in the resolved registry; the same three source
|
||||
knobs below apply, including `RIG_TEMPLATES_DIR` for an offline local
|
||||
definition. Pin reviewed registry content into rig's tree before using it on
|
||||
fleet machines: an optional machine `install.sh` executes as root on metal,
|
||||
and an override is the operator explicitly choosing a different trust root.
|
||||
|
||||
### `rig bootstrap <role>-box` — the box tenants
|
||||
### `rig bootstrap <claude-box|codex-box|grok-box|staging-box>` — the box tenants
|
||||
|
||||
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
|
||||
guest. Convergent — safe to re-run; a second run changes nothing.
|
||||
|
||||
```sh
|
||||
rig bootstrap claude-box # or codex-box, grok-box, kimi-box — the agent tenants
|
||||
rig bootstrap claude-box # or codex-box, grok-box — the agent tenants
|
||||
rig bootstrap staging-box # the server tenant (docker + sshd hardening)
|
||||
rig bootstrap claude-box --user dev # when the seed's BOX_USER differs
|
||||
```
|
||||
|
|
@ -450,48 +403,15 @@ same harness as everything else — and re-runnable on an *existing* box to
|
|||
converge it to a new spec instead of re-minting it. One convergence engine;
|
||||
the guests were the hole.
|
||||
|
||||
It is **one mechanism, parameterized per DEFINITION** (#110), not four
|
||||
hand-maintained scripts. The agent-tenant definitions live in the
|
||||
**[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates)
|
||||
registry** — one directory per role (`template.env`, the allowlisted data
|
||||
table rig parses and never sources; `install.sh`, the CLI install; `creds.md`,
|
||||
the per-vendor context paragraph) — so adding an agent tenant is a data PR
|
||||
there, never a mechanism edit here (#109 was the evidence: adding kimi, pure
|
||||
data, meant editing six files in this repo). Which `-box` roles exist is the
|
||||
registry's fact; `rig bootstrap <anything>-box` dispatches on the family
|
||||
suffix and refuses an unknown role by listing what the resolved registry
|
||||
actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacent
|
||||
(sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user
|
||||
`ops`, box#69's server posture with `root-door=open` acceptance.
|
||||
It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh`
|
||||
holds the whole per-tenant table), not four hand-maintained scripts:
|
||||
|
||||
**Where the registry comes from — precedence high to low:**
|
||||
|
||||
| knob | meaning |
|
||||
|------|---------|
|
||||
| `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" |
|
||||
| `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time |
|
||||
| *(neither set; matching snapshot installed)* | **the installed pin snapshot** — `install.sh` best-effort fetches `RIG_TEMPLATES_PIN` once into `templates@<pin-sha>/` inside the versioned rig tree; default converges read it with zero registry network I/O |
|
||||
| *(snapshot absent, empty, or stale)* | **live fetch of the in-tree pin** — the pre-snapshot fallback: `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh` is fetched at converge time. A failed snapshot download only warns during install, so rig remains usable and retries here |
|
||||
|
||||
The pin remains the only source of truth. An older `templates@<sha>/`
|
||||
directory cannot answer after a pin bump, and an explicit
|
||||
`RIG_TEMPLATES_REF` always fetches that ref rather than consulting the
|
||||
snapshot. Logs mark the installed path as `(snapshot)` so drill evidence
|
||||
records which source actually served the converge.
|
||||
|
||||
**The security trade — in bold, not a footnote.** **A main-tracked
|
||||
rig-templates repo means every merged PR there executes as root inside every
|
||||
future mint.** This is acceptable — and an improvement — only because of
|
||||
three facts together: (1) it *narrows* today's surface, where all of rig is
|
||||
main-tracked-as-root; (2) the repo is small, single-purpose, and
|
||||
ceremony-governed with a **human merge** as the gate and the review panel
|
||||
ahead of it; (3) drills pin the SHA they proved. If any of those three
|
||||
weakens, the default flips to a pinned `RIG_TEMPLATES_REF`. install.sh diffs
|
||||
in that repo are the highest-trust review surface in the org — the reviewer
|
||||
doctrine should say so. *(2026-07-24: the flip this paragraph reserves was
|
||||
taken, before the migration and by the decider — the default IS the pin
|
||||
above, so a merged template reaches mints only through a reviewed pin bump
|
||||
here, or an explicit per-mint `RIG_TEMPLATES_REF`.)*
|
||||
| tenant role | user | what lands |
|
||||
|---------------|----------|------------|
|
||||
| `claude-box` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` |
|
||||
| `codex-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` |
|
||||
| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
|
||||
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) |
|
||||
|
||||
**The role carries the suffix; the user does not.** A tenant user is the
|
||||
account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
|
||||
|
|
@ -525,9 +445,8 @@ disposability facts, and the guard note — **never run `box setup-host`,
|
|||
`box teardown-host`, or the drill inside a box; the box you are in is not a
|
||||
host you own**. A nested box stack claims the guest's own uplink subnet and
|
||||
silently breaks its networking (box#80). The note lives in
|
||||
`lib/templates.sh`'s renderer exactly once — mechanism, not template data —
|
||||
never copy-pasted per definition; that was the point of moving it here. Only
|
||||
the creds paragraph is the definition's (`creds.md`).
|
||||
`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was
|
||||
the point of moving it here.
|
||||
|
||||
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
|
||||
host=no` — no `root-door=`, because a guest has no root-door policy of its own
|
||||
|
|
@ -814,7 +733,6 @@ What is this machine — computed at run time, **stored nowhere**:
|
|||
```
|
||||
PLATFORM
|
||||
HOSTNAME hetzner-cp-1
|
||||
ID cd9fb802-1493-2336-d027-7955f328bcd8
|
||||
OS Debian GNU/Linux 13 (trixie)
|
||||
KERNEL 6.12.95+deb13-amd64 (x86_64)
|
||||
CPU AMD Ryzen 7 3700X 8-Core Processor (16 cores)
|
||||
|
|
@ -842,33 +760,10 @@ Computing at run time removes the problem instead of managing it: the answer
|
|||
is correct by construction because there is nothing to go stale.
|
||||
|
||||
The corollary is deliberate: **`rig platform` works on a machine rig has never
|
||||
converged.** It reads only `/proc`, `uname`, `/etc/os-release`,
|
||||
`/etc/machine-id`, `df` and `systemd-detect-virt`, so it runs on bare Debian
|
||||
before bootstrap — useful for deciding *what to converge this into*, not just
|
||||
for auditing afterwards. It needs no root, makes no network call, and writes
|
||||
nothing, ever.
|
||||
|
||||
**`ID` names the machine where `HOSTNAME` names the slot** — that contrast is
|
||||
why they sit together. rig sets the hostname itself during bootstrap and
|
||||
reuses it across rebuilds (`hetzner-cp-1` is a role, not hardware), so the
|
||||
hostname cannot answer "is this the same machine I converged in June, or its
|
||||
replacement?". `ID` can: it is derived from `/etc/machine-id` as
|
||||
`sha256("rig-machine-id:<machine-id>")`, first 32 hex chars rendered
|
||||
8-4-4-4-12 — computed at run time and stored nowhere, like every other fact in
|
||||
the block, so it exists before bootstrap too. It is deliberately **not** the
|
||||
raw machine-id: `machine-id(5)` asks that the value not be exposed, and the
|
||||
namespaced hash is its documented remedy — a reader of `rig platform` output
|
||||
cannot recover `/etc/machine-id`, nor correlate the id with any other tool's
|
||||
derivation of it. A missing, empty or `uninitialized` machine-id renders
|
||||
`ID unavailable (reason)` while every other field still reports; it is never
|
||||
an empty string and never a hash of nothing, which would hand every such
|
||||
machine the same identity.
|
||||
|
||||
**Two machines reporting the same `ID` means a cloned image** — actionable
|
||||
information, not a coincidence. A host cloned from a golden image carries the
|
||||
image's `/etc/machine-id`, and no identity that lives in the filesystem
|
||||
survives the filesystem being copied. If you hit it, regenerate the clone's
|
||||
machine-id (`systemd-machine-id-setup`) rather than doubting the field.
|
||||
converged.** It reads only `/proc`, `uname`, `/etc/os-release`, `df` and
|
||||
`systemd-detect-virt`, so it runs on bare Debian before bootstrap — useful for
|
||||
deciding *what to converge this into*, not just for auditing afterwards. It
|
||||
needs no root, makes no network call, and writes nothing, ever.
|
||||
|
||||
The `PROVENANCE` block is the complementary half — which rig, and when, which
|
||||
is *decided* rather than observed, so it is stored. It is **read, never
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.2-dev
|
||||
0.3.1-dev
|
||||
|
|
|
|||
32
bin/rig
32
bin/rig
|
|
@ -33,23 +33,15 @@ commands:
|
|||
tenant roles); custom and workstation take none. join=login
|
||||
(workstation) needs no key: interactive login, node must come up
|
||||
untagged. Run as root.
|
||||
bootstrap --undo
|
||||
Leave the tailnet only when /etc/rig/role proves rig performed the
|
||||
join, then remove that marker. Refuses for pre-existing or old unknown
|
||||
joins, and while a GitHub runner is installed. Run as root.
|
||||
bootstrap <role>-box [--user <name>]
|
||||
bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
|
||||
The box TENANT roles: converge a box-minted guest. The '-box' suffix
|
||||
names the family (a guest, vs the '-server' machine roles above).
|
||||
The agent tenants (claude-box, codex-box, grok-box, kimi-box, …) are
|
||||
DEFINED in the heavy-duty/rig-templates registry — resolved via
|
||||
RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin — and land
|
||||
the agent toolbelt (git, gh, tmux, docker), the agent's CLI on the
|
||||
system PATH, and the agent-context file (with the box#80 guard:
|
||||
never run box setup-host inside a box). staging-box, in rig's own
|
||||
tree, lands box#69's server posture — docker + sshd hardening; its
|
||||
tailnet join stays operator-run via 'rig bootstrap workload-server'.
|
||||
Creds-free and non-interactive: box auto-runs these at mint. Run as
|
||||
root, inside the box.
|
||||
claude-box|codex-box|grok-box land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on
|
||||
the system PATH, and the agent-context file (with the box#80 guard:
|
||||
never run box setup-host inside a box). staging-box lands box#69's
|
||||
server posture — docker + sshd hardening; its tailnet join stays
|
||||
operator-run via 'rig bootstrap workload-server'. Creds-free and non-interactive: box
|
||||
auto-runs these at mint. Run as root, inside the box.
|
||||
coolify install --version <pin>
|
||||
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
||||
coolify backup install [options]
|
||||
|
|
@ -99,12 +91,6 @@ commands:
|
|||
Shut root SSH on a class=human box once an admin key works. Refuses
|
||||
on class=server — root there is the control plane's automation door —
|
||||
and while no admin holds a key. Run as root.
|
||||
template-lint <role-dir>...
|
||||
Validate tenant-role definitions (the heavy-duty/rig-templates
|
||||
shape): template.env against the allowlist (data, never sourced),
|
||||
install.sh present with a shebang, creds.md non-blank. Every refusal
|
||||
names the failing key. The registry repo's CI runs this on every PR;
|
||||
no root, no network, no writes.
|
||||
manifest [<key>]
|
||||
Print /etc/rig/manifest — which rig converged this machine and when
|
||||
(bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at
|
||||
|
|
@ -465,10 +451,6 @@ case "$cmd" in
|
|||
;;
|
||||
esac
|
||||
;;
|
||||
template-lint)
|
||||
shift
|
||||
exec "$ROOT/commands/template-lint.sh" "$@"
|
||||
;;
|
||||
manifest)
|
||||
shift
|
||||
exec "$ROOT/commands/manifest.sh" "$@"
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
### Added
|
||||
|
||||
- Machine-role templates can declare bootstrap traits and an optional final root install hook (#152)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Added
|
||||
|
||||
- Pinned template registries install with rig and serve default converges offline (#153)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- The netmap tag read is scoped to `Self`: an untagged node next to tagged peers no longer reads a peer's tag, false-refusing `--join login` and false-verifying untagged authkey joins (#160)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
### Fixed
|
||||
|
||||
- Agent tenant boxes ship cron — binary asserted, service enabled and active — so the duty engine can arm its timer (#162)
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# changelog.d/ — the next release's section, one fragment per issue
|
||||
|
||||
Machine-assembled by `bin/changelog-assemble` (#112): every PR that changes
|
||||
behavior writes one file here — `<issue>.md`, the exact prose that will be
|
||||
published, nothing else — and the release PR folds them all into the next
|
||||
`## X.Y.Z — DATE` section of `CHANGELOG.md`, consuming them. Distinct
|
||||
filenames never conflict, which is this directory's whole reason to exist.
|
||||
This README is the marker that keeps the directory tracked when it holds no
|
||||
fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not
|
||||
delete it.
|
||||
|
|
@ -1,34 +1,24 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig bootstrap <role>-box — the box TENANT roles ('-box' names the family: a
|
||||
# guest, vs the '-server' machine roles): what a box-minted guest becomes
|
||||
# (issue #31). box mints the thin, creds-free seed (base image, user, rig
|
||||
# preinstalled — heavy-duty/box#81); rig converges the tenant content that
|
||||
# used to live in the templates' cloud-init, idempotent and effective-state
|
||||
# asserted, so an EXISTING box can be re-run to a new spec instead of
|
||||
# re-minted.
|
||||
#
|
||||
# One MECHANISM, parameterized per tenant by a fetched DEFINITION (#110): the
|
||||
# agent-tenant registry lives in heavy-duty/rig-templates — one directory per
|
||||
# role (template.env, install.sh, creds.md), resolved through lib/templates.sh
|
||||
# (RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin) — so adding a
|
||||
# tenant is a data PR there, never an edit here (#109 is the scar: adding
|
||||
# kimi, pure data, meant editing six files in this repo). staging-box is the
|
||||
# one in-tree tenant: it is mechanism-adjacent (sshd hardening, docker — no
|
||||
# agent, no CLI, no context file), so it converges from rig's own tree.
|
||||
# rig bootstrap <claude-box|codex-box|grok-box|staging-box> — the box TENANT
|
||||
# roles ('-box' names the family: a guest, vs the '-server' machine roles): what a
|
||||
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed
|
||||
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the
|
||||
# tenant content that used to live in the templates' cloud-init, idempotent and
|
||||
# effective-state asserted, so an EXISTING box can be re-run to a new spec
|
||||
# instead of re-minted. One mechanism, parameterized per tenant through
|
||||
# lib/tenant-config.sh — never four hand-maintained copies.
|
||||
#
|
||||
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
|
||||
# bootstrap claude-box'), so every path here is non-interactive and nothing joins
|
||||
# or admits — no tailnet, no keys, no prompts. That is also why the registry
|
||||
# fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
|
||||
# staging-box's tailnet join stays operator-run ('rig bootstrap
|
||||
# workload-server' through 'box shell'), exactly the creds split box#69
|
||||
# designed.
|
||||
# or admits — no tailnet, no keys, no prompts. staging-box's tailnet join stays
|
||||
# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the
|
||||
# creds split box#69 designed.
|
||||
# Convergent: safe to re-run; a second run changes nothing.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=SCRIPTDIR/lib/templates.sh
|
||||
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
|
||||
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
|
||||
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
|
||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
||||
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||
|
|
@ -42,35 +32,26 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: rig bootstrap <role>-box [--user <name>]
|
||||
usage: rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
|
||||
|
||||
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
|
||||
creds-free seed (base image, user, rig preinstalled); this converges the
|
||||
tenant on top, and re-runs converge an existing box to a new spec.
|
||||
|
||||
<role>-box an agent tenant DEFINED IN THE REGISTRY
|
||||
(heavy-duty/rig-templates — claude-box, codex-box,
|
||||
grok-box, kimi-box, …): base tooling (git, gh, tmux, …),
|
||||
claude-box|codex-box|grok-box
|
||||
the agent tenants: base tooling (git, gh, tmux, …),
|
||||
docker, the agent's CLI on the system PATH, and the
|
||||
agent-context file — including the box#80 guard: never
|
||||
run `box setup-host` or the drill inside a box.
|
||||
staging-box the server tenant (box#69's posture), in rig's own
|
||||
tree: docker + sshd hardening. The tailnet workload
|
||||
join is deliberately NOT here — it holds a credential,
|
||||
so it stays operator-run: `box shell` → `sudo rig
|
||||
bootstrap workload-server` with a tagged pre-auth key.
|
||||
staging-box the server tenant (box#69's posture): docker + sshd
|
||||
hardening. The tailnet workload join is deliberately
|
||||
NOT here — it holds a credential, so it stays
|
||||
operator-run: `box shell` → `sudo rig bootstrap
|
||||
workload-server` with a tagged pre-auth key.
|
||||
|
||||
--user <name> the tenant user the box seed created (default: the
|
||||
definition's USER; staging-box defaults to `ops`)
|
||||
|
||||
The registry source is three knobs, precedence high to low:
|
||||
RIG_TEMPLATES_DIR a local folder (no fetch — the offline/test path, and
|
||||
"try a template before it exists anywhere")
|
||||
RIG_TEMPLATES_REF a ref of RIG_TEMPLATES_REPO (default
|
||||
heavy-duty/rig-templates), fetched as a tarball
|
||||
(neither set) the ref pinned in rig's tree (lib/templates.sh
|
||||
RIG_TEMPLATES_PIN — bumped by ordinary rig PR, so a
|
||||
rig release freezes the mechanism+registry pair)
|
||||
role's name minus the suffix; staging-box defaults to
|
||||
`ops`)
|
||||
|
||||
Tenant roles are creds-free and non-interactive by contract — box auto-runs
|
||||
them at mint (`box exec … rig bootstrap claude-box`). They take none of the
|
||||
|
|
@ -82,35 +63,19 @@ EOF
|
|||
# --- args (validated before the root check, so errors are testable) ---------
|
||||
ROLE="${1:-}"
|
||||
case "$ROLE" in
|
||||
staging-box) shift ;;
|
||||
*-box)
|
||||
# The family suffix is the whole gate here — WHICH '-box' roles exist is
|
||||
# the resolved registry's fact, checked below, so a template added to the
|
||||
# registry is mintable with zero code changes in rig (#110).
|
||||
shift ;;
|
||||
claude-box|codex-box|grok-box|staging-box) shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
"") usage >&2; die "tenant role required (a '-box' role from the template registry, or staging-box)" 2 ;;
|
||||
*) die "unknown tenant role: $ROLE — tenant roles carry the '-box' family suffix (#76); the machine roles are control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom" 2 ;;
|
||||
"") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|staging-box)" 2 ;;
|
||||
*) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|staging-box)" 2 ;;
|
||||
esac
|
||||
# The suffix rule above admits ANY '-box' name, so the charset is pinned
|
||||
# before the name is ever used as a path component: a crafted role dies HERE,
|
||||
# never in a registry lookup (the valid_version discipline, bin/rig).
|
||||
[[ "$ROLE" =~ ^[a-z][a-z0-9-]*-box$ ]] \
|
||||
|| die "invalid tenant role name: '$ROLE' — must match ^[a-z][a-z0-9-]*-box\$" 2
|
||||
|
||||
TENANT_USER_OVERRIDE=""
|
||||
TENANT_USER="$(tenant_user "$ROLE")"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--user)
|
||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||
TENANT_USER_OVERRIDE="$2"; shift 2
|
||||
# Same charset the users file enforces, for the same reasons (a leading
|
||||
# '-' reads as a usermod flag; '|', ':' corrupt things downstream).
|
||||
# Checked HERE, at parse — the definition's USER is checked by the
|
||||
# parser — so the refusal needs no registry and no network.
|
||||
[[ "$TENANT_USER_OVERRIDE" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|
||||
|| die "invalid user: '$TENANT_USER_OVERRIDE' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2 ;;
|
||||
TENANT_USER="$2"; shift 2 ;;
|
||||
--hostname|--root-door|--host|--join)
|
||||
# The machine-role traits, refused with a story rather than "unknown
|
||||
# flag": a tenant is a guest, not a tailnet machine — its shape comes
|
||||
|
|
@ -124,6 +89,10 @@ while [ $# -gt 0 ]; do
|
|||
*) die "unknown flag: $1" 2 ;;
|
||||
esac
|
||||
done
|
||||
# Same charset the users file enforces, for the same reasons (a leading '-'
|
||||
# reads as a usermod flag; '|', ':' corrupt things downstream).
|
||||
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|
||||
|| die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2
|
||||
|
||||
# --- guards ------------------------------------------------------------------
|
||||
# A tenant role converges a box GUEST. A box already carrying a machine-role
|
||||
|
|
@ -171,34 +140,6 @@ if [ -n "$EXISTING_ROOT_DOOR" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# --- the definition ----------------------------------------------------------
|
||||
# Resolved and parsed BEFORE the root check (but after the marker guards,
|
||||
# which need no definition and must stay refusable with no registry in
|
||||
# reach), so the two refusals a definition can earn — unknown role (listing
|
||||
# what the resolved source actually contains) and malformed data (naming the
|
||||
# failing key) — are testable non-root, offline, via RIG_TEMPLATES_DIR
|
||||
# fixtures. The parse is the mint's
|
||||
# own guard, deliberately duplicating the registry CI's lint: CI protects the
|
||||
# registry, this protects a mint served through RIG_TEMPLATES_REPO/_DIR that
|
||||
# CI never saw. template.env is parsed, NEVER sourced — a definition cannot
|
||||
# execute arbitrary shell through its data file; install.sh is the one
|
||||
# deliberately executable part, and it runs only after the root check below.
|
||||
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
|
||||
TPL_DIR=""
|
||||
if [ "$ROLE" = "staging-box" ]; then
|
||||
TENANT_USER="${TENANT_USER_OVERRIDE:-ops}" # box#69's ops
|
||||
else
|
||||
templates_resolve \
|
||||
|| die "cannot resolve the template registry ($(templates_source_desc)) — see above" 2
|
||||
TPL_DIR="$REGISTRY_DIR/$ROLE"
|
||||
if [ ! -f "$TPL_DIR/template.env" ]; then
|
||||
die "unknown tenant role: $ROLE — the resolved registry ($(templates_source_desc)) defines: $(templates_roles "$REGISTRY_DIR" | tr '\n' ' ')— and staging-box is in rig's own tree. A misconfigured RIG_TEMPLATES_REPO/_REF/_DIR looks exactly like this; check the source before the spelling." 2
|
||||
fi
|
||||
template_parse_env "$TPL_DIR/template.env" \
|
||||
|| die "invalid definition for $ROLE in $(templates_source_desc) — the failing key is named above. The registry's CI lints every PR ('rig template-lint'); a malformed definition reaching a mint means the source above was never linted." 2
|
||||
TENANT_USER="${TENANT_USER_OVERRIDE:-$TPL_USER}"
|
||||
fi
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
if [ -r /etc/os-release ]; then
|
||||
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
|
||||
|
|
@ -233,46 +174,23 @@ append_line_once() {
|
|||
chown "$TENANT_USER:$TENANT_GROUP" "$file"
|
||||
}
|
||||
|
||||
# The binary on PATH is not the effective state — an image can ship crontab
|
||||
# with cron.service masked or stopped, and an unarmed timer is exactly the
|
||||
# silent-inert box #162 is about. Converge best-effort, then assert what
|
||||
# systemd actually reports; the assert is the authority. Enabling an
|
||||
# already-enabled unit is a no-op and no path here touches any crontab.
|
||||
# A function so test/cli.sh can lift it verbatim and drive it against a
|
||||
# stubbed systemctl, the drop_incus precedent.
|
||||
converge_cron() {
|
||||
if ! systemctl is-enabled cron >/dev/null 2>&1; then
|
||||
log "cron.service not enabled — converging"
|
||||
systemctl unmask cron >/dev/null 2>&1 || true
|
||||
systemctl enable cron >/dev/null 2>&1 || true
|
||||
fi
|
||||
if ! systemctl is-active cron >/dev/null 2>&1; then
|
||||
log "cron.service not active — starting"
|
||||
systemctl start cron >/dev/null 2>&1 || true
|
||||
fi
|
||||
systemctl is-enabled cron >/dev/null 2>&1 || die "cron.service is not enabled after converge — the duty engine's timer never fires without it (#162)"
|
||||
systemctl is-active cron >/dev/null 2>&1 || die "cron.service is not active after converge — the duty engine's timer never fires without it (#162)"
|
||||
}
|
||||
|
||||
# --- packages ----------------------------------------------------------------
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
log "installing base packages (tenant ${ROLE})"
|
||||
apt-get update -qq
|
||||
if [ "$ROLE" = "staging-box" ]; then
|
||||
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
|
||||
# which only exists once the package is installed — pristine container/VM
|
||||
# images (and thin seeds) do not ship it.
|
||||
apt-get install -y -qq curl ca-certificates tmux openssh-server
|
||||
else
|
||||
# The shared agent toolbelt the templates carried, plus the definition's
|
||||
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
|
||||
# word list, every word already vetted by the parser's package-name gate.
|
||||
# cron is toolbelt, not a template flavour: every agent tenant exists to
|
||||
# run the cron-driven duty engine, whose unprivileged installer can detect
|
||||
# a missing cron but never apt-get it (#162).
|
||||
# shellcheck disable=SC2086
|
||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential cron $TPL_APT_EXTRAS
|
||||
fi
|
||||
case "$ROLE" in
|
||||
claude-box)
|
||||
# The claude-box tenant keeps zsh (its shell UX ships with the box); the
|
||||
# remaining list is the shared agent toolbelt the templates carried.
|
||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;;
|
||||
codex-box|grok-box)
|
||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
|
||||
staging-box)
|
||||
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
|
||||
# which only exists once the package is installed — pristine container/VM
|
||||
# images (and thin seeds) do not ship it.
|
||||
apt-get install -y -qq curl ca-certificates tmux openssh-server ;;
|
||||
esac
|
||||
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
|
||||
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
|
||||
# the operator's git credential lands.
|
||||
|
|
@ -280,9 +198,6 @@ command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — '
|
|||
if [ "$ROLE" != "staging-box" ]; then
|
||||
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
|
||||
command -v git >/dev/null 2>&1 || die "git missing after package install"
|
||||
command -v crontab >/dev/null 2>&1 || die "crontab missing after package install — the duty engine arms itself with cron (#162)"
|
||||
# staging-box is exempt with the rest of this block: no agent, no engine.
|
||||
converge_cron
|
||||
fi
|
||||
|
||||
# --- docker ------------------------------------------------------------------
|
||||
|
|
@ -317,19 +232,17 @@ else
|
|||
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
|
||||
fi
|
||||
|
||||
# --- node (definitions carrying NEEDS_NODE="yes") ----------------------------
|
||||
# An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
|
||||
# verified upstream when the template was written); claude ships node as part
|
||||
# of its toolbelt, same pin. Whether a tenant needs it is the DEFINITION's
|
||||
# fact (NEEDS_NODE), never a role list here — grok's CLI is a self-contained
|
||||
# binary and kimi's is uv-managed Python, so both say no.
|
||||
# --- node (claude-box, codex-box) ----------------------------------------------------
|
||||
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified
|
||||
# upstream when the template was written); the claude-box tenant ships node as part
|
||||
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node.
|
||||
node_ok() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
local major
|
||||
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
|
||||
[ "${major:-0}" -ge 22 ] 2>/dev/null
|
||||
}
|
||||
if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
|
||||
if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
|
||||
if node_ok; then
|
||||
log "node $(node --version) already present"
|
||||
else
|
||||
|
|
@ -341,53 +254,46 @@ if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
|
|||
fi
|
||||
|
||||
# --- the agent CLI -----------------------------------------------------------
|
||||
# Per-definition install, shared discipline: install only when the CLI is
|
||||
# absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
|
||||
# definition names one, `command -v` when it does not (an npm global's path
|
||||
# is the prefix's fact, not the data file's) — then put it on the SYSTEM
|
||||
# path: 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads
|
||||
# no rc files, so a PATH export alone is invisible to it (the #15 lesson).
|
||||
# And assert it ANSWERS as the tenant user: a CLI that exists but cannot run
|
||||
# is what cost the last drill (the grok-box template's scar).
|
||||
#
|
||||
# install.sh — the definition's one executable part — runs AS ROOT with the
|
||||
# tenant named in its environment (TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE);
|
||||
# each definition drops to the tenant user itself (runuser -l) where the
|
||||
# vendor's layout demands it, because some installs are inherently root's
|
||||
# (codex's npm -g writes the global prefix). This is the trade #110 states in
|
||||
# bold — a registry definition executes as root inside every future mint —
|
||||
# and it is why install.sh diffs there are the highest-trust review surface
|
||||
# in the org, why the default ref is a reviewed in-tree pin, and why the data
|
||||
# file beside it is parsed rather than sourced.
|
||||
# Per-agent install, shared discipline: install only when the CLI is absent
|
||||
# (upgrades are the CLI's own business), then put it on the SYSTEM path —
|
||||
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc
|
||||
# files, so a PATH export alone is invisible to it (the #15 lesson) — and
|
||||
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is
|
||||
# what cost the last drill (the grok-box template's scar).
|
||||
CLI="" CLI_SRC=""
|
||||
if [ "$ROLE" != "staging-box" ]; then
|
||||
CLI="$TPL_CLI_NAME"
|
||||
# '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
|
||||
# substitution, never by the shell (hence the literal quoted tilde, SC2088).
|
||||
# shellcheck disable=SC2088
|
||||
case "$TPL_CLI_SRC" in
|
||||
'~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
|
||||
*) CLI_SRC="$TPL_CLI_SRC" ;;
|
||||
esac
|
||||
installed=""
|
||||
if [ -n "$CLI_SRC" ]; then
|
||||
[ -e "$CLI_SRC" ] && installed=1
|
||||
elif command -v "$CLI" >/dev/null 2>&1; then
|
||||
installed=1
|
||||
fi
|
||||
if [ -z "$installed" ]; then
|
||||
log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
|
||||
TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
|
||||
TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
|
||||
bash "$TPL_DIR/install.sh" \
|
||||
|| die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
|
||||
else
|
||||
log "${CLI} CLI already installed"
|
||||
fi
|
||||
if [ -z "$CLI_SRC" ]; then
|
||||
CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
|
||||
[ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?"
|
||||
fi
|
||||
case "$ROLE" in
|
||||
claude-box)
|
||||
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude"
|
||||
if [ ! -e "$CLI_SRC" ]; then
|
||||
log "installing the Claude Code CLI as ${TENANT_USER}"
|
||||
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash'
|
||||
else
|
||||
log "claude CLI already installed"
|
||||
fi ;;
|
||||
codex-box)
|
||||
CLI=codex
|
||||
if ! command -v codex >/dev/null 2>&1; then
|
||||
log "installing the Codex CLI (npm global)"
|
||||
npm install -g @openai/codex
|
||||
else
|
||||
log "codex CLI already installed"
|
||||
fi
|
||||
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
|
||||
grok-box)
|
||||
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`,
|
||||
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir.
|
||||
# Run it AS the tenant user, never root: a symlink into root's 0700 home
|
||||
# would be a CLI that exists and cannot run.
|
||||
CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok"
|
||||
if [ ! -e "$CLI_SRC" ]; then
|
||||
log "installing the Grok CLI as ${TENANT_USER}"
|
||||
runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash'
|
||||
else
|
||||
log "grok CLI already installed"
|
||||
fi ;;
|
||||
staging-box) ;; # no agent lives on the staging-box tenant
|
||||
esac
|
||||
if [ -n "$CLI" ]; then
|
||||
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
|
||||
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
|
||||
# One capture serves both the assert and the log line; emptiness IS the
|
||||
|
|
@ -396,23 +302,27 @@ if [ "$ROLE" != "staging-box" ]; then
|
|||
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
|
||||
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
|
||||
|
||||
# The interactive-shell PATH export the templates carried, converged as a
|
||||
# literal rc line (written once, never duplicated). The definition's
|
||||
# PATH_LINE is DATA, appended verbatim: it must expand in the USER's
|
||||
# shell, not here.
|
||||
append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
|
||||
# The interactive-shell PATH exports the templates carried, converged as
|
||||
# literal rc lines (written once, never duplicated). Single quotes are the
|
||||
# point: the line must expand in the USER's shell, not here.
|
||||
# shellcheck disable=SC2016
|
||||
case "$ROLE" in
|
||||
claude-box)
|
||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
|
||||
codex-box)
|
||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
|
||||
grok-box)
|
||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- the agent-context file --------------------------------------------------
|
||||
# The one file every agent reads before touching anything. The skeleton —
|
||||
# including the box#80 guard note ("never run box setup-host or the drill
|
||||
# inside a box; the box you are in is not a host you own") — is MECHANISM,
|
||||
# rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
|
||||
# template; only the creds paragraph is the definition's (creds.md).
|
||||
# cmp-guarded like every file rig converges. staging-box has no agent and no
|
||||
# context file.
|
||||
if [ "$ROLE" != "staging-box" ]; then
|
||||
CTX_PATH="$TENANT_HOME/$TPL_CONTEXT_PATH"
|
||||
# The one file every agent reads before touching anything. Rendered from
|
||||
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or
|
||||
# the drill inside a box; the box you are in is not a host you own") lives
|
||||
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded
|
||||
# like every file rig converges.
|
||||
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
||||
CTX_DIR="$(dirname "$CTX_PATH")"
|
||||
if [ ! -d "$CTX_DIR" ]; then
|
||||
mkdir -p "$CTX_DIR"
|
||||
|
|
@ -422,7 +332,7 @@ if [ "$ROLE" != "staging-box" ]; then
|
|||
# its ownership is converged on every run, not only on creation.
|
||||
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
|
||||
CTX_TMP="$(mktemp)"
|
||||
render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$CTX_TMP"
|
||||
render_tenant_context "$ROLE" > "$CTX_TMP"
|
||||
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
|
||||
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
|
||||
log "agent-context file written: ${CTX_PATH}"
|
||||
|
|
@ -432,6 +342,32 @@ if [ "$ROLE" != "staging-box" ]; then
|
|||
rm -f "$CTX_TMP"
|
||||
fi
|
||||
|
||||
# --- claude-box shell niceties ---------------------------------------------------
|
||||
# The claude-box template shipped zsh + oh-my-zsh + tmux mouse mode; they move with
|
||||
# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a
|
||||
# bootstrap whose real work (CLI, context, docker) already converged.
|
||||
if [ "$ROLE" = "claude-box" ]; then
|
||||
if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then
|
||||
chsh -s /usr/bin/zsh "$TENANT_USER"
|
||||
log "login shell set to zsh for ${TENANT_USER}"
|
||||
else
|
||||
log "login shell already zsh for ${TENANT_USER}"
|
||||
fi
|
||||
if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then
|
||||
log "installing oh-my-zsh for ${TENANT_USER}"
|
||||
# Single quotes on purpose: the $(...) must run in the USER's shell.
|
||||
# shellcheck disable=SC2016
|
||||
runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \
|
||||
|| warn "oh-my-zsh install failed — cosmetic only; continuing"
|
||||
else
|
||||
log "oh-my-zsh already installed"
|
||||
fi
|
||||
# After oh-my-zsh (it rewrites .zshrc on first install).
|
||||
# shellcheck disable=SC2016
|
||||
append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"'
|
||||
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
|
||||
fi
|
||||
|
||||
# --- staging-box server posture --------------------------------------------------
|
||||
# box#69's posture, minus the join: docker (above) + sshd hardening, through
|
||||
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig bootstrap --undo — remove only off-box state rig can prove it created.
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf 'rig-bootstrap: %s\n' "$*"; }
|
||||
die() { printf 'rig-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
MARKER="${RIG_ROLE_MARKER:-/etc/rig/role}"
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||
[ -e "$MARKER" ] || die "no /etc/rig/role marker — refusing to touch the tailnet"
|
||||
|
||||
runner_installed=0
|
||||
if [ -n "${RIG_RUNNER_DIR:-}" ]; then
|
||||
[ -e "$RIG_RUNNER_DIR/.runner" ] && runner_installed=1
|
||||
else
|
||||
for runner_config in /home/*/actions-runner/.runner /root/actions-runner/.runner; do
|
||||
[ -e "$runner_config" ] && runner_installed=1
|
||||
done
|
||||
compgen -G '/etc/systemd/system/actions.runner.*.service' >/dev/null \
|
||||
&& runner_installed=1
|
||||
fi
|
||||
if [ "$runner_installed" -eq 1 ]; then
|
||||
die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository"
|
||||
fi
|
||||
|
||||
join_by=""
|
||||
while IFS= read -r field; do
|
||||
case "$field" in
|
||||
join-by=*) join_by="${field#join-by=}" ;;
|
||||
esac
|
||||
done < <(tr '[:space:]' '\n' < "$MARKER")
|
||||
|
||||
case "$join_by" in
|
||||
rig) ;;
|
||||
preexisting)
|
||||
die "the tailnet join predates this bootstrap run (join-by=preexisting), so rig will not remove state it did not create; run 'tailscale logout' by hand if that is intended" ;;
|
||||
"")
|
||||
die "the role marker predates join-by provenance, so rig cannot prove it made this tailnet join and will not remove it; re-run bootstrap to write a current marker, or run 'tailscale logout' by hand" ;;
|
||||
*)
|
||||
die "the role marker has unknown join-by=$join_by, so rig cannot prove it made this tailnet join and will not remove it; run 'tailscale logout' by hand if that is intended" ;;
|
||||
esac
|
||||
|
||||
# The same back-out/keep law as first-join verification: logout is earned only
|
||||
# when the marker proves rig performed the join. Preserve the marker on failure
|
||||
# so the operation remains retryable and never reports a half-undone machine.
|
||||
if ! tailscale logout; then
|
||||
die "tailscale logout failed; role marker kept so 'rig bootstrap --undo' can be retried"
|
||||
fi
|
||||
|
||||
rm -f -- "$MARKER"
|
||||
log "tailnet join removed; role marker removed"
|
||||
|
|
@ -12,8 +12,6 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
|||
. "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only
|
||||
# shellcheck source=SCRIPTDIR/lib/manifest.sh
|
||||
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
|
||||
# shellcheck source=SCRIPTDIR/lib/templates.sh
|
||||
. "$HERE/lib/templates.sh" # registry-backed machine-role definitions
|
||||
# The users lib is sourced for validation, never for convergence: `users apply`
|
||||
# stays the single owner of what a users file DOES to a box (#51). Bootstrap
|
||||
# borrows the parser so a typo'd users file is caught in the same breath as a
|
||||
|
|
@ -31,14 +29,9 @@ usage: rig bootstrap <control-plane-server|workload-server|runner-server|
|
|||
(--users <path> | --no-users)
|
||||
[--hostname <name>] [--root-door <closed|open>]
|
||||
[--host <yes|no>] [--join <authkey|login>]
|
||||
rig bootstrap <role>-box [--user <name>]
|
||||
(the box TENANT roles — the agent tenants come from the
|
||||
heavy-duty/rig-templates registry, staging-box from
|
||||
rig's own tree; see their own --help — they take no
|
||||
--users, see below)
|
||||
rig bootstrap --undo
|
||||
leave the tailnet only when the role marker proves rig
|
||||
performed the join, then remove the role marker
|
||||
rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
|
||||
(the box TENANT roles — see their own --help; they take
|
||||
no --users, see below)
|
||||
|
||||
--users the users file this box's operators come from — REQUIRED. It is
|
||||
applied as bootstrap's last phase, exactly as `rig users apply
|
||||
|
|
@ -63,8 +56,7 @@ and per-human accounts keep attribution intact for the times someone does go
|
|||
in. So the complete path is the default path and skipping it is a deliberate
|
||||
--no-users, not an omission.
|
||||
|
||||
--users does NOT reach the box TENANT roles (any '-box' name, e.g.
|
||||
claude-box, staging-box). A
|
||||
--users does NOT reach the box TENANT roles (claude-box|codex-box|grok-box|staging-box). A
|
||||
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
|
||||
non-interactively, with no file to hand it; the guest never joins the tailnet
|
||||
and has no SSH door of its own — entry is `box shell`, gated by the HOST's
|
||||
|
|
@ -121,38 +113,17 @@ EOF
|
|||
|
||||
# --- args (validated before the root check, so errors are testable) ---------
|
||||
ROLE="${1:-}"
|
||||
MACHINE_TEMPLATE_DIR=""
|
||||
case "$ROLE" in
|
||||
--undo)
|
||||
shift
|
||||
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
|
||||
exec "$HERE/bootstrap-undo.sh" ;;
|
||||
control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;;
|
||||
*-box)
|
||||
claude-box|codex-box|grok-box|staging-box)
|
||||
# The box TENANT roles (#31) are a different family — guests a box mints,
|
||||
# never tailnet machines — and live in their own mechanism, one script
|
||||
# parameterized per DEFINITION fetched from the template registry (#110;
|
||||
# staging-box stays in-tree). Dispatched on the FAMILY SUFFIX (#76), not
|
||||
# an enumerated list: which '-box' roles exist is the registry's fact, so
|
||||
# a template added there is mintable with zero code changes here.
|
||||
# `rig bootstrap <role>` stays the single entrypoint for both families.
|
||||
# parameterized per tenant. Dispatched here so `rig bootstrap <role>`
|
||||
# stays the single entrypoint for both families.
|
||||
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
|
||||
*)
|
||||
shift
|
||||
templates_resolve || exit 2
|
||||
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
|
||||
MACHINE_TEMPLATE_DIR="$REGISTRY_DIR/$ROLE"
|
||||
if [[ ! "$ROLE" =~ ^[a-z][a-z0-9-]*-server$ ]] \
|
||||
|| [ "$(template_family "$ROLE" 2>/dev/null || true)" != "machine" ] \
|
||||
|| [ ! -f "$MACHINE_TEMPLATE_DIR/template.env" ]; then
|
||||
MACHINE_ROLES="$(templates_machine_roles "$REGISTRY_DIR" | paste -sd'|' -)"
|
||||
[ -n "$MACHINE_ROLES" ] || MACHINE_ROLES="none"
|
||||
die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom; machine roles from $(templates_source_desc): $MACHINE_ROLES; or a '-box' tenant role)" 2
|
||||
fi
|
||||
machine_template_parse_env "$MACHINE_TEMPLATE_DIR/template.env" \
|
||||
|| die "invalid machine role $ROLE from $(templates_source_desc)" 2 ;;
|
||||
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|staging-box)" 2 ;;
|
||||
*) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|staging-box)" 2 ;;
|
||||
esac
|
||||
|
||||
# Role→traits map — the single place a role's shape is declared (issue #26).
|
||||
|
|
@ -172,7 +143,6 @@ case "$ROLE" in
|
|||
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
|
||||
workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
|
||||
custom) ;;
|
||||
*) ROOT_DOOR="$TPL_ROOT_DOOR" HOST="$TPL_HOST" JOIN="$TPL_JOIN" ;;
|
||||
esac
|
||||
|
||||
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
|
||||
|
|
@ -564,7 +534,6 @@ if ! command -v tailscale >/dev/null 2>&1; then
|
|||
log "installing tailscale"
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
fi
|
||||
JOIN_BY=preexisting
|
||||
if tailscale status >/dev/null 2>&1; then
|
||||
log "tailnet already joined; skipping tailscale up (no pre-auth key needed)"
|
||||
# ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name
|
||||
|
|
@ -605,7 +574,6 @@ elif [ "$JOIN" = "login" ]; then
|
|||
log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)"
|
||||
tailscale up --hostname="$TS_HOSTNAME"
|
||||
verify_user_owned back-out
|
||||
JOIN_BY=rig
|
||||
else
|
||||
# env override, else prompt; never touches disk. The prompt only fires on a
|
||||
# tty: with no terminal, a bare `read` exits non-zero and `set -e` would end
|
||||
|
|
@ -626,7 +594,6 @@ else
|
|||
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
|
||||
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
|
||||
verify_effective_tag back-out
|
||||
JOIN_BY=rig
|
||||
fi
|
||||
|
||||
# --- role marker --------------------------------------------------------------
|
||||
|
|
@ -644,12 +611,11 @@ fi
|
|||
# those exist in the field by the thousand and nothing will rewrite them.
|
||||
MARKER=/etc/rig/role
|
||||
MARKER_TMP="$(mktemp)"
|
||||
printf 'role=%s root-door=%s host=%s join=%s join-by=%s\n' \
|
||||
"$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" "$JOIN_BY" > "$MARKER_TMP"
|
||||
printf 'role=%s root-door=%s host=%s join=%s\n' "$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" > "$MARKER_TMP"
|
||||
if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then
|
||||
mkdir -p /etc/rig
|
||||
install -m 0644 "$MARKER_TMP" "$MARKER"
|
||||
log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN join-by=$JOIN_BY"
|
||||
log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN"
|
||||
else
|
||||
log "role marker already current"
|
||||
fi
|
||||
|
|
@ -705,9 +671,7 @@ fi
|
|||
# hardening + the tailnet, and box is the host EXTRA, so a failed box install
|
||||
# must never abort a bootstrap that otherwise fully succeeded.
|
||||
#
|
||||
# PIN POINTS: BOX_REPO / BOX_REF override the source (default
|
||||
# heavy-duty/box@0.9.0). BOX_RELEASE is bumped deliberately when rig releases,
|
||||
# after the pinned combination has passed the release drill.
|
||||
# PIN POINTS: BOX_REPO / BOX_REF override the source (default heavy-duty/box@main).
|
||||
# BOX_YES=1 makes box's installer non-interactive AND keeps setup-host (so the
|
||||
# Incus stack is actually built, not just the CLI dropped on PATH).
|
||||
#
|
||||
|
|
@ -720,11 +684,10 @@ fi
|
|||
# on box PR #71. Until that merges, box's root install lands in /root and non-root
|
||||
# users cannot reach it, so this step is only fully correct once box#71 is merged.
|
||||
if [ "$HOST" = "yes" ]; then
|
||||
BOX_RELEASE=0.9.0
|
||||
BOX_REPO="${BOX_REPO:-heavy-duty/box}"
|
||||
BOX_REF="${BOX_REF:-$BOX_RELEASE}"
|
||||
BOX_REF="${BOX_REF:-main}"
|
||||
BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh"
|
||||
BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash"
|
||||
BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 bash"
|
||||
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
||||
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}"
|
||||
elif ! command -v curl >/dev/null 2>&1; then
|
||||
|
|
@ -738,7 +701,7 @@ if [ "$HOST" = "yes" ]; then
|
|||
# A curl failure (no network) fails the pipe under pipefail and lands in the
|
||||
# else — a warning, never an abort: box is the host extra, the OS+tailnet core
|
||||
# is already done.
|
||||
if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 BOX_REF="$BOX_REF" bash; then
|
||||
if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 bash; then
|
||||
# Don't trust the exit code — prove the effective state (issue #12). An
|
||||
# installer can exit 0 having done less than it claims: box's setup-host
|
||||
# is written for a sudo-capable user, and one of its paths exits 0 after
|
||||
|
|
@ -801,17 +764,6 @@ if [ -n "$USERS_FILE" ]; then
|
|||
"$HERE/users-apply.sh" --file "$USERS_FILE"
|
||||
fi
|
||||
|
||||
# A registry machine's optional install is the final convergence phase: after
|
||||
# join, host setup, the marker prerequisites, and operators. It inherits the
|
||||
# caller environment, adds only the selected role, and runs from its definition
|
||||
# directory. Definitions own idempotence, like bootstrap itself.
|
||||
if [ -n "$MACHINE_TEMPLATE_DIR" ] && [ -e "$MACHINE_TEMPLATE_DIR/install.sh" ]; then
|
||||
log "running install hook for ${ROLE} from $(templates_source_desc)"
|
||||
if ! (cd "$MACHINE_TEMPLATE_DIR" && RIG_ROLE="$ROLE" bash ./install.sh); then
|
||||
die "install hook failed for role $ROLE from $(templates_source_desc)"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
|
||||
if [ "$ROLE" = "control-plane-server" ]; then
|
||||
log "next: rig coolify install --version <pin>"
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ json_field() {
|
|||
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true
|
||||
}
|
||||
|
||||
# json_string_array <file> <key> — the elements of the array named <key> inside
|
||||
# the netmap's `Self` object, one per line; empty when Self or the key is absent.
|
||||
# json_string_array <file> <key> — the elements of the FIRST array named <key>,
|
||||
# one per line, empty when the key is absent or the array is empty.
|
||||
#
|
||||
# json_field's sibling for the one shape it cannot read: `.Self.Tags` from
|
||||
# `tailscale status --json` is a JSON array, and bootstrap must assert on it to
|
||||
|
|
@ -23,44 +23,21 @@ json_field() {
|
|||
# not the tag rig requested. Same grep/sed spirit, same jq-free reason: a
|
||||
# rig-bootstrapped box has no jq and we will not install one to read one field.
|
||||
#
|
||||
# Scoped to Self, NOT document-global. The previous body took the first "Tags"
|
||||
# array anywhere in the file and justified it with Self-before-Peer field order.
|
||||
# That holds only when Self HAS tags: an untagged Self omits the key entirely
|
||||
# (Go omitempty), so the match fell through into Peer and returned a PEER's tag
|
||||
# — silently inverting both callers on any tailnet with a tagged node (#160).
|
||||
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and an array
|
||||
# spans lines — grep is line-oriented and would never see `[ ... ]` whole
|
||||
# otherwise. `\[[^]]*\]` then captures the first flat array body for <key>
|
||||
# (tag strings never contain `]`, so this is safe); the inner `grep -o` pulls
|
||||
# every quoted token out of it, and `sed 1d` drops the key's own name — which
|
||||
# `"key":[...]` leads with — leaving just the elements.
|
||||
#
|
||||
# Self is brace-counted rather than sliced to the next key: PeerStatus carries a
|
||||
# nested object (Location, a pointer with omitempty), which would end a naive
|
||||
# slice early whenever it is present. Known limit of staying jq-free: a `{` or
|
||||
# `}` inside a STRING value within Self would miscount — no PeerStatus string
|
||||
# field (hostnames, DNS names, OS, key strings) can contain one, so this is
|
||||
# sound in practice, but it is a real assumption, written down on purpose.
|
||||
#
|
||||
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and the object
|
||||
# spans lines — awk and grep are line-oriented and would never see it whole
|
||||
# otherwise. `\[[^]]*\]` then captures the flat array body for <key> (tag
|
||||
# strings never contain `]`, so this is safe); the inner `grep -o` pulls every
|
||||
# quoted token out of it, and `sed 1d` drops the key's own name — which
|
||||
# `"key":[...]` leads with — leaving just the elements. Never fails under
|
||||
# `set -e`+pipefail: a non-match is a fact to test for, not a reason to die.
|
||||
# FIRST array wins by design, and the caller leans on it: `tailscale status
|
||||
# --json` emits Self before Peer (Go struct field order, stable), so the first
|
||||
# "Tags" is the node's OWN, never a peer's. An absent key omits itself entirely
|
||||
# (Go's omitempty) rather than emitting `[]` — which is exactly the untagged,
|
||||
# user-owned node bootstrap must catch. Never fails under `set -e`+pipefail: a
|
||||
# non-match is a fact to test for, like json_field, not a reason to die.
|
||||
json_string_array() {
|
||||
local self
|
||||
self="$(tr -d '\n' < "$1" 2>/dev/null | awk '
|
||||
{
|
||||
i = index($0, "\"Self\"")
|
||||
if (i == 0) exit
|
||||
s = substr($0, i)
|
||||
j = index(s, "{")
|
||||
if (j == 0) exit
|
||||
depth = 0
|
||||
for (k = j; k <= length(s); k++) {
|
||||
c = substr(s, k, 1)
|
||||
if (c == "{") depth++
|
||||
else if (c == "}") { depth--; if (depth == 0) { print substr(s, j, k - j + 1); exit } }
|
||||
}
|
||||
}')" || true
|
||||
[ -n "$self" ] || return 0
|
||||
printf '%s' "$self" \
|
||||
tr -d '\n' < "$1" 2>/dev/null \
|
||||
| grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \
|
||||
| head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,385 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# The tenant-template REGISTRY (#110): resolve where role definitions come
|
||||
# from, parse a definition's template.env against an allowlist, and lint a
|
||||
# whole definition. Sourced by bootstrap-tenant.sh (the mint-time consumer)
|
||||
# and template-lint.sh (the registry repo's CI gate) — pure functions plus
|
||||
# one pin, no side effects at source time (repo precedent: runner-config,
|
||||
# and the tenant-config table this lib replaces).
|
||||
#
|
||||
# The registry moved out of rig's tree so mechanism and data can move at
|
||||
# different cadences (#109 is the evidence: adding kimi — pure data — meant
|
||||
# editing six files here). rig keeps the mechanism and this schema; the
|
||||
# definitions live in heavy-duty/rig-templates, one directory per role:
|
||||
#
|
||||
# <role>/template.env KEY="value" data, parsed against the allowlist
|
||||
# below and NEVER sourced — a definition cannot
|
||||
# execute shell through its data file
|
||||
# <role>/install.sh the CLI install (the one inherently executable part)
|
||||
# <role>/creds.md the per-vendor creds-free paragraph the context
|
||||
# renderer splices in
|
||||
#
|
||||
# THE SOURCE IS THREE KNOBS plus the installed pin snapshot, precedence
|
||||
# _DIR > _REF > snapshot > pin fetch:
|
||||
# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
|
||||
# offline-test path, and "try a template before it
|
||||
# exists anywhere")
|
||||
# RIG_TEMPLATES_REF a ref in the registry repo, fetched as a tarball at
|
||||
# bootstrap time (the same shape as the rig preinstall)
|
||||
# RIG_TEMPLATES_REPO which repo that ref lives in (default
|
||||
# heavy-duty/rig-templates)
|
||||
# and, absent both overrides, the snapshot installed beside this file when it
|
||||
# matches the PIN below, then a live fetch of that pin as the fallback.
|
||||
|
||||
# The default registry ref a mint converges — the BOX_RELEASE discipline
|
||||
# (#103): one line, bumped deliberately by ordinary rig PR after review, so a
|
||||
# rig release freezes the mechanism+registry pair and a newer rig matches
|
||||
# newer templates by default (ruled 2026-07-24 on #110: pinned, not
|
||||
# main-tracked). RIG_TEMPLATES_REF overrides it per mint.
|
||||
#
|
||||
# Currently the seed tree (rig-templates#1's head — fetchable from the
|
||||
# upstream archive already, an ancestor of its main once merged): the four
|
||||
# agent tenants ported byte-equivalent from the case arms this PR cut.
|
||||
RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0
|
||||
|
||||
# The template.env schema. Grammar: blank lines, '#' comments, and
|
||||
# KEY="value" — nothing else. Parsed by regex, never sourced.
|
||||
TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE)
|
||||
TEMPLATE_KEYS_OPTIONAL=(CLI_SRC NEEDS_NODE APT_EXTRAS)
|
||||
MACHINE_KEYS_REQUIRED=(ROOT_DOOR HOST JOIN)
|
||||
|
||||
# templates_source_desc — where the resolved registry came from, for error
|
||||
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
|
||||
# the unknown-role refusal rather than looking like a typo.
|
||||
templates_source_desc() {
|
||||
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
||||
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
|
||||
elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
|
||||
printf '%s@%s (snapshot)' \
|
||||
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
|
||||
"$RIG_TEMPLATES_PIN"
|
||||
else
|
||||
printf '%s@%s%s' \
|
||||
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
|
||||
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
|
||||
"$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')"
|
||||
fi
|
||||
}
|
||||
|
||||
# The snapshot path is derived from this library's installed tree. Its
|
||||
# pin-bearing directory name is the staleness guard: an older snapshot is
|
||||
# invisible after a pin bump. A usable registry has at least one definition;
|
||||
# an empty directory means an interrupted extraction and falls through to the
|
||||
# same live fetch as an absent snapshot.
|
||||
templates_snapshot_dir() {
|
||||
local lib_dir
|
||||
lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
printf '%s/templates@%s' "$(cd "$lib_dir/../.." && pwd)" "$RIG_TEMPLATES_PIN"
|
||||
}
|
||||
|
||||
templates_snapshot_usable() {
|
||||
local snapshot role_env
|
||||
snapshot="$(templates_snapshot_dir)"
|
||||
[ -d "$snapshot" ] || return 1
|
||||
role_env="$(find "$snapshot" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)"
|
||||
[ -n "$role_env" ]
|
||||
}
|
||||
|
||||
# templates_resolve — resolve the knobs to a LOCAL directory holding
|
||||
# the registry, left in the REGISTRY_DIR global (a global, not stdout: a
|
||||
# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP,
|
||||
# the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is
|
||||
# used as-is; otherwise the repo@ref tarball is fetched and extracted under
|
||||
# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's
|
||||
# precedence — a tag outranks a branch that shares its name — plus the bare
|
||||
# archive/<ref> form, which is how a commit-SHA pin (the default) downloads.
|
||||
# Failure lists every URL tried: the fetch is unauthenticated by contract
|
||||
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
|
||||
# and the ref real" is the whole diagnosis.
|
||||
TEMPLATES_TMP=""
|
||||
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
||||
REGISTRY_DIR=""
|
||||
templates_resolve() {
|
||||
local repo ref url got=""
|
||||
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
||||
[ -d "$RIG_TEMPLATES_DIR" ] || {
|
||||
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
|
||||
return 1
|
||||
}
|
||||
REGISTRY_DIR="$RIG_TEMPLATES_DIR"
|
||||
return 0
|
||||
fi
|
||||
if [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
|
||||
REGISTRY_DIR="$(templates_snapshot_dir)"
|
||||
return 0
|
||||
fi
|
||||
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||
ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}"
|
||||
command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; }
|
||||
command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; }
|
||||
TEMPLATES_TMP="$(mktemp -d)"
|
||||
for url in \
|
||||
"https://github.com/$repo/archive/refs/tags/$ref.tar.gz" \
|
||||
"https://github.com/$repo/archive/refs/heads/$ref.tar.gz" \
|
||||
"https://github.com/$repo/archive/$ref.tar.gz"; do
|
||||
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
||||
done
|
||||
if [ -z "$got" ]; then
|
||||
printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2
|
||||
printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2
|
||||
printf ' https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$repo" "$ref" >&2
|
||||
printf ' https://github.com/%s/archive/%s.tar.gz\n' "$repo" "$ref" >&2
|
||||
printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR=<dir> bypasses the fetch.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
|
||||
printf 'cannot extract the registry tarball from %s\n' "$got" >&2
|
||||
return 1
|
||||
}
|
||||
# A GitHub archive holds exactly one top-level directory (<repo>-<ref>,
|
||||
# slashes flattened) — assert that shape instead of assuming the name.
|
||||
set -- "$TEMPLATES_TMP"/*/
|
||||
{ [ $# -eq 1 ] && [ -d "$1" ]; } || {
|
||||
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
|
||||
return 1
|
||||
}
|
||||
# shellcheck disable=SC2034 # the function's output global, read by the sourcing script
|
||||
REGISTRY_DIR="${1%/}"
|
||||
}
|
||||
|
||||
# templates_roles <registry-dir> — the roles a registry defines: its
|
||||
# immediate subdirectories that carry a template.env. This list IS the
|
||||
# unknown-role refusal's body, so it reflects what the resolved source
|
||||
# actually contains — never a hardcoded set.
|
||||
templates_roles() {
|
||||
local d
|
||||
for d in "$1"/*/; do
|
||||
[ -f "$d/template.env" ] || continue
|
||||
basename "$d"
|
||||
done
|
||||
}
|
||||
|
||||
# template_family <role> — directory names are the registry's family tag.
|
||||
# workstation is the one intentional suffix-less machine role (#152 / epic D5).
|
||||
template_family() {
|
||||
case "$1" in
|
||||
*-box) printf 'tenant\n' ;;
|
||||
*-server|workstation) printf 'machine\n' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# templates_machine_roles <registry-dir> — only machine definitions, for the
|
||||
# machine bootstrap's unknown-role refusal.
|
||||
templates_machine_roles() {
|
||||
local role
|
||||
while IFS= read -r role; do
|
||||
[ "$(template_family "$role" 2>/dev/null || true)" = "machine" ] || continue
|
||||
printf '%s\n' "$role"
|
||||
done < <(templates_roles "$1")
|
||||
}
|
||||
|
||||
# template_parse_env <template.env> — parse against the allowlist. Sets
|
||||
# TPL_USER, TPL_CONTEXT_PATH, TPL_CLI_NAME, TPL_CLI_SRC, TPL_PATH_LINE,
|
||||
# TPL_NEEDS_NODE (default no), TPL_APT_EXTRAS. Every refusal names the
|
||||
# failing key (or line): the box.env discipline — a definition is data, and
|
||||
# bad data is refused loudly, never executed to find out.
|
||||
# shellcheck disable=SC2034 # the TPL_* globals are this function's OUTPUT, read by the sourcing script
|
||||
template_parse_env() {
|
||||
local file="$1" line key val n=0 seen=" " k ok
|
||||
TPL_USER="" TPL_CONTEXT_PATH="" TPL_CLI_NAME="" TPL_CLI_SRC=""
|
||||
TPL_PATH_LINE="" TPL_NEEDS_NODE="no" TPL_APT_EXTRAS=""
|
||||
[ -f "$file" ] || { printf 'template.env missing: %s\n' "$file" >&2; return 1; }
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
n=$((n+1))
|
||||
case "$line" in ''|'#'*) continue ;; esac
|
||||
if [[ ! "$line" =~ ^([A-Z_]+)=\"(.*)\"$ ]]; then
|
||||
printf 'template.env:%d: not KEY="value": %s\n' "$n" "$line" >&2
|
||||
return 1
|
||||
fi
|
||||
key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
|
||||
ok=""
|
||||
for k in "${TEMPLATE_KEYS_REQUIRED[@]}" "${TEMPLATE_KEYS_OPTIONAL[@]}"; do
|
||||
[ "$key" = "$k" ] && ok=1
|
||||
done
|
||||
[ -n "$ok" ] || { printf 'template.env:%d: unknown key: %s (allowed: %s %s)\n' \
|
||||
"$n" "$key" "${TEMPLATE_KEYS_REQUIRED[*]}" "${TEMPLATE_KEYS_OPTIONAL[*]}" >&2; return 1; }
|
||||
case "$seen" in *" $key "*)
|
||||
printf 'template.env:%d: duplicate key: %s\n' "$n" "$key" >&2; return 1 ;;
|
||||
esac
|
||||
seen="$seen$key "
|
||||
case "$key" in
|
||||
USER) TPL_USER="$val" ;;
|
||||
CONTEXT_PATH) TPL_CONTEXT_PATH="$val" ;;
|
||||
CLI_NAME) TPL_CLI_NAME="$val" ;;
|
||||
CLI_SRC) TPL_CLI_SRC="$val" ;;
|
||||
PATH_LINE) TPL_PATH_LINE="$val" ;;
|
||||
NEEDS_NODE) TPL_NEEDS_NODE="$val" ;;
|
||||
APT_EXTRAS) TPL_APT_EXTRAS="$val" ;;
|
||||
esac
|
||||
done < "$file"
|
||||
for k in "${TEMPLATE_KEYS_REQUIRED[@]}"; do
|
||||
case "$seen" in *" $k "*) ;; *)
|
||||
printf 'template.env: missing required key: %s\n' "$k" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
# Value shapes — each refusal names its key. USER shares the charset the
|
||||
# users file enforces (a leading '-' reads as a usermod flag; '|', ':'
|
||||
# corrupt things downstream).
|
||||
[[ "$TPL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|
||||
|| { printf 'template.env: USER: invalid user name: %s (want ^[a-z_][a-z0-9_-]{0,31}$)\n' "$TPL_USER" >&2; return 1; }
|
||||
case "$TPL_CONTEXT_PATH" in
|
||||
/*|*..*|'') printf 'template.env: CONTEXT_PATH: must be relative to the tenant home, without "..": %s\n' "$TPL_CONTEXT_PATH" >&2; return 1 ;;
|
||||
esac
|
||||
[[ "$TPL_CLI_NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] \
|
||||
|| { printf 'template.env: CLI_NAME: not a sane command name: %s\n' "$TPL_CLI_NAME" >&2; return 1; }
|
||||
# A literal '~/' on purpose (SC2088): the value is DATA — the mechanism
|
||||
# expands it to the tenant home by string substitution, never the shell.
|
||||
# shellcheck disable=SC2088
|
||||
case "$TPL_CLI_SRC" in
|
||||
*..*) printf 'template.env: CLI_SRC: must not contain "..": %s\n' "$TPL_CLI_SRC" >&2; return 1 ;;
|
||||
''|'~/'*|/*) ;;
|
||||
*) printf 'template.env: CLI_SRC: must be absolute or ~/-relative: %s\n' "$TPL_CLI_SRC" >&2; return 1 ;;
|
||||
esac
|
||||
case "$TPL_NEEDS_NODE" in
|
||||
yes|no) ;;
|
||||
*) printf 'template.env: NEEDS_NODE: want yes or no, got: %s\n' "$TPL_NEEDS_NODE" >&2; return 1 ;;
|
||||
esac
|
||||
[ -n "$TPL_PATH_LINE" ] \
|
||||
|| { printf 'template.env: PATH_LINE: must not be empty\n' >&2; return 1; }
|
||||
# Every word must be a sane package name — the list is handed to apt-get
|
||||
# unquoted by design, and this is what keeps an option ('-o …') or a path
|
||||
# from riding in through the data file.
|
||||
local pkg
|
||||
for pkg in $TPL_APT_EXTRAS; do
|
||||
[[ "$pkg" =~ ^[a-z0-9][a-z0-9.+-]*$ ]] \
|
||||
|| { printf 'template.env: APT_EXTRAS: not a sane package name: %s\n' "$pkg" >&2; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# machine_template_parse_env <template.env> — the fleet-machine traits schema.
|
||||
# The globals match bootstrap's table columns so a definition becomes a table
|
||||
# row without changing any downstream trait behavior.
|
||||
# shellcheck disable=SC2034
|
||||
machine_template_parse_env() {
|
||||
local file="$1" line key val n=0 seen=" " k ok
|
||||
TPL_ROOT_DOOR="" TPL_HOST="" TPL_JOIN=""
|
||||
[ -f "$file" ] || { printf 'template.env missing: %s\n' "$file" >&2; return 1; }
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
n=$((n+1))
|
||||
case "$line" in ''|'#'*) continue ;; esac
|
||||
if [[ ! "$line" =~ ^([A-Z_]+)=\"(.*)\"$ ]]; then
|
||||
printf 'template.env:%d: not KEY="value": %s\n' "$n" "$line" >&2
|
||||
return 1
|
||||
fi
|
||||
key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
|
||||
ok=""
|
||||
for k in "${MACHINE_KEYS_REQUIRED[@]}"; do
|
||||
[ "$key" = "$k" ] && ok=1
|
||||
done
|
||||
[ -n "$ok" ] || {
|
||||
printf 'template.env:%d: unknown key: %s (allowed: %s)\n' \
|
||||
"$n" "$key" "${MACHINE_KEYS_REQUIRED[*]}" >&2
|
||||
return 1
|
||||
}
|
||||
case "$seen" in *" $key "*)
|
||||
printf 'template.env:%d: duplicate key: %s\n' "$n" "$key" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
seen="$seen$key "
|
||||
case "$key" in
|
||||
ROOT_DOOR) TPL_ROOT_DOOR="$val" ;;
|
||||
HOST) TPL_HOST="$val" ;;
|
||||
JOIN) TPL_JOIN="$val" ;;
|
||||
esac
|
||||
done < "$file"
|
||||
for k in "${MACHINE_KEYS_REQUIRED[@]}"; do
|
||||
case "$seen" in *" $k "*) ;; *)
|
||||
printf 'template.env: missing required key: %s\n' "$k" >&2
|
||||
return 1 ;;
|
||||
esac
|
||||
done
|
||||
case "$TPL_ROOT_DOOR" in
|
||||
open|closed) ;;
|
||||
*) printf 'template.env: ROOT_DOOR: want open or closed, got: %s\n' "$TPL_ROOT_DOOR" >&2; return 1 ;;
|
||||
esac
|
||||
case "$TPL_HOST" in
|
||||
yes|no) ;;
|
||||
*) printf 'template.env: HOST: want yes or no, got: %s\n' "$TPL_HOST" >&2; return 1 ;;
|
||||
esac
|
||||
case "$TPL_JOIN" in
|
||||
authkey|login) ;;
|
||||
*) printf 'template.env: JOIN: want authkey or login, got: %s\n' "$TPL_JOIN" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# render_tenant_context <role> <creds.md> — the agent-context file's
|
||||
# content, on stdout: the one file every agent reads before touching
|
||||
# anything. The skeleton is MECHANISM and lives here once — the box#80 guard
|
||||
# note ("never run box setup-host or the drill inside a box; the box you are
|
||||
# in is not a host you own") must never be copy-pasted per template again —
|
||||
# and only the creds paragraph is per-vendor DATA, spliced in from the
|
||||
# definition's creds.md.
|
||||
render_tenant_context() {
|
||||
local role="$1" creds_file="$2"
|
||||
cat <<EOF
|
||||
# You are running inside a box (tenant: ${role})
|
||||
|
||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
||||
\`box\` CLI. Keep this context in mind:
|
||||
|
||||
$(cat "$creds_file")
|
||||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
||||
local network. There is no inbound path.
|
||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
||||
removed; the operator persists work via git push and via \`box snapshot\`.
|
||||
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
|
||||
or the drill inside a box. The box you are in is not a host you own: a
|
||||
nested box stack claims the guest's own uplink subnet and gateway, and
|
||||
silently breaks this box's networking with intermittent egress blackouts
|
||||
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
|
||||
and testing never needs the host stack; host setup belongs to the operator's
|
||||
machine, never this one.
|
||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
|
||||
runbook — how to install dependencies, start services, template environment
|
||||
files, seed data, and smoke-test — and follow it. It is documentation for
|
||||
you, not a script the host runs.
|
||||
EOF
|
||||
}
|
||||
|
||||
# template_lint <role-dir> — the whole-definition check the registry repo's
|
||||
# CI runs on every PR (rig defines what a valid template is; rig-templates
|
||||
# CI enforces it, so a broken definition is refused before it can reach a
|
||||
# mint). Same parser the mint runs — the two gates are not redundant: CI
|
||||
# protects the registry, the mint-time parse protects a mint served through
|
||||
# RIG_TEMPLATES_REPO/_DIR that CI never saw.
|
||||
template_lint() {
|
||||
local dir="${1%/}" role family
|
||||
role="$(basename "$dir")"
|
||||
[ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; }
|
||||
family="$(template_family "$role" 2>/dev/null || true)"
|
||||
[ -n "$family" ] || {
|
||||
printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76; workstation is #152 machine carve-out)\n' "$role" >&2
|
||||
return 1
|
||||
}
|
||||
if [ "$family" = "tenant" ]; then
|
||||
template_parse_env "$dir/template.env" || return 1
|
||||
[ -s "$dir/install.sh" ] \
|
||||
|| { printf '%s: install.sh missing or empty\n' "$role" >&2; return 1; }
|
||||
head -n1 "$dir/install.sh" | grep -q '^#!' \
|
||||
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
|
||||
grep -q '[^[:space:]]' "$dir/creds.md" 2>/dev/null \
|
||||
|| { printf '%s: creds.md missing or blank (the context renderer splices it in — a blank paragraph would ship a context file with a hole)\n' "$role" >&2; return 1; }
|
||||
else
|
||||
machine_template_parse_env "$dir/template.env" || return 1
|
||||
[ ! -e "$dir/creds.md" ] \
|
||||
|| { printf '%s: creds.md is not allowed for machine roles (machines render no tenant context)\n' "$role" >&2; return 1; }
|
||||
if [ -e "$dir/install.sh" ]; then
|
||||
[ -s "$dir/install.sh" ] \
|
||||
|| { printf '%s: install.sh is empty\n' "$role" >&2; return 1; }
|
||||
head -n1 "$dir/install.sh" | grep -q '^#!' \
|
||||
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
92
commands/lib/tenant-config.sh
Normal file
92
commands/lib/tenant-config.sh
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared parameters for the box TENANT roles (claude-box, codex-box, grok-box,
|
||||
# staging-box) — the '-box' suffix names the FAMILY (a guest a box mints, vs the
|
||||
# '-server' machine roles rig converges); see #76.
|
||||
# sourced by bootstrap-tenant.sh and by the test harness. Pure text→text, no
|
||||
# side effects: the per-tenant differences live HERE, in one table, so the
|
||||
# mechanism stays one script parameterized per tenant instead of four
|
||||
# hand-maintained copies (repo precedent: parse_users_file, runner-config).
|
||||
|
||||
# tenant_user <role> — the user the box seed creates (box.env BOX_USER). The
|
||||
# agent tenants are named after their agent (minus the suffix — the USER is not
|
||||
# the role); staging-box keeps box#69's `ops`.
|
||||
tenant_user() {
|
||||
case "$1" in
|
||||
claude-box) printf 'claude' ;;
|
||||
codex-box) printf 'codex' ;;
|
||||
grok-box) printf 'grok' ;;
|
||||
staging-box) printf 'ops' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# tenant_context_path <role> <home> — where the agent-context file lands. Each
|
||||
# agent CLI reads its own instructions file from its own dotdir (named for the
|
||||
# agent, not the role — the dotdir is the CLI's, and the suffix is rig's);
|
||||
# staging-box has no agent and no context file (return 1).
|
||||
tenant_context_path() {
|
||||
case "$1" in
|
||||
claude-box) printf '%s/.claude/CLAUDE.md' "$2" ;;
|
||||
codex-box) printf '%s/.codex/AGENTS.md' "$2" ;;
|
||||
grok-box) printf '%s/.grok/AGENTS.md' "$2" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# render_tenant_context <role> — the agent-context file's content, on stdout.
|
||||
# One renderer for all three agents: only the creds paragraph is per-vendor,
|
||||
# and the box#80 guard note lives HERE once — never copy-pasted per template.
|
||||
# staging-box renders nothing (return 1): no agent lives there.
|
||||
render_tenant_context() {
|
||||
local role="$1" creds
|
||||
# The single-quoted markdown below carries literal `$`-free backtick prose;
|
||||
# single quotes are deliberate — nothing in it may expand here.
|
||||
# shellcheck disable=SC2016
|
||||
case "$role" in
|
||||
claude-box)
|
||||
creds='- **Creds-free by default.** The box starts with no Claude and no git
|
||||
credentials. If you need to authenticate Claude, the operator runs `/login`
|
||||
interactively. For git, the operator adds their own credentials (a PAT or
|
||||
`gh auth login`). Never assume credentials are present; never ask for or
|
||||
store secrets on disk beyond what the operator sets up.' ;;
|
||||
codex-box)
|
||||
creds='- **Creds-free by default.** The box starts with no OpenAI and no git
|
||||
credentials. If you need to authenticate Codex, the operator runs the
|
||||
login flow (`codex`) interactively. For git, the operator adds their own
|
||||
credentials (a PAT or `gh auth login`). Never assume credentials are
|
||||
present; never ask for or store secrets on disk beyond what the operator
|
||||
sets up.' ;;
|
||||
grok-box)
|
||||
creds='- **Creds-free by default.** The box starts with no xAI and no git
|
||||
credentials. If you need to authenticate, the operator runs
|
||||
`grok login` interactively (SuperGrok / X Premium+). For git, the
|
||||
operator adds their own credentials (a PAT or `gh auth login`). Never
|
||||
assume credentials are present; never ask for or store secrets on disk
|
||||
beyond what the operator sets up.' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
cat <<EOF
|
||||
# You are running inside a box (tenant: ${role})
|
||||
|
||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
||||
\`box\` CLI. Keep this context in mind:
|
||||
|
||||
${creds}
|
||||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
||||
local network. There is no inbound path.
|
||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
||||
removed; the operator persists work via git push and via \`box snapshot\`.
|
||||
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
|
||||
or the drill inside a box. The box you are in is not a host you own: a
|
||||
nested box stack claims the guest's own uplink subnet and gateway, and
|
||||
silently breaks this box's networking with intermittent egress blackouts
|
||||
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
|
||||
and testing never needs the host stack; host setup belongs to the operator's
|
||||
machine, never this one.
|
||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
|
||||
runbook — how to install dependencies, start services, template environment
|
||||
files, seed data, and smoke-test — and follow it. It is documentation for
|
||||
you, not a script the host runs.
|
||||
EOF
|
||||
}
|
||||
|
|
@ -2,8 +2,7 @@
|
|||
# rig platform — what is this machine? Calculated at run time, stored nowhere.
|
||||
#
|
||||
# Read-only in the strongest sense rig has: it reads /proc, uname,
|
||||
# /etc/os-release, /etc/machine-id, df and systemd-detect-virt, and writes
|
||||
# NOTHING, ever. That
|
||||
# /etc/os-release, df and systemd-detect-virt, and writes NOTHING, ever. That
|
||||
# is the design, not an implementation detail — specs change without rig doing
|
||||
# anything (RAM added, root disk resized, unattended-upgrades patching the
|
||||
# kernel), so a stored spec is stale the moment the machine changes, and
|
||||
|
|
@ -25,19 +24,13 @@ usage() {
|
|||
cat <<'EOF'
|
||||
usage: rig platform
|
||||
|
||||
Describes the machine you are on: hostname, a stable machine ID, OS, kernel,
|
||||
CPU, memory, disk and virtualization, then rig's own provenance (which rig,
|
||||
when, and the role marker bootstrap wrote).
|
||||
Describes the machine you are on: hostname, OS, kernel, CPU, memory, disk
|
||||
and virtualization, then rig's own provenance (which rig, when, and the role
|
||||
marker bootstrap wrote).
|
||||
|
||||
ID names the machine where HOSTNAME names the slot: it is derived from
|
||||
/etc/machine-id (a namespaced sha256, never the raw value, which machine-id(5)
|
||||
asks tools not to expose). Two machines reporting the same ID were cloned
|
||||
from one image — actionable information, not a coincidence: no identity that
|
||||
lives in the filesystem survives the filesystem being copied.
|
||||
|
||||
Computed at run time from /proc, uname, /etc/os-release, /etc/machine-id, df
|
||||
and systemd-detect-virt. Writes nothing, needs no root, makes no network call
|
||||
— so it also works on a pristine Debian box rig has never bootstrapped, where
|
||||
Computed at run time from /proc, uname, /etc/os-release, df and
|
||||
systemd-detect-virt. Writes nothing, needs no root, makes no network call —
|
||||
so it also works on a pristine Debian box rig has never bootstrapped, where
|
||||
the provenance block reads 'not bootstrapped'.
|
||||
EOF
|
||||
}
|
||||
|
|
@ -60,61 +53,6 @@ field() { printf '%-10s %s\n' "$1" "$2"; }
|
|||
# before anything has been installed.
|
||||
HOSTNAME_V="$(hostname 2>/dev/null || uname -n)"
|
||||
|
||||
# --- identity (#95) -----------------------------------------------------------
|
||||
# HOSTNAME names the slot; ID names the machine. rig itself sets the hostname
|
||||
# during bootstrap and reuses it across rebuilds ('hetzner-cp-1' is a role, not
|
||||
# hardware), so nothing above answers "is this the same machine I converged in
|
||||
# June, or its replacement?". /etc/machine-id does — but machine-id(5) asks
|
||||
# that the raw value not be exposed (it is a stable correlator across every
|
||||
# tool that leaks it), and its documented remedy is an application-specific
|
||||
# derivation. So: THE PINNED DERIVATION, fixed by #95 so two implementations
|
||||
# can never disagree —
|
||||
#
|
||||
# printf 'rig-machine-id:%s' "$(cat /etc/machine-id)" | sha256sum
|
||||
# → first 32 hex chars, rendered 8-4-4-4-12
|
||||
#
|
||||
# The 'rig-machine-id:' prefix is the contract, not decoration: it is what
|
||||
# keeps this id uncorrelatable with any other tool's derivation of the same
|
||||
# machine-id. sha256sum is coreutils, which this command is restricted to.
|
||||
# Derived, computed here, stored nowhere — #64's thesis — so it exists before
|
||||
# bootstrap and needs no write path.
|
||||
#
|
||||
# What this deliberately does NOT fix: a host cloned from a golden image
|
||||
# carries the clone's /etc/machine-id, so two machines reporting the same ID
|
||||
# means a cloned image. That is surfaced (help text, README) rather than
|
||||
# defended against — no identity that lives in the filesystem survives the
|
||||
# filesystem being copied.
|
||||
#
|
||||
# RIG_MACHINE_ID overrides the path so the harness can drive the present,
|
||||
# absent, empty and uninitialized cases against fixtures (repo precedent:
|
||||
# RIG_MANIFEST / RIG_ROLE_MARKER below).
|
||||
MID_FILE="${RIG_MACHINE_ID:-/etc/machine-id}"
|
||||
ID_V=""
|
||||
if [ ! -r "$MID_FILE" ]; then
|
||||
# Never an empty string: an ID field that renders blank looks like a bug,
|
||||
# and a missing file is a fact worth naming.
|
||||
ID_V="unavailable (no $MID_FILE)"
|
||||
else
|
||||
# $(...) strips the trailing newline — that is part of the pinned derivation
|
||||
# above, not an accident of shell.
|
||||
MID="$(cat "$MID_FILE")"
|
||||
if [ -z "$MID" ]; then
|
||||
# NEVER a hash of nothing: hashing the empty string would hand every such
|
||||
# machine the SAME id — the worst possible failure for an identity field.
|
||||
# Images do ship the file empty (that is first-boot semantics per
|
||||
# machine-id(5)), so this path is real, not defensive.
|
||||
ID_V="unavailable ($MID_FILE is empty)"
|
||||
elif [ "$MID" = "uninitialized" ]; then
|
||||
# machine-id(5)'s other not-yet-set sentinel — same collision failure as
|
||||
# empty if hashed, so same loud degradation.
|
||||
ID_V="unavailable ($MID_FILE is uninitialized)"
|
||||
else
|
||||
MID_HASH="$(printf 'rig-machine-id:%s' "$MID" | sha256sum)"
|
||||
MID_HASH="${MID_HASH%% *}"
|
||||
ID_V="${MID_HASH:0:8}-${MID_HASH:8:4}-${MID_HASH:12:4}-${MID_HASH:16:4}-${MID_HASH:20:12}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- OS ---------------------------------------------------------------------
|
||||
# THE os-release TRAP: /etc/os-release defines VERSION, NAME and ID, so
|
||||
# sourcing it in the MAIN shell silently clobbers same-named script variables.
|
||||
|
|
@ -174,7 +112,6 @@ VIRT="$(systemd-detect-virt 2>/dev/null || true)"
|
|||
|
||||
printf '%s\n' "PLATFORM"
|
||||
field HOSTNAME "$HOSTNAME_V"
|
||||
field ID "$ID_V"
|
||||
field OS "${OS:-unknown}"
|
||||
field KERNEL "$KERNEL"
|
||||
field CPU "${CPU_MODEL:-unknown}${CORES:+ ($CORES cores)}"
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig template-lint <role-dir>... — is this a valid role definition?
|
||||
#
|
||||
# rig defines what a valid template is (the schema lives in
|
||||
# lib/templates.sh, beside the mint-time parser that enforces it); the
|
||||
# heavy-duty/rig-templates repo's CI runs this on every definition on every
|
||||
# PR, so a broken definition is refused before it can ever reach a mint
|
||||
# (#110). The two gates are deliberate: CI protects the registry, the
|
||||
# mint-time parse protects a mint served through RIG_TEMPLATES_REPO/_DIR
|
||||
# that CI never saw.
|
||||
#
|
||||
# Pure read: no root, no network, no writes — lintable anywhere, including
|
||||
# the registry repo's checkout, where rig's tree is only a fetched tool.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||
# shellcheck source=SCRIPTDIR/lib/templates.sh
|
||||
. "$HERE/lib/templates.sh" # template_lint (and the schema it enforces)
|
||||
|
||||
die() { printf 'rig-template-lint: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: rig template-lint <role-dir>...
|
||||
|
||||
Validate role definitions (the heavy-duty/rig-templates shape).
|
||||
|
||||
Tenant roles use a *-box directory, tenant template.env schema, a shebang
|
||||
install.sh, and non-blank creds.md. Machine roles use a *-server directory
|
||||
(or exact name workstation), the ROOT_DOOR/HOST/JOIN schema, no creds.md,
|
||||
and an optional install.sh which must be non-empty and carry a shebang.
|
||||
template.env is parsed as KEY="value" data and never sourced. Every refusal
|
||||
names the failing key or file. Exits non-zero if any definition fails;
|
||||
nothing is written.
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
"") usage >&2; die "at least one role directory required" 2 ;;
|
||||
esac
|
||||
|
||||
fail=0
|
||||
for dir in "$@"; do
|
||||
case "$dir" in
|
||||
-*) usage >&2; die "unknown flag: $dir" 2 ;;
|
||||
esac
|
||||
if template_lint "$dir"; then
|
||||
printf 'rig-template-lint: OK: %s\n' "$dir"
|
||||
else
|
||||
printf 'rig-template-lint: FAIL: %s\n' "$dir" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
exit "$fail"
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
# The drill — running it
|
||||
|
||||
`drill/drill.sh` is the instrument; `drills/` is the record it feeds
|
||||
(see [drills/README.md](../drills/README.md) for what a record means and
|
||||
how the three repos' drills relate). rig's drill asserts **convergence**:
|
||||
a machine reaches its role, idempotently. This file is the procedure —
|
||||
written down so a run is repeatable, not reconstructed from memory each
|
||||
release (#105, and #107's debt).
|
||||
|
||||
## What you need
|
||||
|
||||
- **A throwaway Debian 13 machine** you can format, reached as root. The
|
||||
drill hardens its sshd, renames it, joins it to a tailnet, and installs
|
||||
box/Incus, Coolify and a GitHub runner on it. It is not coming back.
|
||||
The machine is its own reset — there is no teardown script and no need
|
||||
for one.
|
||||
- **The pinned candidate refs, both of them.** `--rig-ref` and
|
||||
`--box-ref` are required; the harness refuses to run without them and
|
||||
refuses to continue if what installed disagrees with what was asked
|
||||
(`INSTALLED_FROM`, both trees). Since heavy-duty/rig#103 landed, both
|
||||
installers have sane defaults when unpinned — box installs the
|
||||
`BOX_RELEASE` pin (currently `0.9.0`), rig's `install.sh` resolves the
|
||||
latest release — and a sane default is exactly why the drill will not
|
||||
let a ref go unstated: an unpinned run silently drills a shipping pair
|
||||
that is not the candidate, and the record it leaves looks clean.
|
||||
- **A single-use, tagged tailscale pre-auth key** in `TS_AUTHKEY`
|
||||
(`tag:local` for the default `staging-server` role — bootstrap refuses
|
||||
`tag:server` outside the control-plane shapes).
|
||||
- **A users file** (`--users`) naming at least one operator — leg 1
|
||||
asserts the accounts and keys actually converged.
|
||||
- **For leg 3** (runner lifecycle): a fork to register against
|
||||
(`--runner-repo you/rig`) carrying a `workflow_dispatch` workflow —
|
||||
default name `drill.yml` — whose job has `runs-on: [self-hosted, drill]`
|
||||
and does something trivial (`echo drilled`). Tokens come from an
|
||||
authenticated `gh`, or from `RUNNER_TOKEN` / `RUNNER_REMOVE_TOKEN`.
|
||||
Without a fork the leg **skips, loudly, into the record**.
|
||||
- **For leg 4** (coolify): a version pin, `--coolify-version 4.1.2`.
|
||||
No pin, no leg — rig's own `coolify install` refuses to default a
|
||||
version and so does its drill. The skip is recorded.
|
||||
- **A run ID** (`--run-id`) when this drill shares a substrate with
|
||||
box's or cast's — the shared ID is what lets the per-repo records be
|
||||
joined afterwards. Defaults to `drill-<date>`.
|
||||
|
||||
## Running it
|
||||
|
||||
From a checkout of this repo on the throwaway machine (the record lands
|
||||
in the checkout's `drills/`):
|
||||
|
||||
```sh
|
||||
TS_AUTHKEY=tskey-... bash drill/drill.sh \
|
||||
--rig-ref release/0.4.0 --box-ref 0.9.0 \
|
||||
--users ./drill-users --run-id drill-2026-07-24-a \
|
||||
--coolify-version 4.1.2 --runner-repo you/rig --yes
|
||||
```
|
||||
|
||||
`--box-ref` is a tag on purpose: since #103 the box that ships is the
|
||||
`BOX_RELEASE` tag, so a `release/…` branch is the wrong thing to pin for
|
||||
box — while a release branch stays exactly right for rig's own candidate.
|
||||
|
||||
It runs unattended from there. Legs execute as 1, 4, 2, 3 — Coolify's
|
||||
installer is what puts Docker on the box and the db leg needs a daemon —
|
||||
and the record lists them as they ran. A failing check never aborts the
|
||||
run (`set -u`, no `-e`: a failing check is data), and the summary counts
|
||||
passes, failures and skips separately.
|
||||
|
||||
## What it asserts
|
||||
|
||||
1. **Convergence, and idempotence.** `rig bootstrap <role> --users …`
|
||||
reaches the declared role, asserted on *effective* state — the marker,
|
||||
`sshd -T`, the granted tailnet tag, the operators' accounts and keys.
|
||||
Then bootstrap runs **again**, and the state captured before and after
|
||||
the re-run must diff **empty**. The diff is mechanical; "watched it
|
||||
not obviously break" is exactly what this leg exists to replace.
|
||||
Riding along, the `--host yes` assertions: the **pinned** box
|
||||
installed (`INSTALLED_FROM` matches `--box-ref`, fatal if not),
|
||||
`box doctor` passes. It stops there and says so in the output — the
|
||||
isolation boundary is **box's** drill's assertion, never rig's.
|
||||
2. **db** — `test/db-integration.sh` from the *installed* tree: a real
|
||||
dump/restore round-trip. Its clean-skip contract (no Docker → loud
|
||||
skip, exit 0) survives into the record as a SKIP, never a pass.
|
||||
3. **Runner lifecycle** — register against the fork, dispatch the drill
|
||||
workflow and watch the runner take it, deregister, and assert the
|
||||
box's registration is actually gone.
|
||||
4. **Coolify** — installed at the pin, `AUTOUPDATE=false` landed in the
|
||||
effective `.env`, container running.
|
||||
|
||||
## The record
|
||||
|
||||
The run always ends by writing `drills/<version>.md` (the version is the
|
||||
installed tree's own `VERSION`) — on failures too: **a failed drill is a
|
||||
valid record**; the gate wants evidence, not success. Skipped legs are
|
||||
named as not-run so the record can never read as a clean sweep. Commit
|
||||
the file on the release branch; the `drill-recorded` guard reads that
|
||||
file and nothing else.
|
||||
|
||||
The instrument's own honesty — the refusals, the skip accounting, the
|
||||
capture-and-diff, the emitter — is `test/drill.sh`'s job, and CI runs it
|
||||
on every PR. The live four-leg run is a release's job, once per cycle.
|
||||
708
drill/drill.sh
708
drill/drill.sh
|
|
@ -1,708 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# drill/drill.sh — rig's release drill: the instrument behind drills/README.md.
|
||||
#
|
||||
# ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY Debian machine you
|
||||
# can format. It wipes any installed rig and reinstalls from the pinned
|
||||
# ref, hardens sshd, sets the hostname, joins the tailnet, installs box
|
||||
# and its Incus stack, installs Coolify and a GitHub Actions runner.
|
||||
# Never run it on a machine you care about.
|
||||
#
|
||||
# TS_AUTHKEY=tskey-... bash drill/drill.sh \
|
||||
# --rig-ref release/0.4.0 --box-ref 0.9.0 \
|
||||
# --users ./drill-users --run-id drill-2026-07-24-a \
|
||||
# --coolify-version 4.1.2 --runner-repo you/rig --yes
|
||||
# (--box-ref is a tag: since #103 the box that ships is the BOX_RELEASE tag.)
|
||||
# rig's drill asserts CONVERGENCE — a machine reaches its role, idempotently.
|
||||
# The legs (drills/README.md, issue #105):
|
||||
#
|
||||
# 1. convergence + idempotence — `rig bootstrap <role> --users <path>`
|
||||
# reaches the declared role; a re-run produces an EMPTY state diff,
|
||||
# mechanically, never by eye. Rides along: the --host yes assertions
|
||||
# (the pinned box installed, its host stack stands — and it STOPS there;
|
||||
# the isolation boundary is box's drill's assertion, not this one's).
|
||||
# 2. db — the real dump/restore round-trip, test/db-integration.sh.
|
||||
# 3. runner lifecycle — register, take a job, deregister, against a fork.
|
||||
# 4. coolify install — at a pinned version, AUTOUPDATE=false.
|
||||
#
|
||||
# Execution order is 1, 4, 2, 3 — coolify's installer is what puts Docker on
|
||||
# the box, and leg 2 needs a daemon; running db before coolify would skip a
|
||||
# leg this same run makes runnable. The record lists legs as they ran.
|
||||
#
|
||||
# Exit 0 = no check failed. A FAILED drill still emits a complete record —
|
||||
# the gate wants evidence, not success — and skipped legs are counted and
|
||||
# named, never folded into the passes (heavy-duty/box#153's defect class).
|
||||
#
|
||||
# The file is one long 'probe && ok "…" || no "…"'. ok/no always return 0, so
|
||||
# the C-may-run-when-A-is-true trap SC2015 warns about cannot fire here.
|
||||
# shellcheck disable=SC2015
|
||||
#
|
||||
# NOT -e: a failing check is data, not a crash — a drill that aborts on its
|
||||
# first failure reports one problem per afternoon. NOT pipefail: checks of the
|
||||
# 'refusal 2>&1 | grep -q text' shape have a left side that exits non-zero BY
|
||||
# DESIGN, and 'grep -q' SIGPIPEs the left side on early match — box's first
|
||||
# live run turned both into false FAILs. The pipeline verdict must be grep's
|
||||
# alone. (box drill/drill.sh's header, the discipline #105 prescribes.)
|
||||
set -u
|
||||
|
||||
SELF="$(readlink -f "$0")"
|
||||
ROOT="$(cd "$(dirname "$SELF")/.." && pwd)"
|
||||
|
||||
REPO="${RIG_REPO:-heavy-duty/rig}"
|
||||
REF="${RIG_REF:-}"
|
||||
BOXREPO="${BOX_REPO:-heavy-duty/box}"
|
||||
BOXREF="${BOX_REF:-}"
|
||||
# The template registry the converge will read (#110). No explicitness
|
||||
# demand here, unlike the two refs above: the DEFAULT is already a pin — the
|
||||
# candidate tree's RIG_TEMPLATES_PIN, read after install from what actually
|
||||
# landed — so an unset override means "the ref the release will really use",
|
||||
# not "whatever main was that afternoon".
|
||||
TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||
TPLREF="${RIG_TEMPLATES_REF:-}"
|
||||
TPL_SHA=""
|
||||
TPL_SOURCE="fetched"
|
||||
ROLE=staging-server
|
||||
USERS_FILE="${DRILL_USERS_FILE:-}"
|
||||
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
|
||||
RECORD="${DRILL_RECORD:-}"
|
||||
COOLIFY_VERSION="${DRILL_COOLIFY_VERSION:-}"
|
||||
RUNNER_REPO="${DRILL_RUNNER_REPO:-}"
|
||||
RUNNER_WORKFLOW="${DRILL_RUNNER_WORKFLOW:-drill.yml}"
|
||||
YES=0
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--yes|-y) YES=1; shift ;;
|
||||
--rig-repo) REPO="$2"; shift 2 ;;
|
||||
--rig-ref) REF="$2"; shift 2 ;;
|
||||
--box-repo) BOXREPO="$2"; shift 2 ;;
|
||||
--box-ref) BOXREF="$2"; shift 2 ;;
|
||||
--role) ROLE="$2"; shift 2 ;;
|
||||
--users) USERS_FILE="$2"; shift 2 ;;
|
||||
--run-id) RUN_ID="$2"; shift 2 ;;
|
||||
--record) RECORD="$2"; shift 2 ;;
|
||||
--coolify-version) COOLIFY_VERSION="$2"; shift 2 ;;
|
||||
--runner-repo) RUNNER_REPO="$2"; shift 2 ;;
|
||||
--runner-workflow) RUNNER_WORKFLOW="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "drill: unknown option: $1 (see --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- the reporting verbs (box drill/drill.sh:52-58, the parts worth copying) --
|
||||
# ok/no/skip/note always return 0: the body stays one long sequence of
|
||||
# 'probe && ok || no' without fighting the shell. SKIP is its own verb and its
|
||||
# own counter — a leg that did not run must be visually and arithmetically
|
||||
# distinct from one that passed (box#153's defect class: a silent skip reads
|
||||
# as a pass in the record, months later).
|
||||
pass=0; fail=0; skipped=0; findings=()
|
||||
ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; pass=$((pass + 1)); }
|
||||
no() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; fail=$((fail + 1)); findings+=("FAIL: $*"); }
|
||||
skip() { printf ' \033[35mSKIP\033[0m %s\n' "$*"; skipped=$((skipped + 1)); findings+=("SKIP: $*"); }
|
||||
note() { printf ' \033[33mNOTE\033[0m %s\n' "$*"; findings+=("NOTE: $*"); }
|
||||
inf() { printf ' %s\n' "$*"; }
|
||||
phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
|
||||
|
||||
# The record's leg table, appended as legs run. One row per leg, result text
|
||||
# written at the moment the leg's verdict is known — never reconstructed from
|
||||
# memory at the end (an invented number is worse than no number).
|
||||
LEG_NAMES=(); LEG_RESULTS=()
|
||||
leg() { LEG_NAMES+=("$1"); LEG_RESULTS+=("$2"); }
|
||||
|
||||
# run_logged <log> <cmd...> — run a long command with its narration in a file
|
||||
# and a dot every 5s on the terminal: a silent multi-minute apt/install run is
|
||||
# indistinguishable from a wedge, and that ambiguity has cost box whole
|
||||
# evenings. Returns the command's exit code.
|
||||
run_logged() {
|
||||
local log="$1"; shift
|
||||
inf "watch it live in another terminal: tail -f $log"
|
||||
"$@" >"$log" 2>&1 </dev/null &
|
||||
local pid=$!
|
||||
while kill -0 "$pid" 2>/dev/null; do printf '.'; sleep 5; done
|
||||
printf '\n'
|
||||
wait "$pid"
|
||||
}
|
||||
|
||||
# tree_of <cli-path> — the versioned install tree a CLI's symlink chain lands
|
||||
# in. Both rig and box install as <root>/versions/<v>/bin/<cli> behind a
|
||||
# 'current' link, so the tree is two dirnames above the resolved binary —
|
||||
# derived from the chain itself, never from a hardcoded install root (root vs
|
||||
# user installs put the root in different places).
|
||||
tree_of() {
|
||||
local real
|
||||
real="$(readlink -f "$1" 2>/dev/null)"
|
||||
# -e as well as -n: GNU readlink -f resolves a path whose LAST component
|
||||
# does not exist (exit 0), so a dangling link would hand back a tree that
|
||||
# is not there.
|
||||
{ [ -n "$real" ] && [ -e "$real" ]; } || return 1
|
||||
dirname "$(dirname "$real")"
|
||||
}
|
||||
|
||||
# assert_installed_from <what> <tree> <want> — ASSERT WHAT LANDED, never trust
|
||||
# that the install obeyed. An installer invoked with stale env vars silently
|
||||
# falls back to its defaults — sane ones since rig#103 landed (box: the
|
||||
# BOX_RELEASE pin, rig: the latest release), which is what makes the fallback
|
||||
# invisible — and a drill that thinks it exercised release/X but actually got
|
||||
# whatever the defaults resolve to has proven nothing about the combination
|
||||
# that ships — worse than one that fails, because the record it leaves LOOKS
|
||||
# like evidence. Refusal names both refs, per #105's acceptance criteria.
|
||||
assert_installed_from() {
|
||||
local what="$1" tree="$2" want="$3" got
|
||||
got="$(cat "$tree/INSTALLED_FROM" 2>/dev/null || echo '<unreadable>')"
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'drill: FATAL — asked to install %s from %s, but the installed tree says %s.\n' "$what" "$want" "$got" >&2
|
||||
printf ' (tree: %s)\n' "$tree" >&2
|
||||
printf ' A drill that silently drills the wrong code is worse than one that fails:\n' >&2
|
||||
printf ' every result below would describe a tree that is not the candidate. Check\n' >&2
|
||||
printf ' the env this drill inherited (a stale RIG_REF/BOX_REF export), fix the\n' >&2
|
||||
printf ' pin, and re-run.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# classify_leg <rc> <outfile> — pass | skip | fail. The skip contract is
|
||||
# test/db-integration.sh's, copied carefully: it skips CLEANLY (exit 0) with a
|
||||
# 'skip: <reason>' line when it cannot run, so exit code alone reads a
|
||||
# not-run leg as a pass. The reason line is the verdict's tiebreaker; a
|
||||
# non-zero exit is a fail whatever the output says (a die after a skip line
|
||||
# would be a broken harness, not a skip).
|
||||
classify_leg() {
|
||||
local rc="$1" out="$2"
|
||||
if [ "$rc" -eq 0 ] && grep -q '^skip:' "$out" 2>/dev/null; then
|
||||
printf 'skip'
|
||||
elif [ "$rc" -eq 0 ]; then
|
||||
printf 'pass'
|
||||
else
|
||||
printf 'fail'
|
||||
fi
|
||||
}
|
||||
|
||||
# capture_state <outfile> — the convergent surface bootstrap owns, as one
|
||||
# diffable text file. Leg 1's idempotence claim is decided by capturing this
|
||||
# BEFORE and AFTER the re-run and diffing — mechanically, because idempotence
|
||||
# is the single easiest property to convince yourself of by eye (#105).
|
||||
#
|
||||
# What is captured is what bootstrap CONVERGES, nothing that legitimately
|
||||
# moves between two back-to-back runs: no package lists (unattended-upgrades
|
||||
# may act between captures), no clocks. The manifest is included WHOLE on
|
||||
# purpose — lib/manifest.sh's contract is that a same-version re-run renders
|
||||
# byte-identical content (converged_at tracks the version, not the run), so
|
||||
# the diff ENFORCES that contract instead of exempting it.
|
||||
#
|
||||
# Every path is overridable so test/drill.sh proves the capture-and-diff
|
||||
# machinery against fixtures, without root (repo precedent: RIG_ROLE_MARKER,
|
||||
# RIG_MANIFEST). Absent files and commands degrade to a deterministic
|
||||
# '(absent)' — a capture must never fail, only describe.
|
||||
capture_state() {
|
||||
local out="$1" marker manifest ledger autoup hosts u state home keys
|
||||
marker="${RIG_ROLE_MARKER:-/etc/rig/role}"
|
||||
manifest="${RIG_MANIFEST:-/etc/rig/manifest}"
|
||||
ledger="${DRILL_LEDGER:-/etc/rig/users}"
|
||||
autoup="${DRILL_AUTOUPGRADES:-/etc/apt/apt.conf.d/20auto-upgrades}"
|
||||
hosts="${DRILL_ETC_HOSTS:-/etc/hosts}"
|
||||
{
|
||||
printf 'hostname: %s\n' "$(hostname 2>/dev/null || echo '(absent)')"
|
||||
printf 'hosts.127.0.1.1: %s\n' "$(grep -E '^127\.0\.1\.1[[:space:]]' "$hosts" 2>/dev/null || echo '(absent)')"
|
||||
printf 'role-marker: %s\n' "$(cat "$marker" 2>/dev/null || echo '(absent)')"
|
||||
printf 'manifest:\n'
|
||||
sed 's/^/ /' "$manifest" 2>/dev/null || printf ' (absent)\n'
|
||||
printf 'auto-upgrades:\n'
|
||||
sed 's/^/ /' "$autoup" 2>/dev/null || printf ' (absent)\n'
|
||||
printf 'sshd-effective:\n'
|
||||
if command -v sshd >/dev/null 2>&1; then
|
||||
sshd -T 2>/dev/null | sort | sed 's/^/ /' || printf ' (sshd -T failed)\n'
|
||||
else
|
||||
printf ' (sshd absent)\n'
|
||||
fi
|
||||
# Self's Tags is the FIRST occurrence in the status JSON (Self serializes
|
||||
# before Peer). Tags only, nothing livelier: peers joining, IPs renewing
|
||||
# or a backend-state flap between two captures is not a convergence diff
|
||||
# on this box, and a capture that can move on its own poisons the
|
||||
# idempotence verdict with noise.
|
||||
printf 'tailscale.self.tags: %s\n' "$(tailscale status --json 2>/dev/null | tr -d '\n ' | grep -o '"Tags":\[[^]]*\]' | head -n1 || true)"
|
||||
printf 'users-ledger:\n'
|
||||
sed 's/^/ /' "$ledger" 2>/dev/null || printf ' (absent)\n'
|
||||
# Per-operator effective state: the account, its groups, its lock state,
|
||||
# its keys. sha256 of authorized_keys, not the keys themselves — the
|
||||
# capture may end up quoted in a record and keys are long, not secret.
|
||||
while read -r u state; do
|
||||
[ -n "${u:-}" ] || continue
|
||||
if ! id -u "$u" >/dev/null 2>&1; then
|
||||
printf 'user.%s: (no account)\n' "$u"
|
||||
continue
|
||||
fi
|
||||
printf 'user.%s: state=%s groups=%s lock=%s\n' "$u" "${state:-active}" \
|
||||
"$(id -Gn "$u" 2>/dev/null | tr ' ' ',')" \
|
||||
"$(passwd -S "$u" 2>/dev/null | awk '{print $2}' || echo '?')"
|
||||
home="$(getent passwd "$u" | cut -d: -f6)"
|
||||
keys="$home/.ssh/authorized_keys"
|
||||
printf 'user.%s.authorized_keys: %s\n' "$u" \
|
||||
"$(sha256sum "$keys" 2>/dev/null | cut -d' ' -f1 || echo '(none)')"
|
||||
done < <(cat "$ledger" 2>/dev/null)
|
||||
printf 'sudoers.d:\n'
|
||||
find "${DRILL_SUDOERS_DIR:-/etc/sudoers.d}" -maxdepth 1 -type f 2>/dev/null | sort \
|
||||
| while read -r u; do printf ' %s %s\n' "$(sha256sum "$u" | cut -d' ' -f1)" "$u"; done
|
||||
printf 'box: %s\n' "$(command -v box 2>/dev/null || echo '(absent)')"
|
||||
} > "$out"
|
||||
}
|
||||
|
||||
# ref_sha <owner/repo> <ref> — the commit the record cites. Tags outrank
|
||||
# branches (the installer's own precedence, install.sh:117-120). Resolved once
|
||||
# up front and reused, so the record and the install describe the same instant
|
||||
# even if the branch moves mid-drill. Empty on failure; the record then says
|
||||
# 'unresolved' rather than inventing one.
|
||||
ref_sha() {
|
||||
local sha
|
||||
sha="$(git ls-remote "https://github.com/$1" "refs/tags/$2" 2>/dev/null | head -n1 | cut -f1)"
|
||||
[ -n "$sha" ] || sha="$(git ls-remote "https://github.com/$1" "refs/heads/$2" 2>/dev/null | head -n1 | cut -f1)"
|
||||
printf '%s' "${sha:0:7}"
|
||||
}
|
||||
|
||||
# emit_record <path> — drills/<version>.md, in the shape drills/README.md
|
||||
# defines: what ran, on what host, the pinned refs and SHAs, the numbers, and
|
||||
# what failed. Emitted on EVERY completed run — a failed drill is still a
|
||||
# valid record; the gate wants evidence, not success. Skipped legs are listed
|
||||
# by name: a record with no failures listed reads as "nothing broke", so a leg
|
||||
# that was not run says so instead of being omitted.
|
||||
emit_record() {
|
||||
local out="$1" i os cpus ram virt line
|
||||
os="$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-unknown}")"
|
||||
cpus="$(nproc 2>/dev/null || echo '?')"
|
||||
ram="$(awk '/MemTotal/{printf "%.0f", $2/1024/1024}' /proc/meminfo 2>/dev/null || echo '?')"
|
||||
virt="$(systemd-detect-virt 2>/dev/null || echo unknown)"
|
||||
{
|
||||
printf '# Release drill — %s — %s\n\n' "$DRILL_VERSION" "$(date -u +%F)"
|
||||
printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt"
|
||||
printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \
|
||||
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
|
||||
printf 'Template registry: %s@%s (ref %s, %s) — the rig-templates source the converge read (#110/#153).\n' \
|
||||
"${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" \
|
||||
"${TPLREF:-unresolved}" "${TPL_SOURCE:-fetched}"
|
||||
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
|
||||
printf '| Leg | Result |\n'
|
||||
printf '| --- | --- |\n'
|
||||
for i in "${!LEG_NAMES[@]}"; do
|
||||
printf '| %s | %s |\n' "${LEG_NAMES[$i]}" "${LEG_RESULTS[$i]}"
|
||||
done
|
||||
printf '\nChecks: %s passed, %s failed, %s skipped.\n' "$pass" "$fail" "$skipped"
|
||||
if [ "$fail" -eq 0 ] && [ "$skipped" -eq 0 ]; then
|
||||
printf '\nFailed: nothing. Every leg ran and every check passed.\n'
|
||||
else
|
||||
# printf --: a format opening with '- ' reads as an option to bash's
|
||||
# printf and emits NOTHING — a record whose Failed section silently
|
||||
# vanished is exactly the lie this file exists to make impossible.
|
||||
[ "$fail" -gt 0 ] && printf '\nFailed:\n'
|
||||
for line in "${findings[@]:-}"; do
|
||||
case "$line" in FAIL:*) printf -- '- %s\n' "$line" ;; esac
|
||||
done
|
||||
[ "$skipped" -gt 0 ] && printf '\nSkipped — these did NOT run, and this record is not evidence for them:\n'
|
||||
for line in "${findings[@]:-}"; do
|
||||
case "$line" in SKIP:*) printf -- '- %s\n' "$line" ;; esac
|
||||
done
|
||||
fi
|
||||
printf '\nThe isolation boundary was NOT asserted here: it is box'\''s drill'\''s\n'
|
||||
printf 'assertion (heavy-duty/box drill/drill.sh), joined to this record by the run ID.\n'
|
||||
} > "$out"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Pre-flight — every refusal this run can see coming fires here, before
|
||||
# anything is installed or any credential is spent. Args are validated BEFORE
|
||||
# the root check (repo doctrine, bootstrap.sh:114 — so the refusals are
|
||||
# testable without root, and a typo costs a re-type, never a re-ssh).
|
||||
# =============================================================================
|
||||
# Both refs EXPLICIT, or nothing runs. Defaulting either to main is exactly
|
||||
# the #103 hazard this harness exists to refuse: "I drilled the release" must
|
||||
# not quietly mean "I drilled whatever main was that afternoon".
|
||||
if [ -z "$REF" ] || [ -z "$BOXREF" ]; then
|
||||
echo "drill: both refs must be pinned explicitly — a drill against an unstated ref is not evidence (#103):" >&2
|
||||
echo " --rig-ref <ref> (or RIG_REF) the rig candidate, e.g. release/0.4.0 [got: ${REF:-<unset>}]" >&2
|
||||
echo " --box-ref <ref> (or BOX_REF) the box that will ship with it [got: ${BOXREF:-<unset>}]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$ROLE" in
|
||||
staging-server|dev-server|control-plane-server|workload-server|runner-server) ;;
|
||||
*) echo "drill: --role $ROLE is not a machine role this drill can converge unattended" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if [ -z "$USERS_FILE" ]; then
|
||||
echo "drill: --users <path> is required — leg 1 asserts operators converged, and bootstrap requires the file (its --no-users opt-out would leave leg 1 asserting nothing)" >&2
|
||||
exit 2
|
||||
fi
|
||||
[ -r "$USERS_FILE" ] || { echo "drill: cannot read users file: $USERS_FILE" >&2; exit 2; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || { echo "drill: must run as root (bootstrap, runner, coolify and db all require it) — ssh in as root on the throwaway machine" >&2; exit 1; }
|
||||
|
||||
# The tailnet join needs a key unless this machine already joined (a re-drill
|
||||
# on the same throwaway). Caught here, not 10 apt-minutes into bootstrap.
|
||||
if [ -z "${TS_AUTHKEY:-}" ]; then
|
||||
if ! { command -v tailscale >/dev/null 2>&1 && tailscale status >/dev/null 2>&1; }; then
|
||||
echo "drill: TS_AUTHKEY is unset and this machine has not joined a tailnet — leg 1's bootstrap will refuse. Mint a single-use TAGGED pre-auth key and export TS_AUTHKEY." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "drill: curl is required (the pinned installs download over it)" >&2; exit 1; }
|
||||
|
||||
if [ "$YES" -ne 1 ]; then
|
||||
cat <<EOF
|
||||
This will, ON THIS HOST ($(hostname)):
|
||||
· wipe any installed rig and reinstall $REPO@$REF from scratch
|
||||
· run 'rig bootstrap $ROLE --users $USERS_FILE' — sshd hardening, hostname
|
||||
change, tailnet join, box ($BOXREPO@$BOXREF) + its Incus stack — TWICE
|
||||
(the second run is the idempotence assertion)
|
||||
· install Coolify${COOLIFY_VERSION:+ $COOLIFY_VERSION} and a GitHub runner${RUNNER_REPO:+ against $RUNNER_REPO}
|
||||
Only do this on a THROWAWAY machine you can format.
|
||||
EOF
|
||||
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
|
||||
printf 'Continue? [y/N] '
|
||||
read -r reply
|
||||
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
|
||||
fi
|
||||
|
||||
phase "Pinned candidates"
|
||||
RIG_SHA="$(ref_sha "$REPO" "$REF")"
|
||||
BOX_SHA="$(ref_sha "$BOXREPO" "$BOXREF")"
|
||||
inf "rig: $REPO@$REF (${RIG_SHA:-unresolved})"
|
||||
inf "box: $BOXREPO@$BOXREF (${BOX_SHA:-unresolved})"
|
||||
inf "run ID: $RUN_ID — drills sharing this substrate share it (drills/README.md)"
|
||||
|
||||
# =============================================================================
|
||||
phase "Installing rig ($REPO@$REF) from scratch"
|
||||
# =============================================================================
|
||||
# The drill proves a tree from SCRATCH every run — a fresh machine, not a
|
||||
# converged install — so any prior rig goes first (root's install lands at
|
||||
# \$HOME/.local/share/rig with the /usr/local/bin symlink).
|
||||
rm -rf "$HOME/.local/share/rig" /usr/local/bin/rig
|
||||
|
||||
if ! run_logged /tmp/drill-rig-install.log \
|
||||
env RIG_REPO="$REPO" RIG_REF="$REF" \
|
||||
bash -c "bash <(curl -fsSL \"https://raw.githubusercontent.com/$REPO/$REF/install.sh\")"; then
|
||||
echo "drill: rig's installer failed — tail of /tmp/drill-rig-install.log:" >&2
|
||||
tail -5 /tmp/drill-rig-install.log >&2
|
||||
exit 1
|
||||
fi
|
||||
command -v rig >/dev/null 2>&1 || { echo "drill: installer reported success but no 'rig' on PATH" >&2; exit 1; }
|
||||
|
||||
# ASSERT WHAT LANDED — the up-front ref assertion, fatal on mismatch.
|
||||
RIG_TREE="$(tree_of "$(command -v rig)")"
|
||||
assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1
|
||||
DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)"
|
||||
ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)"
|
||||
|
||||
# The rig-templates ref this candidate converges (#110), for the record: the
|
||||
# env override when the drill was pointed somewhere, else the pin read from
|
||||
# the INSTALLED tree — what actually landed, never this checkout's copy. A
|
||||
# 40-hex ref IS its own SHA (the pin's normal shape); anything else resolves
|
||||
# through ref_sha like the two candidates above.
|
||||
if [ -z "$TPLREF" ]; then
|
||||
TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)"
|
||||
if [ -n "$TPLREF" ] &&
|
||||
[ -n "$(find "$RIG_TREE/templates@$TPLREF" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)" ]; then
|
||||
TPL_SOURCE="snapshot"
|
||||
fi
|
||||
fi
|
||||
if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
TPL_SHA="${TPLREF:0:7}"
|
||||
elif [ -n "$TPLREF" ]; then
|
||||
TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")"
|
||||
fi
|
||||
inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved}, $TPL_SOURCE)"
|
||||
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
|
||||
|
||||
# =============================================================================
|
||||
phase "Leg 1 — convergence: rig bootstrap $ROLE"
|
||||
# =============================================================================
|
||||
# BOX_REPO/BOX_REF ride the environment into bootstrap's host=yes box install,
|
||||
# so the box that lands is the pinned candidate, not what bootstrap falls back
|
||||
# to unexported (the BOX_RELEASE pin, since rig#103 landed).
|
||||
export BOX_REPO="$BOXREPO" BOX_REF="$BOXREF"
|
||||
|
||||
t0=$SECONDS
|
||||
if run_logged /tmp/drill-bootstrap-1.log rig bootstrap "$ROLE" --users "$USERS_FILE"; then
|
||||
ok "rig bootstrap $ROLE --users … exited 0 ($((SECONDS - t0))s)"
|
||||
BOOTSTRAP_OK=1
|
||||
else
|
||||
no "rig bootstrap $ROLE FAILED — tail: $(tail -3 /tmp/drill-bootstrap-1.log | tr '\n' ' ')"
|
||||
BOOTSTRAP_OK=0
|
||||
fi
|
||||
|
||||
MARKER_LINE="$(cat "${RIG_ROLE_MARKER:-/etc/rig/role}" 2>/dev/null || true)"
|
||||
if [ "$BOOTSTRAP_OK" -eq 1 ]; then
|
||||
# The role, asserted on EFFECTIVE state — the marker, the daemon's resolved
|
||||
# config, the netmap's granted tags — never on what was requested (the
|
||||
# sshd-first-wins lesson, lib/sshd.sh:63-70).
|
||||
case "$MARKER_LINE" in
|
||||
"role=$ROLE "*) ok "role marker: $MARKER_LINE" ;;
|
||||
*) no "role marker is '$MARKER_LINE' — expected role=$ROLE …" ;;
|
||||
esac
|
||||
sshd -T 2>/dev/null | grep -qx 'passwordauthentication no' \
|
||||
&& ok "sshd -T resolves passwordauthentication no (the hardening took)" \
|
||||
|| no "sshd still resolves password auth — the 00-rig.conf drop-in is not winning"
|
||||
ts_tags="$(tailscale status --json 2>/dev/null | tr -d '\n ' | grep -o '"Tags":\[[^]]*\]' | head -n1)"
|
||||
if [ -n "$ts_tags" ] && [ "$ts_tags" != '"Tags":[]' ]; then
|
||||
ok "tailnet joined, tagged: $ts_tags"
|
||||
else
|
||||
no "tailnet join did not leave a tagged node (got: ${ts_tags:-nothing}) — bootstrap's verify should have refused this"
|
||||
fi
|
||||
grep -q 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null \
|
||||
&& ok "unattended-upgrades enabled" || no "20auto-upgrades missing or wrong"
|
||||
grep -q "converged_by=$DRILL_VERSION" "${RIG_MANIFEST:-/etc/rig/manifest}" 2>/dev/null \
|
||||
&& ok "manifest: converged_by=$DRILL_VERSION" || no "manifest does not name $DRILL_VERSION as the converging rig"
|
||||
users_bad=""
|
||||
while read -r u state; do
|
||||
[ "$state" = active ] || continue
|
||||
id -u "$u" >/dev/null 2>&1 || { users_bad="$users_bad $u(no-account)"; continue; }
|
||||
uhome="$(getent passwd "$u" | cut -d: -f6)"
|
||||
[ -s "$uhome/.ssh/authorized_keys" ] || users_bad="$users_bad $u(no-keys)"
|
||||
done < <(cat "${DRILL_LEDGER:-/etc/rig/users}" 2>/dev/null)
|
||||
# NOT 'grep -c … || echo 0': grep -c already prints 0 on no match (and then
|
||||
# exits 1), so the fallback would emit a second line into the substitution.
|
||||
n_users="$(grep -c ' active$' "${DRILL_LEDGER:-/etc/rig/users}" 2>/dev/null)" || true
|
||||
n_users="${n_users:-0}"
|
||||
[ -z "$users_bad" ] && [ "$n_users" -gt 0 ] \
|
||||
&& ok "operators converged: $n_users active, accounts and keys present" \
|
||||
|| no "operators NOT converged:${users_bad:- ledger empty}"
|
||||
leg "convergence — bootstrap $ROLE reaches its role" \
|
||||
"$([ "$fail" -eq 0 ] && echo "PASS ($((SECONDS - t0))s)" || echo "FAIL — see Failed below")"
|
||||
|
||||
# --- idempotence: the claim this drill exists to make ----------------------
|
||||
# Capture, re-run, capture, diff. Mechanically — never "watched it not
|
||||
# obviously break". An empty diff IS the definition of converged.
|
||||
phase "Leg 1 — idempotence: the re-run must change nothing"
|
||||
pre="$(mktemp)"; post="$(mktemp)"
|
||||
capture_state "$pre"
|
||||
t0=$SECONDS
|
||||
if run_logged /tmp/drill-bootstrap-2.log rig bootstrap "$ROLE" --users "$USERS_FILE"; then
|
||||
ok "second bootstrap exited 0 ($((SECONDS - t0))s)"
|
||||
else
|
||||
no "second bootstrap FAILED — tail: $(tail -3 /tmp/drill-bootstrap-2.log | tr '\n' ' ')"
|
||||
fi
|
||||
capture_state "$post"
|
||||
if statediff="$(diff -u "$pre" "$post")"; then
|
||||
ok "re-converge is a no-op: the state diff is empty"
|
||||
leg "re-converge (idempotence)" "clean, no changes"
|
||||
else
|
||||
dlines="$(printf '%s\n' "$statediff" | grep -c '^[+-][^+-]')"
|
||||
no "re-converge CHANGED the box — $dlines state line(s) differ:"
|
||||
printf '%s\n' "$statediff" | sed 's/^/ /'
|
||||
leg "re-converge (idempotence)" "DIRTY — $dlines state line(s) changed on the re-run"
|
||||
fi
|
||||
rm -f "$pre" "$post"
|
||||
else
|
||||
leg "convergence — bootstrap $ROLE reaches its role" "FAIL — bootstrap exited non-zero"
|
||||
skip "idempotence not asserted — the first converge already failed, a re-run diff would measure noise"
|
||||
leg "re-converge (idempotence)" "SKIPPED — first converge failed"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
phase "--host yes — the box that will ship"
|
||||
# =============================================================================
|
||||
# The assertions #105 settles this leg at: the installer ran, INSTALLED_FROM
|
||||
# matches the requested BOX_REF, setup-host exited clean, the stack it claims
|
||||
# stands. Then it STOPS. Not one isolation probe: two records that both claim
|
||||
# the trust boundary will eventually disagree with no tiebreaker, and a
|
||||
# partial isolation check reads — months later, in a record — as though the
|
||||
# boundary was drilled (box#153's shape through a different door). Resist
|
||||
# adding "just one" probe here; that is box's drill's whole job.
|
||||
case "$MARKER_LINE" in
|
||||
*"host=yes"*)
|
||||
if command -v box >/dev/null 2>&1; then
|
||||
ok "box CLI on PATH"
|
||||
BOX_TREE="$(tree_of "$(command -v box)")"
|
||||
# Fatal, like rig's own: a wrong box under --host yes poisons the pair.
|
||||
assert_installed_from box "$BOX_TREE" "$BOXREPO@$BOXREF" || exit 1
|
||||
ok "installed box confirms: $BOXREPO@$BOXREF"
|
||||
if box doctor >/dev/null 2>&1; then
|
||||
ok "box doctor passes — setup-host converged; the host stack stands (box's own effective-state verdict)"
|
||||
leg "--host yes: pinned box installed, host stack up" "PASS — $BOXREPO@$BOXREF, box doctor clean"
|
||||
else
|
||||
no "box is installed but 'box doctor' does not pass — the host stack is unproven (run 'box doctor' for box's verdict)"
|
||||
leg "--host yes: pinned box installed, host stack up" "FAIL — box doctor does not pass"
|
||||
fi
|
||||
else
|
||||
no "no 'box' on PATH after a host=yes bootstrap — the box install did not take (bootstrap warns rather than dies there; the drill does not)"
|
||||
leg "--host yes: pinned box installed, host stack up" "FAIL — box CLI never landed"
|
||||
fi
|
||||
inf "isolation NOT asserted here — deliberately. The VM trust boundary is box's"
|
||||
inf "assertion, made by box's own drill (~85 probes); this leg stops at 'the pinned"
|
||||
inf "box installed and its host stack stands'. The records join on the run ID."
|
||||
;;
|
||||
*)
|
||||
skip "--host yes assertions: role $ROLE left host=no (marker: ${MARKER_LINE:-absent})"
|
||||
leg "--host yes: pinned box installed, host stack up" "SKIPPED — this role does not host VMs"
|
||||
;;
|
||||
esac
|
||||
|
||||
# =============================================================================
|
||||
phase "Leg 4 — coolify install (pinned, AUTOUPDATE=false)"
|
||||
# =============================================================================
|
||||
# Runs BEFORE leg 2 on purpose: Coolify's installer is what puts Docker on the
|
||||
# box, and the db leg needs a daemon — ordering them the other way around
|
||||
# would manufacture a skip this same run could have avoided.
|
||||
if [ -z "$COOLIFY_VERSION" ]; then
|
||||
skip "coolify install: no --coolify-version pin given — the leg did not run (rig's own install refuses to default a version, and so does its drill)"
|
||||
leg "coolify install" "SKIPPED — no version pin provided"
|
||||
else
|
||||
t0=$SECONDS
|
||||
if run_logged /tmp/drill-coolify.log rig coolify install --version "$COOLIFY_VERSION"; then
|
||||
ok "rig coolify install --version $COOLIFY_VERSION exited 0 ($((SECONDS - t0))s)"
|
||||
grep -qx 'AUTOUPDATE=false' /data/coolify/source/.env 2>/dev/null \
|
||||
&& ok "AUTOUPDATE=false landed in /data/coolify/source/.env — the platform will not move under its operators" \
|
||||
|| no "AUTOUPDATE=false is NOT in coolify's .env — the pin is not holding"
|
||||
cstate="$(docker inspect -f '{{.State.Status}}' coolify 2>/dev/null || echo absent)"
|
||||
[ "$cstate" = running ] && ok "the coolify container is running" \
|
||||
|| no "coolify container state: $cstate (expected running)"
|
||||
leg "coolify install ($COOLIFY_VERSION)" \
|
||||
"$([ "$cstate" = running ] && echo "PASS ($(((SECONDS - t0) / 60)) min)" || echo "FAIL — container $cstate")"
|
||||
else
|
||||
no "coolify install FAILED — tail: $(tail -3 /tmp/drill-coolify.log | tr '\n' ' ')"
|
||||
leg "coolify install ($COOLIFY_VERSION)" "FAIL — installer exited non-zero"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
phase "Leg 2 — db dump/restore round-trip (test/db-integration.sh)"
|
||||
# =============================================================================
|
||||
# Driven from the INSTALLED tree — the drill exercises what shipped, not the
|
||||
# checkout this script happens to sit in. The leg's skip contract is the
|
||||
# script's own (loud, reasoned, exit 0) and classify_leg keeps it a SKIP:
|
||||
# counted, rendered distinctly, named in the record — never a pass.
|
||||
db_out="$(mktemp)"
|
||||
bash "$RIG_TREE/test/db-integration.sh" >"$db_out" 2>&1
|
||||
db_rc=$?
|
||||
case "$(classify_leg "$db_rc" "$db_out")" in
|
||||
pass)
|
||||
db_numbers="$(tail -1 "$db_out")"
|
||||
ok "db round-trip: $db_numbers"
|
||||
leg "test/db-integration.sh" "PASS — $db_numbers"
|
||||
;;
|
||||
skip)
|
||||
db_reason="$(grep -m1 '^skip:' "$db_out")"
|
||||
skip "db round-trip did not run — $db_reason"
|
||||
leg "test/db-integration.sh" "SKIPPED — ${db_reason#skip: }"
|
||||
;;
|
||||
fail)
|
||||
no "db round-trip FAILED (exit $db_rc) — tail: $(tail -3 "$db_out" | tr '\n' ' ')"
|
||||
leg "test/db-integration.sh" "FAIL — exit $db_rc"
|
||||
;;
|
||||
esac
|
||||
rm -f "$db_out"
|
||||
|
||||
# =============================================================================
|
||||
phase "Leg 3 — runner lifecycle against a fork"
|
||||
# =============================================================================
|
||||
# Register, take a job, deregister. The fork must carry a workflow_dispatch
|
||||
# workflow (default drill.yml) whose job runs-on the 'drill' label — see
|
||||
# drill/README.md. Tokens: RUNNER_TOKEN / RUNNER_REMOVE_TOKEN env, or minted
|
||||
# via an authenticated gh. Without a fork or a token source the leg SKIPS,
|
||||
# loudly, and the record says it did not run.
|
||||
if [ -z "$RUNNER_REPO" ]; then
|
||||
skip "runner lifecycle: no --runner-repo fork given — the leg did not run"
|
||||
leg "runner lifecycle" "SKIPPED — no fork provided"
|
||||
else
|
||||
GH_OK=0
|
||||
command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && GH_OK=1
|
||||
reg_token="${RUNNER_TOKEN:-}"
|
||||
if [ -z "$reg_token" ] && [ "$GH_OK" -eq 1 ]; then
|
||||
reg_token="$(gh api -X POST "repos/$RUNNER_REPO/actions/runners/registration-token" --jq .token 2>/dev/null)"
|
||||
fi
|
||||
if [ -z "$reg_token" ]; then
|
||||
skip "runner lifecycle: no RUNNER_TOKEN and no authenticated gh to mint one — the leg did not run"
|
||||
leg "runner lifecycle ($RUNNER_REPO)" "SKIPPED — no registration token source"
|
||||
else
|
||||
RUNNER_NAME="drill-$(hostname)-$$"
|
||||
if RUNNER_TOKEN="$reg_token" run_logged /tmp/drill-runner-install.log \
|
||||
rig runner install --repo "$RUNNER_REPO" --name "$RUNNER_NAME" --labels drill; then
|
||||
ok "rig runner install --repo $RUNNER_REPO exited 0 (registered as $RUNNER_NAME)"
|
||||
else
|
||||
no "runner install FAILED — tail: $(tail -3 /tmp/drill-runner-install.log | tr '\n' ' ')"
|
||||
fi
|
||||
rig runner status 2>/dev/null | grep -q "$RUNNER_REPO" \
|
||||
&& ok "runner status names the fork: $RUNNER_REPO" \
|
||||
|| no "runner status does not name $RUNNER_REPO"
|
||||
|
||||
took_job=none
|
||||
if [ "$GH_OK" -eq 1 ]; then
|
||||
# Dispatch, then poll the newest run of that workflow to completion.
|
||||
# The newest run's ID is read BEFORE dispatching, so an old completed
|
||||
# run can never be mistaken for the one just dispatched (the poll's
|
||||
# verdict must be about OUR run, and workflow_dispatch takes a few
|
||||
# seconds to materialize a run at all). ~5 min bound: a queued-forever
|
||||
# run means the runner never picked the job up, which is exactly what
|
||||
# this check exists to catch.
|
||||
pre_id="$(gh run list -R "$RUNNER_REPO" --workflow "$RUNNER_WORKFLOW" --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null)"
|
||||
if gh workflow run "$RUNNER_WORKFLOW" -R "$RUNNER_REPO" >/dev/null 2>&1; then
|
||||
inf "dispatched $RUNNER_WORKFLOW on $RUNNER_REPO — waiting for the runner to take it (≤5 min)…"
|
||||
took_job=timeout
|
||||
for _i in $(seq 1 30); do
|
||||
sleep 10
|
||||
run_line="$(gh run list -R "$RUNNER_REPO" --workflow "$RUNNER_WORKFLOW" --limit 1 \
|
||||
--json databaseId,status,conclusion --jq '.[0] | "\(.databaseId) \(.status) \(.conclusion)"' 2>/dev/null)"
|
||||
read -r rid rstatus rconc <<< "$run_line"
|
||||
[ -n "${rid:-}" ] || continue
|
||||
[ "$rid" != "${pre_id:-}" ] || continue
|
||||
if [ "${rstatus:-}" = completed ]; then
|
||||
case "${rconc:-}" in
|
||||
success) took_job=success ;;
|
||||
*) took_job=failed ;;
|
||||
esac
|
||||
break
|
||||
fi
|
||||
done
|
||||
else
|
||||
took_job=nodispatch
|
||||
fi
|
||||
case "$took_job" in
|
||||
success) ok "the runner took a job and it succeeded ($RUNNER_WORKFLOW)" ;;
|
||||
failed) no "the dispatched job completed UNSUCCESSFULLY — the runner ran it, the workflow failed; read the run on $RUNNER_REPO" ;;
|
||||
timeout) no "the dispatched job never completed within 5 min — the runner did not take it (is the workflow's runs-on label 'drill'?)" ;;
|
||||
nodispatch) no "could not dispatch $RUNNER_WORKFLOW on $RUNNER_REPO — does the fork carry it, with workflow_dispatch? (see drill/README.md)" ;;
|
||||
esac
|
||||
else
|
||||
skip "took a job: not attempted — no authenticated gh to dispatch $RUNNER_WORKFLOW with"
|
||||
fi
|
||||
|
||||
rem_token="${RUNNER_REMOVE_TOKEN:-}"
|
||||
if [ -z "$rem_token" ] && [ "$GH_OK" -eq 1 ]; then
|
||||
rem_token="$(gh api -X POST "repos/$RUNNER_REPO/actions/runners/remove-token" --jq .token 2>/dev/null)"
|
||||
fi
|
||||
if [ -n "$rem_token" ]; then
|
||||
RUNNER_REMOVE_TOKEN="$rem_token" rig runner remove >/dev/null 2>&1 \
|
||||
&& ok "rig runner remove deregistered cleanly" \
|
||||
|| no "runner remove FAILED"
|
||||
else
|
||||
rig runner remove --local >/dev/null 2>&1 \
|
||||
&& note "deregistered --local only (no removal token source) — delete the stale runner from $RUNNER_REPO's settings by hand" \
|
||||
|| no "runner remove --local FAILED"
|
||||
fi
|
||||
rig runner status >/dev/null 2>&1 \
|
||||
&& no "runner status still answers after remove — the deregistration did not take" \
|
||||
|| ok "runner status confirms: nothing registered"
|
||||
|
||||
leg "runner lifecycle ($RUNNER_REPO)" \
|
||||
"$(case "$took_job" in
|
||||
success) echo "PASS — registered, took a job, deregistered clean" ;;
|
||||
none) echo "PARTIAL — registered and deregistered; took a job: not attempted (no gh)" ;;
|
||||
*) echo "FAIL — see Failed below" ;;
|
||||
esac)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
phase "Summary"
|
||||
# =============================================================================
|
||||
printf ' %s passed, %s failed, %s skipped\n' "$pass" "$fail" "$skipped"
|
||||
if [ "${#findings[@]}" -gt 0 ]; then
|
||||
echo
|
||||
printf ' %s\n' "${findings[@]}"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$RECORD")"
|
||||
emit_record "$RECORD"
|
||||
echo
|
||||
inf "record written: $RECORD"
|
||||
inf "commit it on the release branch as drills/$DRILL_VERSION.md — the"
|
||||
inf "drill-recorded gate reads that file and nothing else (drills/README.md)."
|
||||
[ "$fail" -eq 0 ]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# rig 0.3.1 — release drill
|
||||
|
||||
**WAIVED. No real-hardware drill was run for this release.**
|
||||
|
||||
The maintainer (@danmt) directed “Add a waiver as the drill” on
|
||||
2026-07-25 at 09:02Z:
|
||||
https://github.com/heavy-duty/rig/pull/145#issuecomment-5077845754
|
||||
|
||||
The real-hardware run requested in discussion #132 was not performed. At the
|
||||
time of the ruling, the harness and procedure (`drill/drill.sh` and
|
||||
`drill/README.md`) were available on `main`, unlike for the 0.3.0 release.
|
||||
The ruling gave no reason for the waiver.
|
||||
|
|
@ -15,18 +15,16 @@ The directory is `drills/`, not `.drills/` — a dot-directory is invisible to
|
|||
any glob without `dotglob`, which is how #70 here and box#116 / box#118 all
|
||||
happened.
|
||||
|
||||
**This directory is the record, not the instrument.** The instrument is
|
||||
[`drill/drill.sh`](../drill/README.md) (#105): it runs the legs, asserts the
|
||||
pinned refs actually landed, decides idempotence by a mechanical state diff,
|
||||
and emits the record file this directory holds. rig does not reach into
|
||||
another repo's harness to decide whether rig may ship: a cross-repo lookup
|
||||
that fails silently degrades to "pass", which is the UNREADABLE-vs-NONE shape
|
||||
#90 fixed. The gate reads a file in this repo, and nothing else.
|
||||
**This directory is the record, not the instrument.** rig has **no drill
|
||||
harness script of its own**; its legs are run by following the documented
|
||||
procedure, and the harness lives in heavy-duty/box's `drill/`. rig does not
|
||||
reach into it to decide whether rig may ship: a cross-repo lookup that fails
|
||||
silently degrades to "pass", which is the UNREADABLE-vs-NONE shape #90 fixed.
|
||||
The gate reads a file in this repo, and nothing else.
|
||||
|
||||
## What the gate requires
|
||||
|
||||
The `drill-recorded` guard (heavy-duty/ceremony's action, pinned in
|
||||
`ci.yml`) runs on every PR. On a `-dev` tree it
|
||||
`.github/scripts/drill-recorded.sh` runs on every PR. On a `-dev` tree it
|
||||
asserts nothing — a development tree has no release to evidence. On a bare
|
||||
`VERSION` — a release ceremony tree — it requires `drills/<version>.md` to
|
||||
exist and to hold at least one non-whitespace character. An empty file, or one
|
||||
|
|
@ -41,16 +39,13 @@ not success.
|
|||
|
||||
## The drill
|
||||
|
||||
rig's legs (#105; `drill/drill.sh` runs them):
|
||||
rig's legs:
|
||||
|
||||
- `rig bootstrap <role>` converges the machine to its role — then runs
|
||||
**again**, and the captured state must diff **empty** (idempotence,
|
||||
decided mechanically). On a host=yes role this is also what installs the
|
||||
pinned box and asserts its host stack stands.
|
||||
- tenant guests minted and converged **via box**
|
||||
- `bash test/db-integration.sh` against a real Postgres on the machine
|
||||
- the GitHub runner lifecycle — register, take a job, deregister — against a
|
||||
fork
|
||||
- a coolify install, pinned, `AUTOUPDATE=false`
|
||||
- a coolify install
|
||||
|
||||
box and rig are **mutually recursive**: `rig bootstrap --host yes` installs box
|
||||
and runs box's `setup-host`, while box's guests converge back through rig's
|
||||
|
|
@ -63,11 +58,7 @@ schedule, in separate sittings. What makes that safe is that every drill
|
|||
**pins the same fixed set of candidate refs**: rig's drill runs `--host yes`
|
||||
with `BOX_REF=release/<box-version>`, so it exercises the box that will
|
||||
actually ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it
|
||||
exercises the rig that will actually ship. Both measure the same pair. The
|
||||
record also cites the **rig-templates SHA** the converge read (#110) — the
|
||||
candidate tree's `RIG_TEMPLATES_PIN` unless the drill was pointed elsewhere
|
||||
via `RIG_TEMPLATES_REF` — so the mechanism+registry pair a release freezes
|
||||
is the pair the drill proved.
|
||||
exercises the rig that will actually ship. Both measure the same pair.
|
||||
|
||||
That — not sequencing — is what dissolves the box↔rig recursion. The refs are
|
||||
static identifiers that exist as soon as the release branches do, long before
|
||||
|
|
@ -108,12 +99,11 @@ Candidate refs: box@1a2b3c4 (BOX_REF=release/0.4.0), rig@5d6e7f8, cast@9a0b1c2.
|
|||
|
||||
| Leg | Result |
|
||||
| --- | --- |
|
||||
| convergence — bootstrap staging-server reaches its role | PASS (312s) |
|
||||
| tenant guests minted + converged via box | 3/3 |
|
||||
| re-converge (idempotence) | clean, no changes |
|
||||
| --host yes: pinned box installed, host stack up | PASS — box doctor clean |
|
||||
| `test/db-integration.sh` | PASS — 14 passed, 0 failed |
|
||||
| `test/db-integration.sh` | 14/14 |
|
||||
| runner lifecycle against a fork | PASS — registered, took a job, deregistered clean |
|
||||
| coolify install (4.1.2) | PASS (6 min) |
|
||||
| coolify install | PASS, ~6 min |
|
||||
|
||||
Failed: `rig users apply` left one revoked key in `authorized_keys`
|
||||
(filed #NNN). Everything else clean.
|
||||
|
|
|
|||
61
install.sh
61
install.sh
|
|
@ -29,11 +29,9 @@ set -euo pipefail
|
|||
# protect, and the operator flipping versions on purpose is the common case.
|
||||
# A pre-versioning flat tree is migrated in place, so upgrading is seamless.
|
||||
#
|
||||
# RIG_INSTALL_SOURCE=<dir-or-tarball> is the LOCAL channel, a supported input
|
||||
# like RIG_REF (#106): installs from that tree instead of downloading — CI's
|
||||
# install-lifecycle job and the test suites use it, so what lands is the code
|
||||
# under review. A path that is neither refuses by name, never falls back to
|
||||
# a download.
|
||||
# RIG_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
|
||||
# downloading — for CI and the test suite, so what lands is the code under
|
||||
# review.
|
||||
|
||||
REPO="${RIG_REPO:-heavy-duty/rig}"
|
||||
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
||||
|
|
@ -249,57 +247,6 @@ set_exec() { # $1 = a rig tree: the executable bits install.sh owns
|
|||
fi
|
||||
}
|
||||
|
||||
# snapshot_templates <rig-tree> — best-effort install-time cache of the exact
|
||||
# registry pin carried by that tree. The pin remains the sole source of truth;
|
||||
# the directory name makes a stale snapshot invisible after an upgrade.
|
||||
# Failure is deliberately a warning: rig itself is still a complete install,
|
||||
# and templates_resolve preserves the live-fetch fallback.
|
||||
snapshot_templates() {
|
||||
local tree="$1" pin repo url got="" unpack top snapshot
|
||||
pin="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$tree/commands/lib/templates.sh" 2>/dev/null | head -n1 || true)"
|
||||
if [ -z "$pin" ]; then
|
||||
warn "installed tree carries no RIG_TEMPLATES_PIN; template registry snapshot skipped."
|
||||
return 0
|
||||
fi
|
||||
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||
snapshot="$tree/templates@$pin"
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
warn "curl is unavailable; template registry snapshot $repo@$pin was not installed (converge will retry the live fetch)."
|
||||
return 0
|
||||
fi
|
||||
unpack="$TMPDIR/templates-unpack"
|
||||
rm -rf "$unpack"
|
||||
mkdir -p "$unpack"
|
||||
log "downloading template registry snapshot $repo@$pin"
|
||||
for url in \
|
||||
"https://github.com/$repo/archive/refs/tags/$pin.tar.gz" \
|
||||
"https://github.com/$repo/archive/refs/heads/$pin.tar.gz" \
|
||||
"https://github.com/$repo/archive/$pin.tar.gz"; do
|
||||
if curl -fsSL "$url" -o "$TMPDIR/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
||||
done
|
||||
if [ -z "$got" ]; then
|
||||
warn "could not fetch template registry snapshot $repo@$pin; rig installed without it (converge will retry the live fetch)."
|
||||
return 0
|
||||
fi
|
||||
if ! tar -xzf "$TMPDIR/templates.tar.gz" -C "$unpack"; then
|
||||
warn "could not extract template registry snapshot from $got; rig installed without it (converge will retry the live fetch)."
|
||||
return 0
|
||||
fi
|
||||
set -- "$unpack"/*/
|
||||
if ! { [ $# -eq 1 ] && [ -d "$1" ]; }; then
|
||||
warn "template registry snapshot from $got has an unexpected archive shape; rig installed without it (converge will retry the live fetch)."
|
||||
return 0
|
||||
fi
|
||||
top="${1%/}"
|
||||
if [ -z "$(find "$top" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)" ]; then
|
||||
warn "template registry snapshot from $got has no definitions; rig installed without it (converge will retry the live fetch)."
|
||||
return 0
|
||||
fi
|
||||
rm -rf "$snapshot"
|
||||
mv "$top" "$snapshot"
|
||||
log "template registry snapshot installed: $repo@$pin"
|
||||
}
|
||||
|
||||
# --- install into $DEST/versions/<version> -----------------------------------
|
||||
VDIR="$DEST/versions/$new_ver"
|
||||
newly_installed=0
|
||||
|
|
@ -310,7 +257,6 @@ if [ -d "$VDIR" ]; then
|
|||
log "RIG_REINSTALL=1 — replacing the installed $new_ver tree"
|
||||
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
|
||||
rm -rf "$stage" "$old"
|
||||
snapshot_templates "$EXTRACTED"
|
||||
set_exec "$EXTRACTED"
|
||||
mv "$EXTRACTED" "$stage"
|
||||
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
|
||||
|
|
@ -328,7 +274,6 @@ if [ -d "$VDIR" ]; then
|
|||
else
|
||||
log "installing $new_ver into $VDIR"
|
||||
mkdir -p "$DEST/versions"
|
||||
snapshot_templates "$EXTRACTED"
|
||||
set_exec "$EXTRACTED"
|
||||
mv "$EXTRACTED" "$VDIR"
|
||||
newly_installed=1
|
||||
|
|
|
|||
759
test/cli.sh
759
test/cli.sh
|
|
@ -143,67 +143,6 @@ check "bootstrap: login verify fails closed on a stalled backend" 0 "" \
|
|||
# The marker is the traits' ground truth for rig users; assert the write exists.
|
||||
check "bootstrap: role marker write is present" 0 "" \
|
||||
grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh"
|
||||
check "bootstrap: role marker records join provenance" 0 "join-by=%s" \
|
||||
grep -F "join-by=%s" "$ROOT/commands/bootstrap.sh"
|
||||
check "bootstrap: both first-join paths record join-by=rig" 0 "2" \
|
||||
grep -c "^[[:space:]]*JOIN_BY=rig$" "$ROOT/commands/bootstrap.sh"
|
||||
check "bootstrap: already-joined path defaults to join-by=preexisting" 0 "JOIN_BY=preexisting" \
|
||||
grep -F "JOIN_BY=preexisting" "$ROOT/commands/bootstrap.sh"
|
||||
|
||||
# Drive the narrow inverse end to end. Every refusal also asserts the tailscale
|
||||
# shim was NOT called: exit status alone would miss the destructive regression.
|
||||
UNDO_FIX="$(mktemp -d)"
|
||||
UNDO_BIN="$UNDO_FIX/bin"
|
||||
UNDO_MARKER="$UNDO_FIX/role"
|
||||
UNDO_RUNNER="$UNDO_FIX/runner"
|
||||
UNDO_CALLS="$UNDO_FIX/tailscale.calls"
|
||||
mkdir -p "$UNDO_BIN" "$UNDO_RUNNER"
|
||||
cat > "$UNDO_BIN/tailscale" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" >> "$UNDO_CALLS"
|
||||
if [ "${TAILSCALE_LOGOUT_FAIL:-0}" = 1 ]; then exit 1; fi
|
||||
SH
|
||||
cat > "$UNDO_BIN/id" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
if [ "${1:-}" = -u ]; then printf '0\n'; else exec /usr/bin/id "$@"; fi
|
||||
SH
|
||||
chmod +x "$UNDO_BIN/tailscale" "$UNDO_BIN/id"
|
||||
undo() {
|
||||
env PATH="$UNDO_BIN:$PATH" UNDO_CALLS="$UNDO_CALLS" \
|
||||
RIG_ROLE_MARKER="$UNDO_MARKER" RIG_RUNNER_DIR="$UNDO_RUNNER" \
|
||||
"$ROOT/bin/rig" bootstrap --undo
|
||||
}
|
||||
undo_untouched() {
|
||||
: > "$UNDO_CALLS"
|
||||
if undo >"$UNDO_FIX/undo.out" 2>&1; then return 1; fi
|
||||
[ ! -s "$UNDO_CALLS" ]
|
||||
}
|
||||
rm -f "$UNDO_MARKER"
|
||||
check "bootstrap --undo: no marker refuses without touching tailnet" 0 "" undo_untouched
|
||||
printf '%s\n' 'role=workload-server root-door=open host=no join=authkey' > "$UNDO_MARKER"
|
||||
check "bootstrap --undo: old marker names missing provenance" \
|
||||
1 "marker predates join-by provenance" undo
|
||||
check "bootstrap --undo: old marker leaves tailnet untouched" 0 "" undo_untouched
|
||||
printf '%s\n' 'role=workload-server root-door=open host=no join=authkey join-by=preexisting' > "$UNDO_MARKER"
|
||||
check "bootstrap --undo: pre-existing join refuses by name" 1 "join-by=preexisting" undo
|
||||
check "bootstrap --undo: pre-existing join leaves tailnet untouched" 0 "" undo_untouched
|
||||
printf '%s\n' 'role=runner-server root-door=open host=no join=authkey join-by=rig' > "$UNDO_MARKER"
|
||||
printf '%s\n' '{}' > "$UNDO_RUNNER/.runner"
|
||||
check "bootstrap --undo: installed runner points at its removal verb" \
|
||||
1 "rig runner remove" undo
|
||||
check "bootstrap --undo: installed runner leaves tailnet untouched" 0 "" undo_untouched
|
||||
rm -f "$UNDO_RUNNER/.runner"
|
||||
check "bootstrap --undo: failed logout is loud" \
|
||||
1 "role marker kept" env TAILSCALE_LOGOUT_FAIL=1 PATH="$UNDO_BIN:$PATH" \
|
||||
UNDO_CALLS="$UNDO_CALLS" RIG_ROLE_MARKER="$UNDO_MARKER" \
|
||||
RIG_RUNNER_DIR="$UNDO_RUNNER" "$ROOT/bin/rig" bootstrap --undo
|
||||
check "bootstrap --undo: failed logout preserves the marker" 0 "" test -e "$UNDO_MARKER"
|
||||
: > "$UNDO_CALLS"
|
||||
check "bootstrap --undo: proven rig join succeeds" 0 "tailnet join removed" undo
|
||||
check "bootstrap --undo: successful logout was called" 0 "logout" cat "$UNDO_CALLS"
|
||||
check "bootstrap --undo: success removes the marker" 1 "" test -e "$UNDO_MARKER"
|
||||
check "bootstrap --undo: second run refuses cleanly" 1 "no /etc/rig/role marker" undo
|
||||
rm -rf "$UNDO_FIX"
|
||||
# ...and that it is written in the CURRENT vocabulary (#77). New markers say
|
||||
# root-door=; the retired class= spelling is something rig READS forever and
|
||||
# WRITES never, so a marker line that reintroduces it must not ship green.
|
||||
|
|
@ -226,28 +165,10 @@ check "bootstrap: box install is guarded on host=yes" 0 "" \
|
|||
grep -qxE 'if \[ "\$HOST" = "yes" \]; then' "$ROOT/commands/bootstrap.sh"
|
||||
# It runs box's OWN global installer with BOX_YES=1 (non-interactive AND keeps
|
||||
# setup-host, so box builds Incus rather than only dropping the CLI on PATH).
|
||||
# shellcheck disable=SC2016
|
||||
check "bootstrap: box install runs box's installer non-interactively" 0 "" \
|
||||
grep -q 'BOX_YES=1 BOX_REF="$BOX_REF" bash' "$ROOT/commands/bootstrap.sh"
|
||||
# The default is a released semver pin carried in rig's tree, never a moving
|
||||
# branch. BOX_REF remains an override so explicit main and release-branch refs
|
||||
# still work for development and pre-release drills.
|
||||
box_release="$(sed -n 's/^[[:space:]]*BOX_RELEASE=//p' "$ROOT/commands/bootstrap.sh")"
|
||||
check "bootstrap: box default is a released semver pin, not a moving ref" 0 "" \
|
||||
grep -qxE '[0-9]+\.[0-9]+\.[0-9]+' <<<"$box_release"
|
||||
# shellcheck disable=SC2016
|
||||
check "bootstrap: BOX_REF overrides the released default" 0 "" \
|
||||
grep -qF 'BOX_REF="${BOX_REF:-$BOX_RELEASE}"' "$ROOT/commands/bootstrap.sh"
|
||||
# Fetching the installer at BOX_REF is only the first pin: box's installer
|
||||
# independently resolves what it installs, so the ref must cross the pipe too.
|
||||
# shellcheck disable=SC2016
|
||||
check "bootstrap: box install passes BOX_REF through the installer pipe" 0 "" \
|
||||
grep -qF 'BOX_YES=1 BOX_REF="$BOX_REF" bash' "$ROOT/commands/bootstrap.sh"
|
||||
# The same pinned command is operators' recovery path on every skip/failure.
|
||||
# shellcheck disable=SC2016
|
||||
check "bootstrap: manual box install carries the pinned ref" 0 "" \
|
||||
grep -qF 'BOX_YES=1 BOX_REF=${BOX_REF} bash' "$ROOT/commands/bootstrap.sh"
|
||||
check "bootstrap: box repository remains pinnable" 0 "" \
|
||||
grep -q "BOX_YES=1 bash" "$ROOT/commands/bootstrap.sh"
|
||||
# Pin points: BOX_REPO / BOX_REF override the source, default heavy-duty/box@main.
|
||||
check "bootstrap: box source is pinnable, defaults to heavy-duty/box@main" 0 "" \
|
||||
grep -qF 'BOX_REPO:-heavy-duty/box' "$ROOT/commands/bootstrap.sh"
|
||||
# Opt-out for rehearsals / offline / hand-managed hosts.
|
||||
check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \
|
||||
|
|
@ -266,8 +187,7 @@ check "bootstrap: rig never apt-installs incus (box owns the daemon)" 1 "" \
|
|||
# $MARKER_TMP is a literal we grep for in the script — single quotes intended.
|
||||
# shellcheck disable=SC2016
|
||||
box_marker_at="$(grep -n 'install -m 0644 "$MARKER_TMP"' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
|
||||
# shellcheck disable=SC2016
|
||||
box_install_at="$(grep -n 'BOX_YES=1 BOX_REF="$BOX_REF" bash' "$ROOT/commands/bootstrap.sh" | tail -n1 | cut -d: -f1)"
|
||||
box_install_at="$(grep -n 'BOX_YES=1 bash' "$ROOT/commands/bootstrap.sh" | grep -v 'BOX_MANUAL=' | tail -n1 | cut -d: -f1)"
|
||||
check "bootstrap: box install runs after the role marker write" \
|
||||
0 "" test "${box_marker_at:-999999}" -lt "${box_install_at:-0}"
|
||||
# On the skip/failure paths, keep pointing operators at the manual command so a
|
||||
|
|
@ -572,69 +492,19 @@ else
|
|||
echo "skip: bootstrap non-root refusals (running as root)"
|
||||
fi
|
||||
|
||||
# --- box tenant roles (#31/#76/#110): <role>-box from the registry + staging-box ---
|
||||
# --- box tenant roles (#31/#76): claude-box|codex-box|grok-box|staging-box ---
|
||||
# What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh),
|
||||
# parameterized per DEFINITION fetched from the template registry
|
||||
# (heavy-duty/rig-templates; lib/templates.sh resolves RIG_TEMPLATES_DIR >
|
||||
# RIG_TEMPLATES_REF > the in-tree pin), dispatched from bootstrap.sh on the
|
||||
# '-box' FAMILY SUFFIX so `rig bootstrap <role>` stays the single entrypoint
|
||||
# and a template added to the registry is mintable with zero code changes
|
||||
# here. The real converge needs root, a tenant user, and the network — the
|
||||
# container rehearsal's job — so the harness proves what it can non-root and
|
||||
# OFFLINE: the whole refusal surface, the resolution precedence, the parser
|
||||
# and the renderer, against fixture definitions via RIG_TEMPLATES_DIR.
|
||||
|
||||
# The fixture registry: one valid scratch definition, plus broken ones the
|
||||
# parser must refuse BY NAME. Synthetic on purpose — the real definitions
|
||||
# live in heavy-duty/rig-templates, and this suite must hold whatever those
|
||||
# say (offline is the point: no fetch, no network, no coupling).
|
||||
TPL_FIX="$(mktemp -d)"
|
||||
mkdir -p "$TPL_FIX/scratch-box"
|
||||
cat > "$TPL_FIX/scratch-box/template.env" <<'TPLEOF'
|
||||
# comments and blank lines are the only non-KEY="value" grammar
|
||||
|
||||
USER="scratch"
|
||||
CONTEXT_PATH=".scratch/AGENTS.md"
|
||||
CLI_NAME="scratch"
|
||||
CLI_SRC="~/.local/bin/scratch"
|
||||
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
|
||||
NEEDS_NODE="no"
|
||||
APT_EXTRAS="zsh"
|
||||
TPLEOF
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$TPL_FIX/scratch-box/install.sh"
|
||||
printf -- '- **Creds-free by default.** The scratch vendor paragraph.\n' > "$TPL_FIX/scratch-box/creds.md"
|
||||
mkdir -p "$TPL_FIX/badkey-box"
|
||||
printf 'USER="x"\nCOLOR="red"\n' > "$TPL_FIX/badkey-box/template.env"
|
||||
mkdir -p "$TPL_FIX/missing-box"
|
||||
printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nPATH_LINE="p"\n' > "$TPL_FIX/missing-box/template.env"
|
||||
mkdir -p "$TPL_FIX/garbled-box"
|
||||
printf 'USER=unquoted\n' > "$TPL_FIX/garbled-box/template.env"
|
||||
mkdir -p "$TPL_FIX/badnode-box"
|
||||
printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nCLI_NAME="x"\nPATH_LINE="p"\nNEEDS_NODE="maybe"\n' > "$TPL_FIX/badnode-box/template.env"
|
||||
mkdir -p "$TPL_FIX/badapt-box"
|
||||
printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nCLI_NAME="x"\nPATH_LINE="p"\nAPT_EXTRAS="zsh -o"\n' > "$TPL_FIX/badapt-box/template.env"
|
||||
mkdir -p "$TPL_FIX/scratch-server"
|
||||
printf 'ROOT_DOOR="closed"\nHOST="no"\nJOIN="login"\n' > "$TPL_FIX/scratch-server/template.env"
|
||||
mkdir -p "$TPL_FIX/workstation"
|
||||
printf 'ROOT_DOOR="closed"\nHOST="yes"\nJOIN="authkey"\n' > "$TPL_FIX/workstation/template.env"
|
||||
mkdir -p "$TPL_FIX/baddoor-server"
|
||||
printf 'ROOT_DOOR="ajar"\nHOST="no"\nJOIN="authkey"\n' > "$TPL_FIX/baddoor-server/template.env"
|
||||
mkdir -p "$TPL_FIX/tenantkeys-server"
|
||||
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/tenantkeys-server/template.env"
|
||||
mkdir -p "$TPL_FIX/machinekeys-box"
|
||||
cp "$TPL_FIX/scratch-server/template.env" "$TPL_FIX/machinekeys-box/template.env"
|
||||
mkdir -p "$TPL_FIX/creds-server"
|
||||
cp "$TPL_FIX/scratch-server/template.env" "$TPL_FIX/creds-server/template.env"
|
||||
printf 'not used\n' > "$TPL_FIX/creds-server/creds.md"
|
||||
mkdir -p "$TPL_FIX/noshebang-server"
|
||||
cp "$TPL_FIX/scratch-server/template.env" "$TPL_FIX/noshebang-server/template.env"
|
||||
printf 'exit 0\n' > "$TPL_FIX/noshebang-server/install.sh"
|
||||
|
||||
# parameterized per tenant through lib/tenant-config.sh, dispatched from
|
||||
# bootstrap.sh so `rig bootstrap <role>` stays the single entrypoint. The real
|
||||
# converge needs root, a tenant user, and the network — the container
|
||||
# rehearsal's job — so the harness proves what it can non-root: the whole
|
||||
# arg/refusal surface, the pure parameter table, the rendered agent-context
|
||||
# file (guard note included), and grep-pins on the shipped script.
|
||||
# THE HARD CUT, tenant half (#76). The pre-rename names are gone and must fail
|
||||
# as UNKNOWN — asserted per name, because an alias left in for one tenant is the
|
||||
# shape that survives review. And the #110 cut on top: the mechanism no longer
|
||||
# KNOWS any agent tenant by name — which '-box' roles exist is the registry's
|
||||
# fact, so the old names die on the family-suffix rule, not an enumerated list.
|
||||
# shape that survives review: the taxonomy reads complete while one old name
|
||||
# still quietly converges. Checked at BOTH entrypoints, since bootstrap.sh has
|
||||
# its own dispatch list and a name could survive in one and not the other.
|
||||
for r in claude codex grok staging; do
|
||||
check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" "$r"
|
||||
|
|
@ -643,171 +513,39 @@ for r in claude codex grok staging; do
|
|||
done
|
||||
check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help
|
||||
check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "tenant: a suffix-less role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato
|
||||
check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato
|
||||
check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude-box --nope
|
||||
check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user
|
||||
check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user 'fo|o'
|
||||
# The suffix rule admits ANY '-box' name, so the charset gate must catch a
|
||||
# crafted one BEFORE it is used as a path component (the valid_version
|
||||
# discipline): uppercase, dots, a leading '-' all die at the name, never in a
|
||||
# registry lookup.
|
||||
check "tenant: a crafted role name dies at the charset gate" 2 "invalid tenant role name" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" 'UPPER-box'
|
||||
# The docker converge asserts the DAEMON answers, not just the client binary —
|
||||
# a dead dockerd passing `docker --version` is the "linked but cannot run"
|
||||
# scar in daemon form. Grep-pinned so the assert cannot ship deleted.
|
||||
check "tenant: dockerd effective-state assert is present" 0 "" \
|
||||
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
|
||||
# The #162 contract, both halves: cron installs with the agent toolbelt (the
|
||||
# duty engine's unprivileged installer can never apt-get it), and PATH is not
|
||||
# the effective state — the service must be asserted enabled AND active, or a
|
||||
# masked daemon leaves every tenant crontab silently inert.
|
||||
check "tenant: cron rides the agent toolbelt install" 0 "" \
|
||||
grep -qE '^ *apt-get install .* cron ' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "tenant: crontab toolbelt assert is present" 0 "" \
|
||||
grep -qF "command -v crontab" "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "tenant: cron.service enabled assert is present" 0 "" \
|
||||
grep -qF "systemctl is-enabled cron" "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "tenant: cron.service active assert is present" 0 "" \
|
||||
grep -qF "systemctl is-active cron" "$ROOT/commands/bootstrap-tenant.sh"
|
||||
|
||||
# The converge is exercised, not argued about (the drop_incus precedent):
|
||||
# converge_cron is lifted out of the real file verbatim — column-0
|
||||
# 'converge_cron() {' through column-0 '}' — and driven against a stub
|
||||
# systemctl whose effective state lives in files. The extraction is asserted
|
||||
# first: if that shape ever changes the lift comes back empty and every case
|
||||
# below fails loudly rather than passing vacuously.
|
||||
CRON_FN="$(sed -n '/^converge_cron() {/,/^}/p' "$ROOT/commands/bootstrap-tenant.sh")"
|
||||
# shellcheck disable=SC2016 # $1 is the inner bash -c's positional, deliberately
|
||||
check "tenant: converge_cron lifts out of the real file whole" 0 "" \
|
||||
bash -c '[ -n "$1" ] && printf %s "$1" | grep -q "^}$"' _ "$CRON_FN"
|
||||
# ...and the function must actually be CALLED — a lifted-and-driven function
|
||||
# nobody invokes proves nothing about bootstrap.
|
||||
check "tenant: converge_cron is invoked" 0 "" \
|
||||
grep -qE '^ *converge_cron$' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
|
||||
CRON_BASH="$(command -v bash)"
|
||||
# drive_cron <noop|converge|masked|deadstart> — the real converge_cron against
|
||||
# a stub systemctl. Effective state is files: 'enabled'/'active' existing means
|
||||
# the probe passes. 'noop': both preexist — the idempotent re-run. 'converge':
|
||||
# neither, and enable/start take effect. 'masked': neither, and enable/start do
|
||||
# NOTHING — the unrecoverably-inert daemon #162 is about. 'deadstart': enabled,
|
||||
# but start never takes. The stub logs its calls to a file: the real call sites
|
||||
# are '>/dev/null 2>&1', so a stub that spoke on either stream would be
|
||||
# silenced and the call assertions below would pass vacuously.
|
||||
drive_cron() {
|
||||
local mode="$1" d
|
||||
d="$(mktemp -d)"
|
||||
mkdir -p "$d/bin"
|
||||
case "$mode" in noop) : > "$d/enabled"; : > "$d/active" ;; deadstart) : > "$d/enabled" ;; esac
|
||||
# The stub restores a real PATH for itself: the caller's PATH is REPLACED by
|
||||
# the stub dir (that is what keeps a host systemctl out of reach), which
|
||||
# would otherwise leave the stub unable to find 'echo' as an executable.
|
||||
cat > "$d/bin/systemctl" <<EOF
|
||||
#!/bin/sh
|
||||
PATH=/usr/bin:/bin
|
||||
echo "systemctl \$*" >> "$d/calls"
|
||||
case "\$1" in
|
||||
is-enabled) [ -e "$d/enabled" ] ;;
|
||||
is-active) [ -e "$d/active" ] ;;
|
||||
enable) case "$mode" in noop|converge) : > "$d/enabled" ;; esac ;;
|
||||
start) case "$mode" in noop|converge) : > "$d/active" ;; esac ;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$d/bin/systemctl"
|
||||
# The driving shell mirrors the real script: same set flags, same log/die.
|
||||
# shellcheck disable=SC2016 # $*/$1/$2 resolve inside the driving shell
|
||||
PATH="$d/bin" "$CRON_BASH" -c '
|
||||
set -euo pipefail
|
||||
log() { printf "rig-bootstrap: %s\n" "$*"; }
|
||||
die() { printf "rig-bootstrap: ERROR: %s\n" "$1" >&2; exit "${2:-1}"; }
|
||||
'"$CRON_FN"'
|
||||
converge_cron' 2>&1
|
||||
echo "RC=$?"
|
||||
cat "$d/calls" 2>/dev/null
|
||||
rm -rf "$d"
|
||||
}
|
||||
CRON_NOOP="$(drive_cron noop)"
|
||||
CRON_CONV="$(drive_cron converge)"
|
||||
CRON_MASK="$(drive_cron masked)"
|
||||
CRON_DEAD="$(drive_cron deadstart)"
|
||||
cron_has() { printf '%s' "$1" | grep -qF -e "$2"; } # cron_has <captured> <substr>
|
||||
|
||||
# The idempotent re-run: both probes already pass, NOTHING is converged and
|
||||
# nothing dies — a second bootstrap must not touch the unit.
|
||||
check "converge_cron: already enabled+active exits 0" 0 "" cron_has "$CRON_NOOP" "RC=0"
|
||||
check "converge_cron: the no-op never calls unmask" 1 "" cron_has "$CRON_NOOP" "systemctl unmask"
|
||||
check "converge_cron: the no-op never calls enable" 1 "" cron_has "$CRON_NOOP" "systemctl enable"
|
||||
check "converge_cron: the no-op never calls start" 1 "" cron_has "$CRON_NOOP" "systemctl start"
|
||||
# The converge path: a disabled, stopped unit is unmasked, enabled, started —
|
||||
# and the asserts then pass on systemd's own answer, exit 0.
|
||||
check "converge_cron: disabled+inactive converges, exits 0" 0 "" cron_has "$CRON_CONV" "RC=0"
|
||||
check "converge_cron: the converge unmasks" 0 "" cron_has "$CRON_CONV" "systemctl unmask cron"
|
||||
check "converge_cron: the converge enables" 0 "" cron_has "$CRON_CONV" "systemctl enable cron"
|
||||
check "converge_cron: the converge starts" 0 "" cron_has "$CRON_CONV" "systemctl start cron"
|
||||
# The log states the probe fact, never a success it did not verify.
|
||||
check "converge_cron: the log states the probe fact" 0 "" \
|
||||
cron_has "$CRON_CONV" "cron.service not enabled — converging"
|
||||
# THE #162 FAILURE: a converge that does not take effect DIES, nonzero, naming
|
||||
# cron — never a silent success wrapping an inert timer.
|
||||
check "converge_cron: an unrecoverable unit dies nonzero" 0 "" cron_has "$CRON_MASK" "RC=1"
|
||||
check "converge_cron: the death names the enabled assert" 0 "" \
|
||||
cron_has "$CRON_MASK" "cron.service is not enabled after converge"
|
||||
check "converge_cron: the death cites #162" 0 "" cron_has "$CRON_MASK" "#162"
|
||||
check "converge_cron: the dying path tried to converge first" 0 "" \
|
||||
cron_has "$CRON_MASK" "systemctl unmask cron"
|
||||
# Enabled but the start never takes: the ACTIVE assert dies — enabled alone
|
||||
# is not an armed timer.
|
||||
check "converge_cron: enabled-but-dead start dies nonzero" 0 "" cron_has "$CRON_DEAD" "RC=1"
|
||||
check "converge_cron: that death names the active assert" 0 "" \
|
||||
cron_has "$CRON_DEAD" "cron.service is not active after converge"
|
||||
# The machine-role traits die with the tenant story, never "unknown flag" — an
|
||||
# operator coming from the machine families needs the boundary, not a shrug.
|
||||
# operator reaching for --hostname must learn where the trait family went.
|
||||
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed
|
||||
check "tenant: --hostname dies the same way" 2 "have no traits" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest
|
||||
# Dispatch: the machine-role entrypoint hands ANY '-box' role to the tenant
|
||||
# mechanism on the family suffix — enumerating them would re-chain template
|
||||
# velocity to rig edits, the exact coupling #110 removes.
|
||||
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "Box TENANT roles" \
|
||||
# Dispatch: the machine-role entrypoint hands tenant roles to the tenant
|
||||
# mechanism with args intact (--help reaching the TENANT usage proves both).
|
||||
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|codex-box|grok-box|staging-box" \
|
||||
"$ROOT/commands/bootstrap.sh" claude-box --help
|
||||
check "bootstrap: an unheard-of '-box' role still dispatches (zero code changes)" 0 "Box TENANT roles" \
|
||||
"$ROOT/commands/bootstrap.sh" scratch-box --help
|
||||
|
||||
# Machine roles use the same resolved registry but remain table-compatible:
|
||||
# loading happens before flag parsing, so an explicit flag overrides the
|
||||
# definition exactly as it overrides a built-in row.
|
||||
check "machine template: traits load from the local registry" 2 "join=login" \
|
||||
env RIG_TEMPLATES_DIR="$TPL_FIX" TS_AUTHKEY=x \
|
||||
"$ROOT/commands/bootstrap.sh" scratch-server --no-users
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
check "machine template: a flag overrides the loaded trait" 1 "must run as root" \
|
||||
env RIG_TEMPLATES_DIR="$TPL_FIX" TS_AUTHKEY=x \
|
||||
"$ROOT/commands/bootstrap.sh" scratch-server --no-users --join authkey
|
||||
fi
|
||||
check "machine template: invalid ROOT_DOOR is refused by key" 2 "ROOT_DOOR" \
|
||||
env RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap.sh" baddoor-server --no-users
|
||||
check "machine template: unknown role lists machine definitions" 2 "scratch-server" \
|
||||
env RIG_TEMPLATES_DIR="$TPL_FIX" "$ROOT/commands/bootstrap.sh" absent-server
|
||||
check "machine template: unknown role names the resolved source" 2 "RIG_TEMPLATES_DIR" \
|
||||
env RIG_TEMPLATES_DIR="$TPL_FIX" "$ROOT/commands/bootstrap.sh" absent-server
|
||||
check "machine template: a registry role cannot shadow a built-in" 2 "unset TS_AUTHKEY" \
|
||||
env RIG_TEMPLATES_DIR="$TPL_FIX" TS_AUTHKEY=x \
|
||||
"$ROOT/commands/bootstrap.sh" workstation --no-users
|
||||
|
||||
# The tenant marker guard (#83), against marker FIXTURES (never the harness
|
||||
# machine's real /etc/rig/role): converging a tenant onto a machine-role box or a
|
||||
# The marker guard fires BEFORE the root check (repo precedent: the coolify
|
||||
# marker warning), so the refusals are provable here off fixture markers. A
|
||||
# VM host (host=yes) refuses for every tenant — and names the staging PAIR,
|
||||
# because whoever hits it has the halves confused: the guest (staging-box), the metal
|
||||
# because whoever lands here has the two halves confused and wants the metal
|
||||
# (staging-server). An agent tenant refuses ANY machine-role box; staging-box
|
||||
# tolerates exactly the workload-joined guest (root-door=open host=no) and refuses the
|
||||
# rest. These need no registry: the guards run before the resolution, so a
|
||||
# poisoning converge is refused even when the registry is unreachable.
|
||||
# tolerates ONLY root-door=open with host=no — that is the guest after its
|
||||
# operator-run workload join, and re-converging it is what convergence is for.
|
||||
# A closed-door machine (root-door=closed via custom) is NOT that guest, and
|
||||
# open-door hardening would die at it with root-door=open-specific messaging —
|
||||
# refuse instead.
|
||||
TEN_FIX="$(mktemp -d)"
|
||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine"
|
||||
printf 'role=custom root-door=closed host=no join=authkey\n' > "$TEN_FIX/closed"
|
||||
printf 'role=staging-server root-door=open host=yes join=authkey\n' > "$TEN_FIX/host"
|
||||
printf 'role=workload class=server\n' > "$TEN_FIX/pre77-machine"
|
||||
printf 'role=dev class=human\n' > "$TEN_FIX/pre77-human"
|
||||
printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$TEN_FIX/host"
|
||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine"
|
||||
printf 'role=custom root-door=closed host=no join=login\n' > "$TEN_FIX/closed"
|
||||
printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant"
|
||||
check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
|
|
@ -817,69 +555,30 @@ check "tenant: the host refusal sends you to the metal half of the pair" 1 "stag
|
|||
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
|
||||
# The tenant guard's compat read (#77). This guard asks "does this marker name
|
||||
# a root-door policy?" through the resolver, so the pre-#77 spelling counts —
|
||||
# pattern-matching one spelling would fail OPEN here: the marker stops looking
|
||||
# like a machine's, the refusal never fires, and a tenant converge clobbers a
|
||||
# live fleet box's marker.
|
||||
# a root-door policy?" as its proxy for "is this a real fleet machine?", and it
|
||||
# must ask it in BOTH vocabularies. Kept deliberately at the retired spelling,
|
||||
# same reason as the close-root fixtures below: a pre-#77 box that stops
|
||||
# looking like a machine here is the fail-OPEN direction of this rename — the
|
||||
# agent-tenant refusal never fires, and `rig bootstrap claude-box` converges a
|
||||
# tenant straight over a live fleet box, clobbering the marker that holds its
|
||||
# root-door policy. Do not modernize these two fixtures.
|
||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/pre77-machine"
|
||||
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/pre77-human"
|
||||
check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
# ...and the guard needs no registry: an unreachable RIG_TEMPLATES_DIR must
|
||||
# not stop a refusal that protects a live fleet box.
|
||||
check "tenant: the marker guard fires even with the registry unreachable" 1 "never tailnet machines" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/machine" RIG_TEMPLATES_DIR=/nonexistent/registry \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
|
||||
# The definition surface (#110), offline via RIG_TEMPLATES_DIR. An unknown
|
||||
# role's refusal LISTS what the resolved source actually contains and names
|
||||
# the source — a misconfigured RIG_TEMPLATES_REPO/_REF/_DIR must be visible
|
||||
# in the error rather than looking like a typo.
|
||||
check "tenant: unknown role lists the resolved registry" 2 "scratch-box" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
|
||||
check "tenant: the unknown-role refusal names the source" 2 "RIG_TEMPLATES_DIR" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
|
||||
check "tenant: an unreadable RIG_TEMPLATES_DIR refuses loudly" 2 "not a directory" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
|
||||
# _DIR outranks _REF: with both set, resolution must not touch the network —
|
||||
# provable offline exactly because a fetch attempt would fail here.
|
||||
check "tenant: RIG_TEMPLATES_DIR outranks RIG_TEMPLATES_REF" 2 "scratch-box" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" RIG_TEMPLATES_REF=some-branch \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
|
||||
# A malformed definition is refused at bootstrap BY KEY (the box.env
|
||||
# discipline: parsed, never sourced — so a template cannot execute arbitrary
|
||||
# shell through the data file). The registry CI's lint is the other gate;
|
||||
# this one protects a mint served through a source CI never saw.
|
||||
check "tenant: an unknown key is refused by name" 2 "unknown key: COLOR" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" badkey-box
|
||||
check "tenant: a missing required key is refused by name" 2 "missing required key: CLI_NAME" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" missing-box
|
||||
check "tenant: a non-KEY=\"value\" line is refused with its line number" 2 'not KEY="value"' \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" garbled-box
|
||||
check "tenant: a bad NEEDS_NODE value is refused by key" 2 "NEEDS_NODE" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" badnode-box
|
||||
check "tenant: an option riding APT_EXTRAS is refused by key" 2 "APT_EXTRAS" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" badapt-box
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before
|
||||
# the root check, and the harness machine may carry a real /etc/rig/role.
|
||||
# Reaching the root check proves the whole pre-root surface passed: the
|
||||
# name, the flags, the guard, the resolution AND the parse.
|
||||
check "tenant: a valid definition parses, refuses non-root" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
|
||||
check "tenant: staging-box needs no registry at all" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
check "tenant: claude-box parses, refuses non-root" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
check "tenant: codex-box parses, refuses non-root" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex-box
|
||||
check "tenant: grok-box parses, refuses non-root" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box
|
||||
check "tenant: staging-box tolerates a workload-joined guest's marker" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
# ...and the same guest joined before #77: reaching the root check (rather
|
||||
|
|
@ -887,190 +586,49 @@ if [ "$(id -u)" -ne 0 ]; then
|
|||
check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/tenant" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
|
||||
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||
else
|
||||
echo "skip: tenant non-root refusals (running as root)"
|
||||
fi
|
||||
rm -rf "$TEN_FIX"
|
||||
|
||||
# The same definition served from a REF (tarball fetch, curl stubbed — the
|
||||
# release.sh discipline) and from a local DIR must resolve to identical
|
||||
# converge inputs: the parsed TPL_* table and the rendered context file are
|
||||
# everything the mechanism consumes, so identical inputs ARE the identical
|
||||
# converge (#110's acceptance criterion, provable offline).
|
||||
TPL_WORK="$(mktemp -d)"
|
||||
mkdir -p "$TPL_WORK/bin" "$TPL_WORK/stage/rig-templates-testref"
|
||||
cp -r "$TPL_FIX"/. "$TPL_WORK/stage/rig-templates-testref/"
|
||||
tar -czf "$TPL_WORK/reg.tar.gz" -C "$TPL_WORK/stage" rig-templates-testref
|
||||
cat > "$TPL_WORK/bin/curl" <<'CURLEOF'
|
||||
#!/usr/bin/env bash
|
||||
# stub: invoked as `curl -fsSL <url> -o <out>` by templates_resolve
|
||||
echo "$2" >> "${CURL_LOG:?}"
|
||||
cp "${CURL_TARBALL:?}" "$4"
|
||||
CURLEOF
|
||||
chmod +x "$TPL_WORK/bin/curl"
|
||||
# Single quotes deliberate throughout (SC2016): the $-expressions expand in
|
||||
# the INNER bash, against the sourced lib's state, never in the harness.
|
||||
# shellcheck disable=SC2016
|
||||
tpl_inputs_script='set -euo pipefail
|
||||
. "$1/commands/lib/templates.sh"
|
||||
templates_resolve
|
||||
template_parse_env "$REGISTRY_DIR/scratch-box/template.env"
|
||||
printf "USER=%s|CTX=%s|CLI=%s|SRC=%s|PATH=%s|NODE=%s|APT=%s\n" \
|
||||
"$TPL_USER" "$TPL_CONTEXT_PATH" "$TPL_CLI_NAME" "$TPL_CLI_SRC" \
|
||||
"$TPL_PATH_LINE" "$TPL_NEEDS_NODE" "$TPL_APT_EXTRAS"
|
||||
render_tenant_context scratch-box "$REGISTRY_DIR/scratch-box/creds.md"'
|
||||
tpl_from_dir() { # tpl_from_dir <outfile> — the local-folder path
|
||||
env RIG_TEMPLATES_DIR="$TPL_FIX" bash -c "$tpl_inputs_script" _ "$ROOT" > "$1"
|
||||
}
|
||||
tpl_from_ref() { # tpl_from_ref <outfile> — the tarball path, curl stubbed
|
||||
env PATH="$TPL_WORK/bin:$PATH" CURL_LOG="$TPL_WORK/curl.log" \
|
||||
CURL_TARBALL="$TPL_WORK/reg.tar.gz" RIG_TEMPLATES_REF=testref \
|
||||
bash -c "$tpl_inputs_script" _ "$ROOT" > "$1"
|
||||
}
|
||||
check "templates: a local DIR resolves and parses" 0 "" tpl_from_dir "$TPL_WORK/from-dir"
|
||||
check "templates: a REF resolves through the tarball fetch (stubbed curl)" 0 "" \
|
||||
tpl_from_ref "$TPL_WORK/from-ref"
|
||||
check "templates: DIR and REF yield byte-identical converge inputs" 0 "" \
|
||||
diff "$TPL_WORK/from-dir" "$TPL_WORK/from-ref"
|
||||
# The fetch's first candidate is refs/tags — a tag must outrank a branch that
|
||||
# happens to share its name (install.sh's own precedence, the pin must win).
|
||||
check "templates: the fetch asks refs/tags first" 0 "/archive/refs/tags/testref.tar.gz" \
|
||||
head -n1 "$TPL_WORK/curl.log"
|
||||
check "templates: the rendered context carries the box#80 guard" 0 "box setup-host" \
|
||||
cat "$TPL_WORK/from-dir"
|
||||
check "templates: the guard says whose host this is not" 0 "not a host you own" \
|
||||
cat "$TPL_WORK/from-dir"
|
||||
check "templates: the guard cites box#80" 0 "box#80" cat "$TPL_WORK/from-dir"
|
||||
check "templates: the definition's creds paragraph is spliced in" 0 "The scratch vendor paragraph" \
|
||||
cat "$TPL_WORK/from-dir"
|
||||
check "templates: the bootstrap runbook note survives the split" 0 "Bootstrap runbook" \
|
||||
cat "$TPL_WORK/from-dir"
|
||||
# The default ref is the IN-TREE PIN (the BOX_RELEASE discipline, ruled on
|
||||
# #110: pinned, not main-tracked): exactly one greppable assignment, so a pin
|
||||
# bump is a one-line PR and the drill can read the pin from an installed tree.
|
||||
# shellcheck disable=SC2016
|
||||
check "templates: the pin is one greppable line" 0 "1" \
|
||||
bash -c 'grep -c "^RIG_TEMPLATES_PIN=" "$1/commands/lib/templates.sh"' _ "$ROOT"
|
||||
# shellcheck disable=SC2016
|
||||
check "templates: unset knobs fall back to the pin" 0 "the in-tree pin" \
|
||||
bash -c '. "$1/commands/lib/templates.sh" && templates_source_desc' _ "$ROOT"
|
||||
|
||||
# The installed snapshot is found relative to templates.sh itself, so exercise
|
||||
# it in a copied rig tree: no fixture-only path knob can accidentally make the
|
||||
# production precedence pass. Poisoned curl makes any network attempt fatal.
|
||||
mkdir -p "$TPL_WORK/rig/commands/lib"
|
||||
cp "$ROOT/commands/lib/templates.sh" "$TPL_WORK/rig/commands/lib/templates.sh"
|
||||
TPL_PIN="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$ROOT/commands/lib/templates.sh")"
|
||||
cp -r "$TPL_FIX" "$TPL_WORK/rig/templates@$TPL_PIN"
|
||||
cat > "$TPL_WORK/bin/curl" <<'CURLEOF'
|
||||
#!/usr/bin/env bash
|
||||
echo "poisoned curl: snapshot resolution attempted network I/O" >&2
|
||||
exit 99
|
||||
CURLEOF
|
||||
chmod +x "$TPL_WORK/bin/curl"
|
||||
# shellcheck disable=SC2016
|
||||
snapshot_resolve='set -euo pipefail
|
||||
. "$1/commands/lib/templates.sh"
|
||||
templates_resolve
|
||||
printf "%s\n%s\n" "$REGISTRY_DIR" "$(templates_source_desc)"'
|
||||
check "templates: matching snapshot resolves with poisoned curl" 0 "(snapshot)" \
|
||||
env PATH="$TPL_WORK/bin:$PATH" bash -c "$snapshot_resolve" _ "$TPL_WORK/rig"
|
||||
|
||||
# A stale directory and an empty current directory are both unusable. The
|
||||
# poisoned fetch exit is folded into templates_resolve's normal loud refusal;
|
||||
# the important assertion is that neither path answers as the registry.
|
||||
mv "$TPL_WORK/rig/templates@$TPL_PIN" "$TPL_WORK/rig/templates@stale-pin"
|
||||
mkdir "$TPL_WORK/rig/templates@$TPL_PIN"
|
||||
check "templates: empty matching snapshot falls back to fetch" 1 "cannot fetch" \
|
||||
env PATH="$TPL_WORK/bin:$PATH" bash -c "$snapshot_resolve" _ "$TPL_WORK/rig"
|
||||
rm -rf "$TPL_WORK/rig/templates@$TPL_PIN"
|
||||
check "templates: stale snapshot is ignored" 1 "cannot fetch" \
|
||||
env PATH="$TPL_WORK/bin:$PATH" bash -c "$snapshot_resolve" _ "$TPL_WORK/rig"
|
||||
|
||||
# An explicit ref always means a live fetch, even when the matching snapshot
|
||||
# exists: restore it and prove the poison is reached.
|
||||
mv "$TPL_WORK/rig/templates@stale-pin" "$TPL_WORK/rig/templates@$TPL_PIN"
|
||||
check "templates: explicit REF never reads the snapshot" 1 "cannot fetch" \
|
||||
env PATH="$TPL_WORK/bin:$PATH" RIG_TEMPLATES_REF=operator-ref \
|
||||
bash -c "$snapshot_resolve" _ "$TPL_WORK/rig"
|
||||
|
||||
# rig template-lint — the registry repo's CI gate, same schema as the mint's
|
||||
# parser (rig defines validity; rig-templates CI enforces it on every PR).
|
||||
check "template-lint: --help exits 0" 0 "usage:" "$ROOT/commands/template-lint.sh" --help
|
||||
check "template-lint: a directory is required" 2 "role directory required" "$ROOT/commands/template-lint.sh"
|
||||
check "template-lint: dispatched from bin/rig" 0 "usage:" "$ROOT/bin/rig" template-lint --help
|
||||
check "template-lint: a valid definition passes" 0 "OK: " "$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box"
|
||||
check "template-lint: an unknown key fails by name" 1 "unknown key: COLOR" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/badkey-box"
|
||||
check "template-lint: one bad definition fails the whole run" 1 "FAIL: " \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box" "$TPL_FIX/badkey-box"
|
||||
mkdir -p "$TPL_FIX/plain"
|
||||
cp "$TPL_FIX/scratch-box"/* "$TPL_FIX/plain/"
|
||||
check "template-lint: a suffix-less role directory is refused (#76)" 1 "family suffix" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/plain"
|
||||
mkdir -p "$TPL_FIX/noinstall-box"
|
||||
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noinstall-box/"
|
||||
check "template-lint: a missing install.sh is refused by name" 1 "install.sh missing" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/noinstall-box"
|
||||
mkdir -p "$TPL_FIX/blankcreds-box"
|
||||
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/install.sh" "$TPL_FIX/blankcreds-box/"
|
||||
printf ' \n\t\n' > "$TPL_FIX/blankcreds-box/creds.md"
|
||||
check "template-lint: a blank creds.md is refused by name" 1 "creds.md missing or blank" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/blankcreds-box"
|
||||
mkdir -p "$TPL_FIX/noshebang-box"
|
||||
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noshebang-box/"
|
||||
printf 'exit 0\n' > "$TPL_FIX/noshebang-box/install.sh"
|
||||
check "template-lint: an install.sh without a shebang is refused" 1 "no shebang" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/noshebang-box"
|
||||
check "template-lint: a traits-only machine definition passes" 0 "OK: " \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-server"
|
||||
check "template-lint: workstation is the machine-family carve-out" 0 "OK: " \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/workstation"
|
||||
check "template-lint: machine roles refuse tenant keys" 1 "unknown key: USER" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/tenantkeys-server"
|
||||
check "template-lint: tenant roles refuse machine keys" 1 "unknown key: ROOT_DOOR" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/machinekeys-box"
|
||||
check "template-lint: machine roles refuse creds.md" 1 "creds.md is not allowed" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/creds-server"
|
||||
check "template-lint: machine install.sh requires a shebang" 1 "no shebang" \
|
||||
"$ROOT/commands/template-lint.sh" "$TPL_FIX/noshebang-server"
|
||||
# The install is deliberately after the users phase and its wrapper names both
|
||||
# role and source. Dynamic execution belongs to the root integration path; the
|
||||
# non-root offline harness pins the safety ordering and failure contract.
|
||||
machine_hook_at="$(grep -n 'running install hook for' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
|
||||
check "machine template: install hook is bootstrap's last convergence phase" 0 "" \
|
||||
test "${users_apply_at:-999999}" -lt "${machine_hook_at:-0}"
|
||||
# shellcheck disable=SC2016
|
||||
check "machine template: install failure names role and source" 0 "" \
|
||||
grep -qF 'install hook failed for role $ROLE from $(templates_source_desc)' "$ROOT/commands/bootstrap.sh"
|
||||
# shellcheck disable=SC2016
|
||||
check "machine template: install runs from its definition with RIG_ROLE" 0 "" \
|
||||
grep -qF 'cd "$MACHINE_TEMPLATE_DIR" && RIG_ROLE="$ROLE" bash ./install.sh' "$ROOT/commands/bootstrap.sh"
|
||||
rm -rf "$TPL_FIX" "$TPL_WORK"
|
||||
|
||||
# The per-tenant parameter table and the agent-context renderer are pure lib
|
||||
# functions on purpose (repo precedent: parse_users_file, json_string_array):
|
||||
# the CLI path to them sits behind root + a real tenant user, so the harness
|
||||
# proves them here, sourced, non-root and network-free.
|
||||
tuser() { bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; }
|
||||
tpath() { bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; }
|
||||
tctx() { bash -c 'set -euo pipefail
|
||||
. "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; }
|
||||
check "tenant params: agent users are named after their agent" 0 "claude" tuser claude-box
|
||||
check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging-box
|
||||
check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude-box /home/claude
|
||||
check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex-box /home/codex
|
||||
check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok-box /home/grok
|
||||
check "tenant params: staging has no context file" 1 "" tpath staging-box /home/ops
|
||||
# The box#80 guard note lives ONCE, in the renderer, and every agent's file
|
||||
# carries it — the layering decision's whole point: never per-template again.
|
||||
check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude-box
|
||||
check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex-box
|
||||
check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok-box
|
||||
check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude-box
|
||||
check "tenant context: the guard cites box#80" 0 "box#80" tctx claude-box
|
||||
check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude-box
|
||||
check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude-box
|
||||
check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex-box
|
||||
check "tenant context: grok names its login flow" 0 "grok login" tctx grok-box
|
||||
check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging-box
|
||||
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal
|
||||
# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or
|
||||
# apt-installs incus. A grep that finds nothing (exit 1) is the pass. The
|
||||
# same absences hold for the templates lib — it fetches DATA, unauthenticated
|
||||
# by contract, and must never grow a credential to do it.
|
||||
# apt-installs incus. A grep that finds nothing (exit 1) is the pass.
|
||||
check "tenant: never touches the tailnet" 1 "" \
|
||||
grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "tenant: non-interactive — nothing prompts" 1 "" \
|
||||
grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
|
||||
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
check "templates lib: the fetch carries no credential" 1 "" \
|
||||
grep -nE 'Authorization|gh api|GITHUB_TOKEN' "$ROOT/commands/lib/templates.sh"
|
||||
# The data file is PARSED, never executed: the parse loop reads lines, and
|
||||
# no source statement may ever reach template.env. Grep-pinned because the
|
||||
# failure is silent and total — a sourced template.env is arbitrary shell
|
||||
# running as root at every mint.
|
||||
check "templates lib: the parser READS template.env line by line" 0 "" \
|
||||
grep -qF 'while IFS= read -r line' "$ROOT/commands/lib/templates.sh"
|
||||
check "templates lib: template.env is never sourced" 1 "" \
|
||||
grep -nE '(source|^[[:space:]]*\.)[[:space:]]+[^#]*template\.env' "$ROOT/commands/lib/templates.sh" "$ROOT/commands/bootstrap-tenant.sh"
|
||||
# staging-box's posture rides the SAME hardening code as the machine roles — the
|
||||
# shared lib call is the anti-drift property, so pin the call, not the words.
|
||||
check "tenant: staging-box hardens through the shared sshd lib" 0 "" \
|
||||
|
|
@ -1104,6 +662,7 @@ check "tenant: the marker write follows the context-file converge" \
|
|||
# shellcheck disable=SC2016
|
||||
check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \
|
||||
grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh"
|
||||
|
||||
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
|
||||
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
|
||||
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version
|
||||
|
|
@ -1356,7 +915,6 @@ tags_empty() { # tags_empty <file> — exit 0 iff the reader prints NOTHING
|
|||
}
|
||||
FIX_TAGGED="$(mktemp)" # Self carries two tags; a peer carries a third
|
||||
FIX_UNTAGGED="$(mktemp)" # Self has no Tags key at all — the untagged hazard
|
||||
FIX_NESTED="$(mktemp)" # tagged Self carrying a nested Location object
|
||||
cat > "$FIX_TAGGED" <<'JSON'
|
||||
{
|
||||
"BackendState": "Running",
|
||||
|
|
@ -1377,77 +935,23 @@ cat > "$FIX_TAGGED" <<'JSON'
|
|||
}
|
||||
}
|
||||
JSON
|
||||
# The peers are the point (#160): an untagged Self OMITS its Tags key (Go
|
||||
# omitempty), and the old document-global reader then fell through into Peer and
|
||||
# returned tag:server here. Every real tailnet has this shape — untagged Self
|
||||
# next to tagged peers — which the peerless fixture this replaces never covered.
|
||||
cat > "$FIX_UNTAGGED" <<'JSON'
|
||||
{
|
||||
"BackendState": "Running",
|
||||
"Self": {
|
||||
"HostName": "user-owned-box"
|
||||
},
|
||||
"Peer": {
|
||||
"nodekey:aaa": {
|
||||
"HostName": "coolify-box",
|
||||
"Tags": [
|
||||
"tag:server"
|
||||
]
|
||||
},
|
||||
"nodekey:bbb": {
|
||||
"HostName": "ci-box",
|
||||
"Tags": [
|
||||
"tag:ci"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
# Location is a nested object INSIDE Self (a pointer with omitempty in the real
|
||||
# netmap): a reader that sliced Self to the next key would end early at its
|
||||
# closing brace and drop the Tags that follow — the brace counter must not.
|
||||
cat > "$FIX_NESTED" <<'JSON'
|
||||
{
|
||||
"BackendState": "Running",
|
||||
"Self": {
|
||||
"HostName": "coolify-box",
|
||||
"Location": {
|
||||
"Country": "Croatia",
|
||||
"CountryCode": "HR"
|
||||
},
|
||||
"Tags": [
|
||||
"tag:server",
|
||||
"tag:prod"
|
||||
]
|
||||
},
|
||||
"Peer": {
|
||||
"nodekey:abc": {
|
||||
"HostName": "ci-box",
|
||||
"Tags": [
|
||||
"tag:ci"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
check "json_string_array: reads the first array element" 0 "tag:ci" tags "$FIX_TAGGED"
|
||||
check "json_string_array: reads a later array element" 0 "tag:build" tags "$FIX_TAGGED"
|
||||
# The reader is scoped to the Self object: exactly two elements read proves the
|
||||
# peer's tag:server did not leak into Self's tags.
|
||||
# Self precedes Peer in the netmap, so the FIRST "Tags" is the node's own: exactly
|
||||
# two elements read proves the peer's tag:server did not leak into Self's tags.
|
||||
check "json_string_array: reads Self's array, not a peer's" 0 "2" tags_count "$FIX_TAGGED"
|
||||
# An absent key omits itself (Go omitempty), never emits []: empty is the signal
|
||||
# bootstrap turns into a hard untagged-key refusal, so it must read as empty here.
|
||||
check "json_string_array: absent Tags key prints nothing" 0 "" tags_empty "$FIX_UNTAGGED"
|
||||
# Regression, #160: with tagged peers present, an untagged Self must STILL read
|
||||
# empty — pre-fix this returned the peer's tag:server, false-refusing every
|
||||
# login join and false-verifying untagged authkey joins as tagged.
|
||||
check "json_string_array: untagged Self + tagged peers reads empty (#160)" \
|
||||
0 "" tags_empty "$FIX_UNTAGGED"
|
||||
check "json_string_array: nested Location does not truncate Self's tags" \
|
||||
0 "2" tags_count "$FIX_NESTED"
|
||||
check "json_string_array: reads past a nested object to a later element" \
|
||||
0 "tag:prod" tags "$FIX_NESTED"
|
||||
rm -f "$FIX_TAGGED" "$FIX_UNTAGGED" "$FIX_NESTED"
|
||||
rm -f "$FIX_TAGGED" "$FIX_UNTAGGED"
|
||||
|
||||
# The guard is only worth something if it runs BEFORE the box is touched: the
|
||||
# token prompt, the download, configure and svc.sh start all come after it.
|
||||
|
|
@ -1504,7 +1008,7 @@ check "platform: dispatches through bin/rig" 0 "PLATFORM" "$ROOT/bin/rig" platf
|
|||
|
||||
# The real run: exit 0 and every field present, as the running user.
|
||||
check "platform: runs as this user, exit 0" 0 "PLATFORM" "$ROOT/bin/rig" platform
|
||||
for f in HOSTNAME ID OS KERNEL CPU MEMORY DISK VIRT; do
|
||||
for f in HOSTNAME OS KERNEL CPU MEMORY DISK VIRT; do
|
||||
check "platform: reports $f" 0 "$f" "$ROOT/bin/rig" platform
|
||||
done
|
||||
# Not just the labels — the VALUES have to describe THIS machine. uname -r and
|
||||
|
|
@ -1571,70 +1075,10 @@ check "platform: reads a manifest with no trailing newline" 0 "BOOTSTRAP 0.4.0,
|
|||
printf 'role=dev class=human host=yes join=authkey\n' > "$PLATWORK/role"
|
||||
check "platform: renders the role marker's traits" 0 "dev (class=human host=yes join=authkey)" \
|
||||
env RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/role" "$ROOT/bin/rig" platform
|
||||
|
||||
# --- identity (#95): ID names the machine, HOSTNAME names the slot ----------
|
||||
# Everything below drives RIG_MACHINE_ID fixtures, so the suite neither
|
||||
# depends on nor leaks the machine-id of whatever box runs it.
|
||||
# THE PINNED DERIVATION: sha256("rig-machine-id:<machine-id>") → first 32 hex
|
||||
# rendered 8-4-4-4-12. The literal below is that digest computed OUTSIDE the
|
||||
# implementation. This exact-match is what keeps every machine's identity
|
||||
# stable: a refactor that changes the prefix, the hash or the slicing renames
|
||||
# the whole fleet at once, and nothing but this line would notice.
|
||||
# RIG_MANIFEST/RIG_ROLE_MARKER point at the absent fixture on purpose — this
|
||||
# doubles as the unconverged-machine case: ID must render with no manifest
|
||||
# and no role marker, because a minted-at-bootstrap id was #95's rejected
|
||||
# Option B and pre-bootstrap usefulness is the property that rejected it.
|
||||
printf '0123456789abcdef0123456789abcdef\n' > "$PLATWORK/machine-id"
|
||||
check "platform: ID is the pinned derivation, manifest-free (#95)" 0 "ID cd9fb802-1493-2336-d027-7955f328bcd8" \
|
||||
env RIG_MACHINE_ID="$PLATWORK/machine-id" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform
|
||||
# Determinism asserted, not assumed: two runs over the same input agree.
|
||||
# (Reboot-stability follows — the id is a pure function of the file content.)
|
||||
ID_A="$(env RIG_MACHINE_ID="$PLATWORK/machine-id" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform | awk '$1=="ID" {print $2}')"
|
||||
ID_B="$(env RIG_MACHINE_ID="$PLATWORK/machine-id" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform | awk '$1=="ID" {print $2}')"
|
||||
check "platform: ID is deterministic across runs" 0 "" test "$ID_A" = "$ID_B"
|
||||
printf '%s\n' "$ID_A" > "$PLATWORK/idval"
|
||||
check "platform: ID is UUID-shaped (8-4-4-4-12 hex)" 0 "" \
|
||||
grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' "$PLATWORK/idval"
|
||||
# A different machine-id yields a different id — pinned exactly rather than
|
||||
# asserted merely unequal, so a broken extraction cannot pass as "different".
|
||||
printf 'ffffffffffffffffffffffffffffffff\n' > "$PLATWORK/machine-id-2"
|
||||
check "platform: ID changes when the machine-id changes" 0 "ID 65441a65-bf82-8c75-b610-26e68a768bd3" \
|
||||
env RIG_MACHINE_ID="$PLATWORK/machine-id-2" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform
|
||||
# THE CONFIDENTIALITY PROPERTY — the whole reason the derivation exists, and
|
||||
# the one a future refactor is most likely to lose: the raw machine-id never
|
||||
# appears anywhere in the output. machine-id(5) asks exactly this.
|
||||
env RIG_MACHINE_ID="$PLATWORK/machine-id" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" \
|
||||
"$ROOT/bin/rig" platform > "$PLATWORK/platout" 2>&1
|
||||
check "platform: the raw machine-id never appears in the output" 1 "" \
|
||||
grep -qF '0123456789abcdef0123456789abcdef' "$PLATWORK/platout"
|
||||
# An EMPTY machine-id must take the unavailable path, never be hashed:
|
||||
# sha256("rig-machine-id:") renders as the literal below, and hashing nothing
|
||||
# would hand every such machine the SAME id — the worst possible failure for
|
||||
# an identity field. Images do ship the file empty (machine-id(5) first-boot
|
||||
# semantics), so this is a real path, not a defensive one.
|
||||
: > "$PLATWORK/machine-id-empty"
|
||||
check "platform: an empty machine-id says why, exit 0" 0 "ID unavailable" \
|
||||
env RIG_MACHINE_ID="$PLATWORK/machine-id-empty" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform
|
||||
env RIG_MACHINE_ID="$PLATWORK/machine-id-empty" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" \
|
||||
"$ROOT/bin/rig" platform > "$PLATWORK/platout-empty" 2>&1
|
||||
check "platform: empty machine-id is never hashed (no collision id)" 1 "" \
|
||||
grep -qF 'ddb56c2f-0df1-0ab0-1c12-371b1d32e34e' "$PLATWORK/platout-empty"
|
||||
check "platform: empty machine-id — every other field still renders" 0 "HOSTNAME" \
|
||||
env RIG_MACHINE_ID="$PLATWORK/machine-id-empty" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform
|
||||
# Missing file: same degradation, named reason, never an empty field.
|
||||
check "platform: a missing machine-id says why, exit 0" 0 "ID unavailable (no " \
|
||||
env RIG_MACHINE_ID="$PLATWORK/absent" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform
|
||||
# 'uninitialized' is machine-id(5)'s other not-yet-set sentinel — hashing it
|
||||
# would collide every first-boot image exactly like the empty case.
|
||||
printf 'uninitialized\n' > "$PLATWORK/machine-id-uninit"
|
||||
check "platform: an 'uninitialized' machine-id is not hashed" 0 "ID unavailable ($PLATWORK/machine-id-uninit is uninitialized)" \
|
||||
env RIG_MACHINE_ID="$PLATWORK/machine-id-uninit" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform
|
||||
|
||||
# The defining property: it writes NOTHING. Not the manifest it just reported
|
||||
# missing, not the marker, not a cached id (#95's Option A stores nothing),
|
||||
# not anything else in the fixture directory — the whole design rests on
|
||||
# this, so assert it rather than trust it.
|
||||
env RIG_MACHINE_ID="$PLATWORK/absent" RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform >/dev/null 2>&1
|
||||
# missing, not the marker, not anything else in the fixture directory — the
|
||||
# whole design rests on this, so assert it rather than trust it.
|
||||
env RIG_MANIFEST="$PLATWORK/absent" RIG_ROLE_MARKER="$PLATWORK/absent" "$ROOT/bin/rig" platform >/dev/null 2>&1
|
||||
check "platform: writes nothing (no manifest created)" 1 "" test -e "$PLATWORK/absent"
|
||||
rm -rf "$PLATWORK"
|
||||
|
||||
|
|
@ -2812,19 +2256,6 @@ check "help lists the versioned verbs" 0 "uninstall" "$ROOT/bin/rig" --help
|
|||
WORK="$(mktemp -d)"
|
||||
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||
|
||||
# Every real installer run gets a deterministic registry archive. The curl
|
||||
# shim can also be poisoned per call to prove warn-and-continue behavior.
|
||||
SNAPBIN="$WORK/snapshot-bin"
|
||||
mkdir -p "$SNAPBIN" "$WORK/snapshot-stage/rig-templates-pin/scratch-box"
|
||||
printf 'USER="scratch"\n' > "$WORK/snapshot-stage/rig-templates-pin/scratch-box/template.env"
|
||||
tar -czf "$WORK/snapshot.tar.gz" -C "$WORK/snapshot-stage" rig-templates-pin
|
||||
cat > "$SNAPBIN/curl" <<'CURLEOF'
|
||||
#!/usr/bin/env bash
|
||||
[ -z "${SNAPSHOT_FETCH_FAIL:-}" ] || exit 22
|
||||
cp "${SNAPSHOT_TARBALL:?}" "$4"
|
||||
CURLEOF
|
||||
chmod +x "$SNAPBIN/curl"
|
||||
|
||||
# A fabricated "newer release": the same CLI, a different VERSION — what an
|
||||
# upgrade actually is, from the installer's point of view.
|
||||
SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin"
|
||||
|
|
@ -2836,8 +2267,7 @@ echo "8.8.8-drill" > "$SRC8/VERSION"
|
|||
|
||||
inst() { # inst <rig_home> <rig_bin> [VAR=val ...] — run install.sh for real
|
||||
local h="$1" b="$2"; shift 2
|
||||
env HOME="$FAKEHOME" PATH="$SNAPBIN:$PATH" \
|
||||
SNAPSHOT_TARBALL="$WORK/snapshot.tar.gz" RIG_ROLE_MARKER="$WORK/no-marker" \
|
||||
env HOME="$FAKEHOME" RIG_ROLE_MARKER="$WORK/no-marker" \
|
||||
RIG_HOME="$h" RIG_BIN="$b" \
|
||||
RIG_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.sh"
|
||||
}
|
||||
|
|
@ -2853,16 +2283,6 @@ check "install: 'current' points at versions/<v>" 0 "versions/$VER" readlink "$H
|
|||
check "install: the PATH symlink rides the chain" 0 "$H1/current/bin/rig" readlink "$B1/rig"
|
||||
check "install: rig --version answers through the whole chain" 0 "rig $VER" irig "$B1/rig" --version
|
||||
check "install: INSTALLED_FROM records the local source" 0 "local:" cat "$H1/versions/$VER/INSTALLED_FROM"
|
||||
check "install: the pinned registry snapshot lands inside the version tree" 0 "" \
|
||||
test -f "$H1/versions/$VER/templates@$TPL_PIN/scratch-box/template.env"
|
||||
|
||||
HFAIL="$WORK/h-failed-snapshot"; BFAIL="$WORK/b-failed-snapshot"
|
||||
check "install: unreachable registry warns and still installs rig" 0 "WARNING: could not fetch template registry snapshot" \
|
||||
inst "$HFAIL" "$BFAIL" SNAPSHOT_FETCH_FAIL=1
|
||||
check "install: failed snapshot fetch leaves a working tree" 0 "rig $VER" \
|
||||
"$BFAIL/rig" --version
|
||||
check "install: failed snapshot fetch leaves no hollow snapshot" 1 "" \
|
||||
test -e "$HFAIL/versions/$VER/templates@$TPL_PIN"
|
||||
|
||||
# --- rig#39: no $HOME in the environment (cloud-init's runcmd) ---------------
|
||||
# The box#88 seed runs install.sh from runcmd, which carries NO $HOME; under
|
||||
|
|
@ -2885,13 +2305,10 @@ check "install: no \$HOME and no getent answer refuses by name" 1 "set HOME and
|
|||
|
||||
# --- converge, don't clobber ------------------------------------------------
|
||||
touch "$H1/versions/$VER/CANARY"
|
||||
touch "$H1/versions/$VER/templates@$TPL_PIN/STALE"
|
||||
check "install: a same-version re-run is a no-op that says so" 0 "already installed" inst "$H1" "$B1"
|
||||
check "install: the no-op left the tree untouched" 0 "" test -e "$H1/versions/$VER/CANARY"
|
||||
check "install: RIG_REINSTALL=1 replaces that version's tree" 0 "reinstalled" inst "$H1" "$B1" RIG_REINSTALL=1
|
||||
check "install: the reinstall really replaced it (canary gone)" 1 "" test -e "$H1/versions/$VER/CANARY"
|
||||
check "install: reinstall replaces the registry snapshot" 1 "" \
|
||||
test -e "$H1/versions/$VER/templates@$TPL_PIN/STALE"
|
||||
|
||||
# --- a second version: side-by-side, and the flip ---------------------------
|
||||
check "install: a second version installs side-by-side" 0 "" inst "$H1" "$B1" RIG_INSTALL_SOURCE="$SRC9"
|
||||
|
|
|
|||
223
test/drill.sh
223
test/drill.sh
|
|
@ -1,223 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# test/drill.sh — the drill harness's HONESTY, proven without hardware.
|
||||
#
|
||||
# drill/drill.sh is the instrument (#105), so what this suite tests is the
|
||||
# instrument itself: the refusals, the classifications, the capture-and-diff
|
||||
# that decides idempotence, and the record emitter — the parts whose lies
|
||||
# would be believed, months later, by a reader of drills/<version>.md. The
|
||||
# four-leg live run on a real Debian machine is #107's exercise, not this
|
||||
# file's: nothing here needs root, Docker, a tailnet or the network.
|
||||
#
|
||||
# Extraction pattern is test/release.sh's: the functions under test are
|
||||
# awk-extracted from drill/drill.sh and driven against fixtures, so the tests
|
||||
# exercise the shipped bytes, and the extraction check itself guards the awk
|
||||
# against a drifted function boundary.
|
||||
# Deliberately no `set -e` — the harness asserts on failing commands.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT" || exit 1
|
||||
PASS=0 FAIL=0
|
||||
|
||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
||||
check() {
|
||||
local desc="$1" want="$2" substr="$3"; shift 3
|
||||
local out rc
|
||||
out="$("$@" 2>&1)"; rc=$?
|
||||
if [ "$rc" -ne "$want" ]; then
|
||||
echo "FAIL: $desc — exit $rc, wanted $want"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
FAIL=$((FAIL + 1)); return
|
||||
fi
|
||||
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
|
||||
echo "FAIL: $desc — output missing '$substr'"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
FAIL=$((FAIL + 1)); return
|
||||
fi
|
||||
echo "ok: $desc"; PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
# refute <desc> <substr> <file> — the file must NOT contain the substring.
|
||||
refute() {
|
||||
if grep -qF -e "$2" "$3"; then
|
||||
echo "FAIL: $1 — found forbidden '$2'"
|
||||
FAIL=$((FAIL + 1)); return
|
||||
fi
|
||||
echo "ok: $1"; PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# --- the functions under test, extracted -------------------------------------
|
||||
FNS="$WORK/drill-fns.sh"
|
||||
for fn in tree_of assert_installed_from classify_leg capture_state emit_record; do
|
||||
awk "/^${fn}\(\) \{/,/^\}/" "$ROOT/drill/drill.sh" >> "$FNS"
|
||||
done
|
||||
for fn in tree_of assert_installed_from classify_leg capture_state emit_record; do
|
||||
check "extraction guards the awk: ${fn}() landed" 0 "${fn}() {" grep -F "${fn}() {" "$FNS"
|
||||
done
|
||||
# shellcheck source=/dev/null
|
||||
. "$FNS"
|
||||
|
||||
# =============================================================================
|
||||
# tree_of — the versioned tree behind a CLI's symlink chain
|
||||
# =============================================================================
|
||||
IR="$WORK/install"; mkdir -p "$IR/versions/1.2.3/bin"
|
||||
: > "$IR/versions/1.2.3/bin/rig"
|
||||
ln -s "versions/1.2.3" "$IR/current"
|
||||
mkdir -p "$WORK/bin"
|
||||
ln -s "$IR/current/bin/rig" "$WORK/bin/rig"
|
||||
check "tree_of resolves a current-symlink chain to versions/<v>" 0 "$IR/versions/1.2.3" \
|
||||
tree_of "$WORK/bin/rig"
|
||||
ln -s "$IR/gone/bin/rig" "$WORK/bin/dangling"
|
||||
check "tree_of refuses a dangling chain — a tree that is not there is not a tree" 1 "" \
|
||||
tree_of "$WORK/bin/dangling"
|
||||
|
||||
# =============================================================================
|
||||
# assert_installed_from — the up-front ref refusal, naming both refs
|
||||
# =============================================================================
|
||||
TREE="$WORK/tree-main"; mkdir -p "$TREE"
|
||||
printf 'heavy-duty/rig@main\n' > "$TREE/INSTALLED_FROM"
|
||||
check "matching INSTALLED_FROM passes silently" 0 "" \
|
||||
assert_installed_from rig "$TREE" "heavy-duty/rig@main"
|
||||
check "a mismatch refuses (the #103 hazard: asked release, got main)" 1 "FATAL" \
|
||||
assert_installed_from rig "$TREE" "heavy-duty/rig@release/9.9.9"
|
||||
check "…the refusal names the ref that was ASKED for" 1 "heavy-duty/rig@release/9.9.9" \
|
||||
assert_installed_from rig "$TREE" "heavy-duty/rig@release/9.9.9"
|
||||
check "…and the ref that actually LANDED" 1 "heavy-duty/rig@main" \
|
||||
assert_installed_from rig "$TREE" "heavy-duty/rig@release/9.9.9"
|
||||
check "an unreadable INSTALLED_FROM refuses too — absence is not a match" 1 "<unreadable>" \
|
||||
assert_installed_from rig "$WORK/no-such-tree" "heavy-duty/rig@main"
|
||||
|
||||
# =============================================================================
|
||||
# classify_leg — a loud skip is a SKIP, never a pass (box#153's defect class)
|
||||
# =============================================================================
|
||||
printf 'skip: docker not installed — nothing to exercise\n' > "$WORK/out-skip"
|
||||
printf 'ok: seeded\nok: restored\n---\n14 passed, 0 failed\n' > "$WORK/out-pass"
|
||||
printf 'FAIL: restore blew up\n' > "$WORK/out-fail"
|
||||
check "exit 0 + 'skip:' line classifies as skip" 0 "skip" classify_leg 0 "$WORK/out-skip"
|
||||
check "exit 0, no skip line, classifies as pass" 0 "pass" classify_leg 0 "$WORK/out-pass"
|
||||
check "non-zero exit classifies as fail" 0 "fail" classify_leg 1 "$WORK/out-fail"
|
||||
check "a skip line cannot rescue a non-zero exit (fail wins)" 0 "fail" \
|
||||
classify_leg 1 "$WORK/out-skip"
|
||||
|
||||
# =============================================================================
|
||||
# capture_state + diff — the idempotence verdict's machinery. The claim in
|
||||
# #105's acceptance criteria: the assertion is a REAL diff of captured state,
|
||||
# and it FAILS when convergence is broken — demonstrated here, mechanically,
|
||||
# on every CI run, by breaking the state between two captures.
|
||||
# =============================================================================
|
||||
FIX="$WORK/fix"; mkdir -p "$FIX/sudoers.d"
|
||||
printf 'role=staging-server root-door=open host=yes join=authkey\n' > "$FIX/role"
|
||||
printf 'schema=1\nbootstrapped_by=9.9.9\nbootstrapped_at=T\nconverged_by=9.9.9\nconverged_at=T\n' > "$FIX/manifest"
|
||||
printf 'dan active\nghost revoked\n' > "$FIX/ledger"
|
||||
printf 'APT::Periodic::Update-Package-Lists "1";\n' > "$FIX/autoup"
|
||||
printf '127.0.0.1 localhost\n127.0.1.1\tstaging-server\n' > "$FIX/hosts"
|
||||
printf 'nosuchdrilluser ALL=(ALL) NOPASSWD:ALL\n' > "$FIX/sudoers.d/00-rig-nosuch"
|
||||
# A stubbed sshd, so the effective-config section is exercised rather than
|
||||
# skipped on a box with no daemon (repo precedent: test/release.sh's curl).
|
||||
STUB="$WORK/stub"; mkdir -p "$STUB"
|
||||
# The single-quoted $SSHD_FIXTURE is the STUB's expansion, not this shell's.
|
||||
# shellcheck disable=SC2016
|
||||
printf '#!/usr/bin/env bash\ncat "$SSHD_FIXTURE"\n' > "$STUB/sshd"; chmod +x "$STUB/sshd"
|
||||
printf 'passwordauthentication no\npermitrootlogin prohibit-password\n' > "$FIX/sshd-T"
|
||||
|
||||
cap() { # cap <outfile> — capture_state against the fixture set
|
||||
RIG_ROLE_MARKER="$FIX/role" RIG_MANIFEST="$FIX/manifest" \
|
||||
DRILL_LEDGER="$FIX/ledger" DRILL_AUTOUPGRADES="$FIX/autoup" \
|
||||
DRILL_ETC_HOSTS="$FIX/hosts" DRILL_SUDOERS_DIR="$FIX/sudoers.d" \
|
||||
SSHD_FIXTURE="$FIX/sshd-T" PATH="$STUB:$PATH" \
|
||||
bash -c '. "$1"; capture_state "$2"' _ "$FNS" "$2" 2>/dev/null
|
||||
:
|
||||
}
|
||||
# cap runs capture_state in a child bash so the PATH stub cannot leak into
|
||||
# this harness; $2 arrives as the capture's outfile.
|
||||
cap out "$WORK/cap1"
|
||||
cap out "$WORK/cap2"
|
||||
check "two captures over untouched state diff EMPTY (the converged verdict)" 0 "" \
|
||||
diff -u "$WORK/cap1" "$WORK/cap2"
|
||||
check "the capture reads the fixtures, not the machine (marker line present)" 0 "role=staging-server" \
|
||||
grep -o 'role=staging-server[^"]*' "$WORK/cap1"
|
||||
check "…the sshd section captured the effective config" 0 "passwordauthentication no" \
|
||||
cat "$WORK/cap1"
|
||||
check "…a ledger user with no account reads as one, deterministically" 0 "(no account)" \
|
||||
cat "$WORK/cap1"
|
||||
|
||||
# Break convergence: the re-run "changed" the role marker and root's door.
|
||||
printf 'role=staging-server root-door=closed host=yes join=authkey\n' > "$FIX/role"
|
||||
printf 'passwordauthentication yes\npermitrootlogin prohibit-password\n' > "$FIX/sshd-T"
|
||||
cap out "$WORK/cap3"
|
||||
check "a broken convergence makes the diff NON-empty — the assertion can fail" 1 "root-door=closed" \
|
||||
diff -u "$WORK/cap1" "$WORK/cap3"
|
||||
check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "passwordauthentication yes" \
|
||||
diff -u "$WORK/cap1" "$WORK/cap3"
|
||||
|
||||
# =============================================================================
|
||||
# emit_record — the record is drills/README.md's shape, and it cannot lie:
|
||||
# a failed run still emits, a skipped leg is named, no clean-sweep reading.
|
||||
# =============================================================================
|
||||
emit() { # emit <outfile> — emit_record with the harness globals staged
|
||||
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
|
||||
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
|
||||
TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" TPL_SOURCE="snapshot" \
|
||||
bash -c '
|
||||
. "$1"
|
||||
pass=12 fail=1 skipped=1
|
||||
findings=("FAIL: coolify container state: absent" "SKIP: runner lifecycle: no --runner-repo fork given — the leg did not run" "NOTE: something worth a line")
|
||||
LEG_NAMES=("convergence — bootstrap staging-server reaches its role" "re-converge (idempotence)" "coolify install (4.1.2)" "runner lifecycle")
|
||||
LEG_RESULTS=("PASS (312s)" "clean, no changes" "FAIL — container absent" "SKIPPED — no fork provided")
|
||||
emit_record "$2"
|
||||
' _ "$FNS" "$2"
|
||||
}
|
||||
emit out "$WORK/record.md"
|
||||
check "record: the version-and-date heading" 0 "# Release drill — 9.9.9 — " head -1 "$WORK/record.md"
|
||||
check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md"
|
||||
check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md"
|
||||
check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md"
|
||||
check "record: the template registry SHA and actual source ride alongside the pair (#110/#153)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432, snapshot)" cat "$WORK/record.md"
|
||||
check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md"
|
||||
check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md"
|
||||
check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"
|
||||
check "record: a skipped leg is stated as NOT run, by name" 0 "SKIP: runner lifecycle" cat "$WORK/record.md"
|
||||
check "record: the skip section says the record is not evidence for it" 0 "not evidence" cat "$WORK/record.md"
|
||||
check "record: the isolation boundary is named as box's, in words" 0 "NOT asserted here" cat "$WORK/record.md"
|
||||
refute "record with a skip cannot read as a clean sweep" "Failed: nothing" "$WORK/record.md"
|
||||
refute "notes are findings for the log, not failures for the record" "NOTE: something" "$WORK/record.md"
|
||||
|
||||
# The all-green shape: says so plainly, and only then.
|
||||
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
|
||||
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
|
||||
bash -c '
|
||||
. "$1"
|
||||
pass=20 fail=0 skipped=0
|
||||
findings=()
|
||||
LEG_NAMES=("convergence" "re-converge (idempotence)")
|
||||
LEG_RESULTS=("PASS" "clean, no changes")
|
||||
emit_record "$2"
|
||||
' _ "$FNS" "$WORK/record-green.md"
|
||||
check "an all-green record says every leg ran and passed" 0 "Every leg ran and every check passed" \
|
||||
cat "$WORK/record-green.md"
|
||||
|
||||
# =============================================================================
|
||||
# the shipped script itself
|
||||
# =============================================================================
|
||||
# Arg refusals fire before the root check (repo doctrine, bootstrap.sh:114),
|
||||
# which is what makes them provable here without a throwaway machine.
|
||||
check "drill.sh refuses to run without BOTH refs pinned (#103)" 2 "--box-ref" \
|
||||
env -u RIG_REF -u BOX_REF bash "$ROOT/drill/drill.sh" --rig-ref release/9.9.9 --yes
|
||||
check "…and the refusal shows which ref is missing" 2 "<unset>" \
|
||||
env -u RIG_REF -u BOX_REF bash "$ROOT/drill/drill.sh" --rig-ref release/9.9.9 --yes
|
||||
check "a tenant role is refused — the drill converges machines, not guests" 2 "not a machine role" \
|
||||
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --role claude-box --yes
|
||||
check "no --users is a refusal, naming why the drill will not default it" 2 "--users <path> is required" \
|
||||
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --yes
|
||||
check "an unreadable users file dies before anything is spent" 2 "cannot read users file" \
|
||||
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --users "$WORK/no-such-users" --yes
|
||||
check "an unknown flag dies loudly, exit 2" 2 "unknown option" \
|
||||
bash "$ROOT/drill/drill.sh" --frobnicate
|
||||
check "--help prints the header and exits 0" 0 "THROWAWAY" \
|
||||
bash "$ROOT/drill/drill.sh" --help
|
||||
|
||||
echo "---"
|
||||
echo "$PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# The install LIFECYCLE, driven end to end against a tree install.sh itself
|
||||
# produced (#106) — the four beats box and cast already run in CI, which rig,
|
||||
# the repo whose headline claim is convergence, ran nowhere:
|
||||
#
|
||||
# 1. install from THIS checkout (RIG_INSTALL_SOURCE — the local channel)
|
||||
# 2. assert what landed (layout, current, the PATH chain)
|
||||
# 3. a converging re-run (an EMPTY DIFF, never an exit code)
|
||||
# 4. uninstall --all (ending in the absence assert)
|
||||
#
|
||||
# test/cli.sh drives the same verbs against throwaway roots; this suite runs
|
||||
# them in the environment cli.sh deliberately fakes — the real default paths
|
||||
# under the runner's own $HOME. Run: bash test/install-lifecycle.sh (CI's
|
||||
# `install:` job). RIG_HOME/RIG_BIN redirect the roots for a local run; the
|
||||
# refusal below explains when you need them.
|
||||
#
|
||||
# Deliberately no `set -e` — a failing beat is data, and the summary is the
|
||||
# verdict (the test/release.sh harness shape).
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT" || exit 1
|
||||
PASS=0 FAIL=0
|
||||
|
||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
||||
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
||||
# contains want_substr.
|
||||
check() {
|
||||
local desc="$1" want="$2" substr="$3"; shift 3
|
||||
local out rc
|
||||
out="$("$@" 2>&1)"; rc=$?
|
||||
if [ "$rc" -ne "$want" ]; then
|
||||
echo "FAIL: $desc — exit $rc, wanted $want"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
FAIL=$((FAIL + 1)); return
|
||||
fi
|
||||
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
|
||||
echo "FAIL: $desc — output missing '$substr'"
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
FAIL=$((FAIL + 1)); return
|
||||
fi
|
||||
echo "ok: $desc"; PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
# The roots install.sh will use, computed by ITS rules (install.sh:55-60), so
|
||||
# every assert below points at what the installer actually touched.
|
||||
DEST="${RIG_HOME:-$HOME/.local/share/rig}"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
BINDIR="${RIG_BIN:-/usr/local/bin}"
|
||||
else
|
||||
BINDIR="${RIG_BIN:-$HOME/.local/bin}"
|
||||
fi
|
||||
|
||||
# Beat 4 REMOVES the install at those roots, so a rig that already lives there
|
||||
# is a refusal, not a fixture — this suite must never eat an operator's
|
||||
# install. CI runners are clean; a workstation run points the roots at
|
||||
# something disposable.
|
||||
if [ -e "$DEST" ] || [ -L "$DEST" ] || [ -e "$BINDIR/rig" ] || [ -L "$BINDIR/rig" ]; then
|
||||
echo "install-lifecycle: a rig install already exists ($DEST or $BINDIR/rig)" >&2
|
||||
echo "install-lifecycle: refusing to drive the lifecycle over it — re-run against scratch roots:" >&2
|
||||
echo " W=\$(mktemp -d); RIG_HOME=\$W/rig RIG_BIN=\$W/bin bash test/install-lifecycle.sh" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
VER="$(cat "$ROOT/VERSION")"
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# install.sh snapshots the pinned registry even for the local source channel.
|
||||
# Serve a deterministic archive so this lifecycle remains fully offline.
|
||||
SNAPBIN="$WORK/snapshot-bin"
|
||||
mkdir -p "$SNAPBIN" "$WORK/snapshot-stage/rig-templates-pin/test-box"
|
||||
printf 'USER="test"\n' > "$WORK/snapshot-stage/rig-templates-pin/test-box/template.env"
|
||||
tar -czf "$WORK/snapshot.tar.gz" -C "$WORK/snapshot-stage" rig-templates-pin
|
||||
cat > "$SNAPBIN/curl" <<'CURLEOF'
|
||||
#!/usr/bin/env bash
|
||||
cp "${SNAPSHOT_TARBALL:?}" "$4"
|
||||
CURLEOF
|
||||
chmod +x "$SNAPBIN/curl"
|
||||
|
||||
# tree_state <root> — what "changed nothing" must mean: every file's bytes,
|
||||
# every path's type and mode, every symlink's target. Beat 3 captures this
|
||||
# before and after the re-run and diffs the two.
|
||||
tree_state() {
|
||||
(cd "$1" || return 1
|
||||
find . -type f -exec sha256sum {} + | LC_ALL=C sort
|
||||
find . -type l -printf '%p -> %l\n' | LC_ALL=C sort
|
||||
find . -printf '%y %m %p\n' | LC_ALL=C sort)
|
||||
}
|
||||
|
||||
diff_state() { # diff_state <capture> <root> — how the tree drifted, by name
|
||||
tree_state "$2" | diff "$1" -
|
||||
}
|
||||
|
||||
no_residue() { # no_residue <path>... — 0 iff every path is GONE: file, dir OR
|
||||
local p bad=0 # symlink. `! -e` alone follows the link and cannot see it
|
||||
for p in "$@"; do # dangling — the residue a broken uninstall actually leaves.
|
||||
if [ -e "$p" ] || [ -L "$p" ]; then echo "still present: $p"; bad=1; fi
|
||||
done
|
||||
return "$bad"
|
||||
}
|
||||
|
||||
# --- instrument honesty ------------------------------------------------------
|
||||
# The diff and the absence assert must be able to FAIL, or beats 3 and 4 prove
|
||||
# nothing — so break each one against a scratch tree first, on every run
|
||||
# (the test/drill.sh doctrine: mechanical, not a one-off claim in a PR).
|
||||
SCR="$WORK/scr"; mkdir -p "$SCR/tree/bin"
|
||||
echo content > "$SCR/tree/bin/rig"
|
||||
ln -s bin/rig "$SCR/tree/link"
|
||||
tree_state "$SCR/tree" > "$SCR/cap"
|
||||
check "honesty: an untouched tree reads as zero drift" 0 "" \
|
||||
diff_state "$SCR/cap" "$SCR/tree"
|
||||
echo drift >> "$SCR/tree/bin/rig"
|
||||
check "honesty: a mutated file is drift, named" 1 "bin/rig" \
|
||||
diff_state "$SCR/cap" "$SCR/tree"
|
||||
tree_state "$SCR/tree" > "$SCR/cap"
|
||||
ln -sfn ../elsewhere "$SCR/tree/link"
|
||||
check "honesty: a retargeted symlink is drift" 1 "elsewhere" \
|
||||
diff_state "$SCR/cap" "$SCR/tree"
|
||||
tree_state "$SCR/tree" > "$SCR/cap"
|
||||
touch "$SCR/tree/leftover"
|
||||
check "honesty: an ADDED file is drift (what a non-convergent installer leaves)" 1 "leftover" \
|
||||
diff_state "$SCR/cap" "$SCR/tree"
|
||||
# The beat-4 distinction, demonstrated: `test ! -e` PASSES on a dangling
|
||||
# symlink (it follows the link), so on its own it would certify a broken
|
||||
# uninstall clean — only `! -L` sees the corpse.
|
||||
ln -s "$SCR/nowhere" "$SCR/dangling-rig"
|
||||
check "honesty: test ! -e cannot see a dangling symlink (the lie)" 0 "" \
|
||||
test ! -e "$SCR/dangling-rig"
|
||||
check "honesty: the absence assert can (! -L is the catch)" 1 "still present" \
|
||||
no_residue "$SCR/dangling-rig"
|
||||
rm "$SCR/dangling-rig"
|
||||
check "honesty: a really-gone path passes the absence assert" 0 "" \
|
||||
no_residue "$SCR/dangling-rig"
|
||||
|
||||
# --- beat 1: install from THIS checkout --------------------------------------
|
||||
# RIG_INSTALL_SOURCE is the supported local channel (its contract — dir,
|
||||
# tarball, loud refusal, no silent download fallback — is test/release.sh's);
|
||||
# in CI $ROOT is $GITHUB_WORKSPACE, so what lands is the code under review.
|
||||
b1() {
|
||||
PATH="$SNAPBIN:$PATH" SNAPSHOT_TARBALL="$WORK/snapshot.tar.gz" \
|
||||
RIG_INSTALL_SOURCE="$ROOT" bash "$ROOT/install.sh"
|
||||
}
|
||||
check "beat 1: install.sh installs this checkout" 0 "done" b1
|
||||
|
||||
# --- beat 2: assert what landed ----------------------------------------------
|
||||
check "beat 2: the tree landed in versions/$VER" 0 "" \
|
||||
test -x "$DEST/versions/$VER/bin/rig"
|
||||
check "beat 2: current points at versions/$VER" 0 "versions/$VER" \
|
||||
readlink "$DEST/current"
|
||||
check "beat 2: the PATH symlink rides the chain" 0 "$DEST/current/bin/rig" \
|
||||
readlink "$BINDIR/rig"
|
||||
check "beat 2: ...and resolves into versions/ (cast's assert)" 0 "/versions/$VER/bin/rig" \
|
||||
readlink -f "$BINDIR/rig"
|
||||
check "beat 2: rig --version answers through the whole chain" 0 "rig $VER" \
|
||||
"$BINDIR/rig" --version
|
||||
check "beat 2: INSTALLED_FROM names the local source" 0 "local:$ROOT" \
|
||||
cat "$DEST/versions/$VER/INSTALLED_FROM"
|
||||
TPL_PIN="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$ROOT/commands/lib/templates.sh")"
|
||||
check "beat 2: pinned registry snapshot landed in the version tree" 0 "" \
|
||||
test -f "$DEST/versions/$VER/templates@$TPL_PIN/test-box/template.env"
|
||||
|
||||
# --- beat 3: the converging re-run -------------------------------------------
|
||||
# "Ran twice without crashing" is the self-deception this beat exists to
|
||||
# refuse (#106): the assert is an empty diff of captured state, plus current
|
||||
# still pointing where it did.
|
||||
tree_state "$DEST" > "$WORK/before"
|
||||
CUR_BEFORE="$(readlink "$DEST/current")"
|
||||
check "beat 3: the re-run is a no-op that says so" 0 "already installed" b1
|
||||
check "beat 3: ...and changed NOTHING — the diff is the verdict" 0 "" \
|
||||
diff_state "$WORK/before" "$DEST"
|
||||
check "beat 3: current did not move" 0 "" \
|
||||
test "$(readlink "$DEST/current")" = "$CUR_BEFORE"
|
||||
|
||||
# --- beat 4: uninstall --all, ending in the absence assert -------------------
|
||||
check "beat 4: uninstall --all removes the whole install" 0 "uninstalled" \
|
||||
"$BINDIR/rig" uninstall --all --force
|
||||
check "beat 4: zero residue at the install root" 0 "" no_residue "$DEST"
|
||||
check "beat 4: zero residue on PATH — not even a dangling symlink" 0 "" \
|
||||
no_residue "$BINDIR/rig"
|
||||
# The doctrine spelled out as its two distinct asserts (#106): -e for
|
||||
# presence, -L for the dangling link -e cannot see.
|
||||
check "beat 4: test ! -e on the PATH entry" 0 "" test ! -e "$BINDIR/rig"
|
||||
check "beat 4: test ! -L on the PATH entry" 0 "" test ! -L "$BINDIR/rig"
|
||||
check "beat 4: test ! -e on the install root" 0 "" test ! -e "$DEST"
|
||||
check "beat 4: test ! -L on the install root" 0 "" test ! -L "$DEST"
|
||||
|
||||
echo "---"
|
||||
echo "$PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
416
test/labels-reconcile.sh
Normal file
416
test/labels-reconcile.sh
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
#!/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 ---------------------------
|
||||
# With a live request that is the bots' ball; with NO request outstanding it
|
||||
# is the agent's, because nothing is coming until somebody asks.
|
||||
REQUESTED="$BOT3" REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)")"
|
||||
expect "a missing bot WITH a live request is bots-reviewing" state:bots-reviewing "$(decide_state)"
|
||||
REQUESTED=""
|
||||
expect "...but with nobody asked it is the agent's ball" state:addressing "$(decide_state)"
|
||||
expect "...and the blocker names the stall" blocker:unrequested "$(blockers)"
|
||||
|
||||
# -- 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=""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #136: state:needs-human must mean "a human could merge this RIGHT NOW".
|
||||
# Both cases below were observed live in this repo on 2026-07-20, and both
|
||||
# showed state:needs-human while being unmergeable in different ways.
|
||||
# ---------------------------------------------------------------------------
|
||||
ALL_APPROVE="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
||||
|
||||
# -- flavour 1: not mergeable. The merge button is disabled, yet the board
|
||||
# said "your turn" on #119/#120/#127 for hours. The branch fact now rides
|
||||
# the blocker axis; the state says whose ball it is, which is the agent's.
|
||||
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=CONFLICTING CHECKS=SUCCESS
|
||||
expect "a CONFLICTING PR is the agent's, not the human's" state:addressing "$(decide_state)"
|
||||
expect "...and says WHY on the blocker axis" blocker:conflict "$(blockers)"
|
||||
REQUESTED="$HUMAN"
|
||||
expect "...even with the human explicitly requested" state:addressing "$(decide_state)"
|
||||
|
||||
# -- red CI is the same claim, but NOT the same work: a rebase does not fix a
|
||||
# failing test. Collapsing both into one needs-rebase label told the agent
|
||||
# to do the wrong thing, which is why the axis split exists.
|
||||
REQUESTED="" MERGEABLE=MERGEABLE CHECKS=FAILURE
|
||||
expect "a red PR is the agent's" state:addressing "$(decide_state)"
|
||||
expect "...and is distinguishable from a conflict" blocker:ci-red "$(blockers)"
|
||||
REQUESTED="$HUMAN"
|
||||
expect "...and a human request does not override red CI" state:addressing "$(decide_state)"
|
||||
|
||||
# -- both at once. The single-axis design could not say this at all: one label
|
||||
# had to win, and the loser silently vanished off the board.
|
||||
REQUESTED="" MERGEABLE=CONFLICTING CHECKS=FAILURE
|
||||
expect "a conflicted AND red PR reports both blockers" "blocker:conflict
|
||||
blocker:ci-red" "$(blockers)"
|
||||
expect "...and is still just the agent's ball" state:addressing "$(decide_state)"
|
||||
|
||||
# -- UNKNOWN is NOT unmergeable. GitHub reports it for ~a minute after every
|
||||
# merge while it recomputes; treating it as broken would flap every open PR
|
||||
# on each merge — worse than the bug being fixed.
|
||||
REQUESTED="" MERGEABLE=UNKNOWN CHECKS=PENDING
|
||||
expect "UNKNOWN mergeability blocks nothing" state:needs-human "$(decide_state)"
|
||||
expect "...and raises no blocker" "" "$(blockers)"
|
||||
|
||||
# -- blocker:unrequested — the stalled round. Nobody owes an answer because
|
||||
# nobody was ever asked, yet the board read "waiting on the bots" until
|
||||
# `stale` noticed 48h later.
|
||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]'
|
||||
expect "ready, nobody asked, nothing reviewed raises unrequested" blocker:unrequested "$(blockers)"
|
||||
# ...the partial case is equally stalled: one verdict in, nobody asked for the rest
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
|
||||
expect "one bot in, none requested is still unrequested" blocker:unrequested "$(blockers)"
|
||||
# ...a STALE round with nobody asked is the same debt, and arguably worse: the
|
||||
# page carries approvals that no longer describe the tree. Guarding on
|
||||
# MISSING alone let this one through with no blocker at all.
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED oldhead "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED oldhead "" t3)")"
|
||||
expect "a stale round with nobody asked is unrequested too" blocker:unrequested "$(blockers)"
|
||||
expect "...and is still the agent's ball" state:addressing "$(decide_state)"
|
||||
# ...but a live request means an answer IS coming
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
|
||||
REQUESTED="$BOT2"
|
||||
expect "a live bot request is not a stalled round" "" "$(blockers)"
|
||||
# ...and a draft is exempt: the bots ignore drafts by design
|
||||
DRAFT=true REQUESTED="" REVIEWS_JSON='[]'
|
||||
expect "a draft with nobody asked is not stalled" "" "$(blockers)"
|
||||
# ...as is an explicit human request — claiming a PR early is deliberate
|
||||
DRAFT=false REQUESTED="$HUMAN"
|
||||
expect "an early human claim is not a stalled round" "" "$(blockers)"
|
||||
REQUESTED="" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||
|
||||
# -- flavour 2 (the dangerous one): mergeable, green, human requested, and
|
||||
# NOBODY has reviewed this head. Observed on #119 after a rebase: every
|
||||
# signal read "merge me" and nothing on the page contradicted it.
|
||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN"
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED oldhead "" t2)" \
|
||||
"$(rev "$BOT3" APPROVED oldhead "" t3)")"
|
||||
expect "stale approvals outrank the human request (nobody reviewed this tree)" state:addressing "$(decide_state)"
|
||||
|
||||
# -- ...and a round that is BOTH unfinished and staled is still the agent's.
|
||||
# Deciding inside the bot loop made this depend on BOTS order: the MISSING
|
||||
# returned before any later bot's STALE was read, so the mixed round came
|
||||
# out needs-human with nothing bound to the head. Pinned at both ends of
|
||||
# the array, because the whole failure was one of ordering.
|
||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="$HUMAN"
|
||||
REVIEWS_JSON="$(reviews \
|
||||
"$(rev "$BOT1" APPROVED oldhead "" t1)" \
|
||||
"$(rev "$BOT2" APPROVED oldhead "" t2)")"
|
||||
expect "stale approvals + a bot yet to review is addressing, not needs-human" \
|
||||
state:addressing "$(decide_state)"
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT3" APPROVED oldhead "" t3)")"
|
||||
expect "...and the same when the stale verdict is the LAST bot in BOTS" \
|
||||
state:addressing "$(decide_state)"
|
||||
|
||||
# -- but an UNFINISHED round still yields to an explicit human request: a
|
||||
# maintainer pulling a PR to themselves early is deliberate, and was the
|
||||
# original precedence. MISSING differs from STALE — nobody has reviewed
|
||||
# YET, versus everyone reviewed something else.
|
||||
REVIEWS_JSON="$(reviews "$(rev "$BOT1" APPROVED head1 "" t1)")"
|
||||
expect "an unfinished round still yields to an explicit human request" state:needs-human "$(decide_state)"
|
||||
REQUESTED=""
|
||||
expect "...and without that request the agent owes the ask" state:addressing "$(decide_state)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# checks_state: the rollup classifier. It lived inline in main() for the first
|
||||
# round of this PR, which is why nothing here caught it calling ERROR,
|
||||
# CANCELLED and STALE green. Extracted so the enum can be pinned down.
|
||||
# ---------------------------------------------------------------------------
|
||||
rollup() { jq -n --argjson c "$1" '{statusCheckRollup: $c}'; }
|
||||
run_() { jq -n --arg n "$1" --arg o "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
|
||||
'{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o, completedAt:$t}'; }
|
||||
ctx_() { jq -n --arg n "$1" --arg s "$2" --arg t "${3:-2026-07-20T15:00:00Z}" \
|
||||
'{__typename:"StatusContext", context:$n, state:$s, createdAt:$t}'; }
|
||||
|
||||
expect "no checks at all is NONE" NONE "$(rollup '[]' | checks_state)"
|
||||
# A failed fetch leaves no rollup KEY; a PR with no checks leaves an empty
|
||||
# ARRAY. Collapsing the two let an API hiccup read as "nothing is failing" —
|
||||
# the same unknown-certified-as-green shape as #136, in the one place that
|
||||
# fix did not look. The caller skips an UNREADABLE PR rather than relabelling.
|
||||
expect "a failed read is UNREADABLE, not NONE" UNREADABLE "$(echo '{}' | checks_state)"
|
||||
expect "...and a real empty rollup is still NONE" NONE \
|
||||
"$(echo '{"mergeable":"MERGEABLE","statusCheckRollup":[]}' | checks_state)"
|
||||
expect "all green is SUCCESS" SUCCESS \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b SUCCESS)]" | checks_state)"
|
||||
expect "a queued run is PENDING" PENDING \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b QUEUED)]" | checks_state)"
|
||||
expect "a plain failure is FAILURE" FAILURE \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b FAILURE)]" | checks_state)"
|
||||
|
||||
# -- the round-1 gap: outcomes that are neither success nor pending, and that
|
||||
# leave a required check unsatisfied. All three reached the old `else`.
|
||||
expect "a commit status ERROR blocks" FAILURE \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(ctx_ lint ERROR)]" | checks_state)"
|
||||
expect "a CANCELLED run blocks" FAILURE \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)"
|
||||
expect "a STALE run blocks" FAILURE \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b STALE)]" | checks_state)"
|
||||
expect "an outcome the enum does not know blocks, it does not pass" FAILURE \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b SOME_FUTURE_STATE)]" | checks_state)"
|
||||
|
||||
# -- NEUTRAL and SKIPPED satisfy branch protection; path-filtered jobs skip
|
||||
# constantly, and calling that red would park every PR on the agent.
|
||||
expect "NEUTRAL and SKIPPED are not failures" SUCCESS \
|
||||
"$(rollup "[$(run_ a SUCCESS),$(run_ b NEUTRAL),$(run_ c SKIPPED)]" | checks_state)"
|
||||
|
||||
# -- latest-wins. The rollup keeps superseded runs, so this PR's own tip
|
||||
# carried a CANCELLED `scope` beside the SUCCESS `scope` that replaced it.
|
||||
# Without collapsing, making CANCELLED block would strand it forever.
|
||||
expect "a re-run supersedes the cancelled original" SUCCESS \
|
||||
"$(rollup "[$(run_ scope CANCELLED 2026-07-20T15:19:39Z),\
|
||||
$(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
|
||||
expect "...and the reverse order is not a re-run passing, it is one failing" FAILURE \
|
||||
"$(rollup "[$(run_ scope SUCCESS 2026-07-20T15:19:39Z),\
|
||||
$(run_ scope CANCELLED 2026-07-20T15:19:45Z)]" | checks_state)"
|
||||
# same job name in a different workflow is a different context, not a re-run
|
||||
expect "same name in another workflow does not supersede" FAILURE \
|
||||
"$(rollup "[$(jq -n '{__typename:"CheckRun",workflowName:"labels",name:"scope",conclusion:"FAILURE",completedAt:"2026-07-20T15:00:00Z"}'),\
|
||||
$(run_ scope SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
|
||||
|
||||
# -- a run still IN FLIGHT. `run_()` cannot express this: it always carries a
|
||||
# real completedAt, which is exactly why the supersede rule shipped dating
|
||||
# runs by completion and nothing caught it. Both spellings of "no
|
||||
# completion" are pinned, because `gh` emits the zero sentinel (a string,
|
||||
# which `//` does not fall through) while the API emits null.
|
||||
inflight_() { jq -n --arg n "$1" --arg t "$2" --arg c "${3:-0001-01-01T00:00:00Z}" \
|
||||
'{__typename:"CheckRun", workflowName:"ci", name:$n, status:"IN_PROGRESS",
|
||||
conclusion:"", startedAt:$t, completedAt:(if $c == "null" then null else $c end)}'; }
|
||||
|
||||
expect "a re-run in flight beats the success it superseded (zero sentinel)" PENDING \
|
||||
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
|
||||
$(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)"
|
||||
expect "...and the same when the absent completion is null" PENDING \
|
||||
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
|
||||
$(inflight_ build 2026-07-20T15:10:00Z null)]" | checks_state)"
|
||||
expect "a replacement in flight for a CANCELLED run is pending, not failed" PENDING \
|
||||
"$(rollup "[$(run_ build CANCELLED 2026-07-20T15:00:00Z),\
|
||||
$(inflight_ build 2026-07-20T15:10:00Z)]" | checks_state)"
|
||||
# an entry carrying no usable timestamp is treated as newest, not oldest —
|
||||
# ambiguity resolves toward "not settled" rather than toward a stale success.
|
||||
# Guarded by the sort tiebreak rather than the dating expression: reverting
|
||||
# only `at:` leaves this passing, so the two changes are separately pinned.
|
||||
expect "an undateable in-flight run is not discarded for a stale success" PENDING \
|
||||
"$(rollup "[$(run_ build SUCCESS 2026-07-20T15:00:00Z),\
|
||||
$(jq -n '{__typename:"CheckRun",workflowName:"ci",name:"build",conclusion:"",startedAt:null,completedAt:null}')]" \
|
||||
| checks_state)"
|
||||
# ...and the reverse direction, which stops "in flight sorts last" being
|
||||
# widened into "in flight always wins": a run that FINISHED after an earlier
|
||||
# in-flight entry is the newer word, and the context is settled.
|
||||
expect "a finished re-run supersedes an earlier in-flight run" SUCCESS \
|
||||
"$(rollup "[$(inflight_ build 2026-07-20T15:19:00Z),\
|
||||
$(run_ build SUCCESS 2026-07-20T15:19:45Z)]" | checks_state)"
|
||||
|
||||
# -- the wind-down window. A predecessor cancelled by the concurrency group
|
||||
# does not stop the instant its replacement starts, so its completion
|
||||
# routinely lands AFTER the successor's start — on box's aa5a6ba the
|
||||
# replacement started 15:19:38 and the run it cancelled finished 15:19:51.
|
||||
# Dating by "newest stamp of any kind" compares the dead run's completion
|
||||
# against the live run's start, which is not an ordering on runs, and the
|
||||
# predecessor wins. Every fixture above spaces completion before start, so
|
||||
# none of them can see it. run_() cannot express the overlap either — it
|
||||
# carries no startedAt — hence the explicit payloads.
|
||||
overlap_() { jq -n --arg n "$1" --arg o "$2" --arg s "$3" --arg c "$4" \
|
||||
'{__typename:"CheckRun", workflowName:"ci", name:$n, conclusion:$o,
|
||||
startedAt:$s, completedAt:$c}'; }
|
||||
expect "a predecessor finishing after its replacement started is still older (CANCELLED)" PENDING \
|
||||
"$(rollup "[$(overlap_ scope CANCELLED 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\
|
||||
$(inflight_ scope 2026-07-20T15:19:38Z)]" | checks_state)"
|
||||
expect "...and the same when it finished green — mid-flight is not mergeable" PENDING \
|
||||
"$(rollup "[$(overlap_ build SUCCESS 2026-07-20T15:19:00Z 2026-07-20T15:19:51Z),\
|
||||
$(inflight_ build 2026-07-20T15:19:38Z)]" | checks_state)"
|
||||
|
||||
# -- the classifier feeds the state machine: a cancelled required check must
|
||||
# take the PR off the human's plate, which is the whole point of #136.
|
||||
DRAFT=false HEAD_SHA=head1 REQUESTED="$HUMAN" REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE
|
||||
CHECKS="$(rollup "[$(run_ a SUCCESS),$(run_ b CANCELLED)]" | checks_state)"
|
||||
expect "a cancelled check reaches decide_state as the agent's ball" state:addressing "$(decide_state)"
|
||||
expect "...via blocker:ci-red, not a conflict" blocker:ci-red "$(blockers)"
|
||||
|
||||
# -- the happy path survives all of the above.
|
||||
REVIEWS_JSON="$ALL_APPROVE" MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED=""
|
||||
expect "mergeable + green + three head-current approvals is needs-human" state:needs-human "$(decide_state)"
|
||||
# -- and a draft outranks everything, including a conflict.
|
||||
DRAFT=true MERGEABLE=CONFLICTING
|
||||
expect "a draft is building even when conflicted" state:building "$(decide_state)"
|
||||
DRAFT=false MERGEABLE=MERGEABLE CHECKS=SUCCESS REQUESTED="" REVIEWS_JSON='[]'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reconcile_pr's cold-start path. Everything above tests pure functions, which
|
||||
# is exactly why a per-PR `return` in the label pre-flight got through review:
|
||||
# the fixtures could not reach it. A missing state:* label must skip the label
|
||||
# EDIT only — merge-next clearing and the stale sweep are independent of the
|
||||
# taxonomy, and stranding them reintroduced the false-invitation bug (a
|
||||
# `merge-next` claim surviving on a PR the board had moved to the agent).
|
||||
# ---------------------------------------------------------------------------
|
||||
reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr emits
|
||||
(
|
||||
REPO_LABELS="$1" REPO=owner/repo NOW="$(date +%s)"
|
||||
LABELS="merge-next" # the PR carries a queue claim
|
||||
DRAFT=false HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
||||
run() { :; } # swallow mutations
|
||||
gh() { :; } # no network
|
||||
reconcile_pr 777 2>&1
|
||||
)
|
||||
}
|
||||
|
||||
cold="$(reconcile_probe "merge-next")" # state:* labels absent entirely
|
||||
expect "a cold-start repo still clears merge-next" \
|
||||
yes "$(grep -q 'cleared merge-next' <<<"$cold" && echo yes || echo no)"
|
||||
expect "...and still runs the stale sweep" \
|
||||
yes "$(grep -q 'stale (' <<<"$cold" && echo yes || echo no)"
|
||||
expect "...while warning that the state label is missing" \
|
||||
yes "$(grep -q "state label 'state:addressing' does not exist" <<<"$cold" && echo yes || echo no)"
|
||||
|
||||
warm="$(reconcile_probe "$(printf 'state:addressing\nmerge-next\nstale\nblocker:unrequested')")"
|
||||
expect "a bootstrapped repo converges the state as well" \
|
||||
yes "$(grep -q 'state -> state:addressing' <<<"$warm" && echo yes || echo no)"
|
||||
|
||||
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
||||
[ "$fail" -eq 0 ]
|
||||
781
test/release.sh
781
test/release.sh
|
|
@ -1,16 +1,17 @@
|
|||
#!/usr/bin/env bash
|
||||
# Rig's own half of the release surface (#32; trimmed in ceremony#13's
|
||||
# conversion): latest-tag resolution and the installer's three channels.
|
||||
# The machinery halves — changelog extraction, the arming rule,
|
||||
# monotonicity, the drill gate, the workflow-shape pins — moved to
|
||||
# heavy-duty/ceremony, which tests them in its own test/; what stays is
|
||||
# everything that drives rig's install.sh and bin/. Dependency-free and
|
||||
# The release flow's testable half (#32): changelog extraction, latest-tag
|
||||
# resolution, and the installer's three channels. Dependency-free and
|
||||
# NETWORK-FREE — wherever the code under test would call curl, the curl on
|
||||
# PATH is a stub this harness wrote. Run: bash test/release.sh
|
||||
# Deliberately no `set -e` — the harness asserts on failing commands.
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT" || exit 1
|
||||
# The extraction the workflow runs is the extraction under test — one
|
||||
# function, sourced by release.yml and by this harness (repo precedent:
|
||||
# test/labels-reconcile.sh sourcing the reconciler's decide_state).
|
||||
# shellcheck source=.github/scripts/release-lib.sh
|
||||
. "$ROOT/.github/scripts/release-lib.sh"
|
||||
PASS=0 FAIL=0
|
||||
|
||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
||||
|
|
@ -36,6 +37,745 @@ check() {
|
|||
WORK="$(mktemp -d)"
|
||||
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||
|
||||
# --- changelog_section: the release body, extracted --------------------------
|
||||
# A fixture changelog with the three heading shapes the flow produces: the
|
||||
# bare '## Unreleased', stamped '## X.Y.Z — date' releases, and a last
|
||||
# section that runs to EOF.
|
||||
FIXCH="$WORK/CHANGELOG.fixture.md"
|
||||
cat > "$FIXCH" <<'MD'
|
||||
# Changelog
|
||||
|
||||
History before 0.1.0 lives in git.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- an unreleased entry
|
||||
|
||||
## 0.2.0 — 2026-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- **the newer entry** (#42) — prose.
|
||||
|
||||
### Fixed
|
||||
|
||||
- a fix in 0.2.0
|
||||
|
||||
## 0.1.0 — 2026-07-01
|
||||
|
||||
- the first entry
|
||||
MD
|
||||
sect_has() { changelog_section "$1" "$2" | grep -qF -e "$3"; }
|
||||
check "changelog: extracts the asked-for section" 0 "the newer entry" \
|
||||
changelog_section "$FIXCH" 0.2.0
|
||||
check "changelog: the whole section, subheadings included" 0 "a fix in 0.2.0" \
|
||||
changelog_section "$FIXCH" 0.2.0
|
||||
check "changelog: stops at the next release heading" 1 "" \
|
||||
sect_has "$FIXCH" 0.2.0 "the first entry"
|
||||
check "changelog: never leaks the preceding section" 1 "" \
|
||||
sect_has "$FIXCH" 0.2.0 "an unreleased entry"
|
||||
check "changelog: the heading itself is not the body" 1 "" \
|
||||
sect_has "$FIXCH" 0.2.0 "## 0.2.0"
|
||||
first_line() { changelog_section "$1" "$2" | head -n1; }
|
||||
check "changelog: leading blank lines are dropped" 0 "### Added" \
|
||||
first_line "$FIXCH" 0.2.0
|
||||
check "changelog: the bare Unreleased heading matches too" 0 "an unreleased entry" \
|
||||
changelog_section "$FIXCH" Unreleased
|
||||
check "changelog: the last section runs to EOF" 0 "the first entry" \
|
||||
changelog_section "$FIXCH" 0.1.0
|
||||
absent() { [ -z "$(changelog_section "$1" "$2")" ]; }
|
||||
check "changelog: an unknown version yields NOTHING (the refusal signal)" 0 "" \
|
||||
absent "$FIXCH" 3.3.3
|
||||
check "changelog: a date-stamped heading never matches by date" 0 "" \
|
||||
absent "$FIXCH" 2026-07-18
|
||||
|
||||
# ...and the SHIPPED changelog fits the extractor. The real file has two
|
||||
# legitimate states, and the old check knew only one (#44, found the day the
|
||||
# first release PR turned CI red): BETWEEN releases there is an `## Unreleased`
|
||||
# section feature PRs append to; on a `release: X.Y.Z` tree — and on main
|
||||
# right after it, until the next feature PR — that section IS the stamped
|
||||
# `## X.Y.Z — date`. Demanding the literal heading (or, worse, an issue
|
||||
# number inside it) made the release PR of the ceremony unshippable by
|
||||
# construction. What the guard is FOR is format drift: whatever the top
|
||||
# section is called, the exact function release.yml runs must extract it
|
||||
# non-empty.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CHANGELOG.md: has a top section (Unreleased or a stamped release)" 0 "" \
|
||||
bash -c '[ -n "$(grep -m1 "^## " "$1")" ]' _ "$ROOT/CHANGELOG.md"
|
||||
|
||||
# --- the arming rule: is main's changelog ready for a late merge? ------------
|
||||
# #66: stamping the Unreleased heading DISARMS the file. A PR authored before
|
||||
# a release and merged after it wrote its entry under `## Unreleased`; once
|
||||
# that heading has become `## X.Y.Z — date`, git lands the entry under the
|
||||
# release that already shipped — cleanly, no conflict, nothing for the author
|
||||
# to notice. It happened here: #60's #58 entry landed inside `## 0.1.0` at
|
||||
# 67386b4, repaired two minutes later by 0ff520c.
|
||||
#
|
||||
# The check above cannot see this, and #44 is why: demanding a literal
|
||||
# `## Unreleased` is FALSE BY CONSTRUCTION on the tree the ceremony's own PR
|
||||
# produces, which made the release PR unshippable. That relaxation must not
|
||||
# be undone.
|
||||
#
|
||||
# What distinguishes the two states the old guard collapsed is VERSION.
|
||||
# A stamped top section is legal exactly when VERSION is bare — the ceremony
|
||||
# PR, and main until the -dev bump lands. The moment VERSION carries -dev,
|
||||
# main is a place feature PRs merge into, and the top section MUST be
|
||||
# `## Unreleased` or the next late merge is misfiled.
|
||||
#
|
||||
# Note the asymmetry, which is deliberate: on a BARE version the top heading
|
||||
# is not constrained at all. The ceremony re-arms in the same PR
|
||||
# (CONTRIBUTING step 1), so its tree legitimately carries an EMPTY
|
||||
# `## Unreleased` above the section it just stamped — and an empty top
|
||||
# section is exactly what the old non-empty assert would have rejected.
|
||||
# What must extract non-empty on a bare VERSION is the section that SHIPS,
|
||||
# which is the same assert release.yml makes before it publishes.
|
||||
#
|
||||
# changelog_armed <version> <changelog-file> — 0 armed, 1 disarmed.
|
||||
changelog_armed() {
|
||||
local ver="$1" file="$2" top
|
||||
top="$(grep -m1 '^## ' "$file")"
|
||||
[ -n "$top" ] || return 1
|
||||
case "$ver" in
|
||||
*-dev) [ "$top" = "## Unreleased" ] ;;
|
||||
*) [ -n "$(changelog_section "$file" "$ver")" ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# The guard itself, against the real tree.
|
||||
check "CHANGELOG.md: armed for the VERSION it carries (#66)" 0 "" \
|
||||
changelog_armed "$(cat "$ROOT/VERSION")" "$ROOT/CHANGELOG.md"
|
||||
|
||||
# ...and the rule proven against trees built for the purpose, because a guard
|
||||
# that is only ever run against a passing tree has not been shown to fail.
|
||||
# Each is a real VERSION + CHANGELOG.md pair the flow actually produces.
|
||||
armtree() { # armtree <name> <version> <changelog-body...> -> prints the dir
|
||||
local d="$WORK/arm-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION"
|
||||
shift 2; printf '%s\n' "$@" > "$d/CHANGELOG.md"; printf '%s' "$d"
|
||||
}
|
||||
armed() { changelog_armed "$(cat "$1/VERSION")" "$1/CHANGELOG.md"; }
|
||||
|
||||
# The ceremony PR's own tree, re-armed per CONTRIBUTING step 1: VERSION bare,
|
||||
# an empty Unreleased sitting above the section it just stamped. GREEN — this
|
||||
# is the case #44 was about, and the empty section must not break it.
|
||||
T="$(armtree ceremony 0.2.0 '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: the re-armed ceremony tree passes (#44 stays fixed)" 0 "" armed "$T"
|
||||
|
||||
# The same ceremony WITHOUT the re-arm — old-style, stamped straight over the
|
||||
# heading. Also GREEN: VERSION is bare, so a stamped top is legal. The guard
|
||||
# refuses to make the ceremony unshippable, which is the whole #44 lesson.
|
||||
T="$(armtree ceremony-old 0.2.0 '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: an un-re-armed ceremony tree still passes (bare VERSION)" 0 "" armed "$T"
|
||||
|
||||
# main AFTER release.yml's -dev bump, with the changelog left disarmed. This
|
||||
# is #66 exactly, and the state cast sat in at the time of writing. RED.
|
||||
T="$(armtree disarmed 0.2.1-dev '# Changelog' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: a -dev main with a stamped top section FAILS (#66)" 1 "" armed "$T"
|
||||
|
||||
# The same main, re-armed. The Unreleased section is EMPTY — no feature PR has
|
||||
# merged since the release — and that is a correct, expected state. GREEN.
|
||||
T="$(armtree rearmed 0.2.1-dev '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: a -dev main with an EMPTY Unreleased passes (no entries yet)" 0 "" armed "$T"
|
||||
|
||||
# Steady state between releases: entries accumulating under Unreleased.
|
||||
T="$(armtree steady 0.2.1-dev '# Changelog' '' '## Unreleased' '' '### Fixed' '' '- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' '- **A shipped thing** (#1) — prose.')"
|
||||
check "arming: the normal between-releases tree passes" 0 "" armed "$T"
|
||||
|
||||
# A release PR that bumped VERSION but forgot to stamp: the version it claims
|
||||
# to ship has no section, so release.yml would publish empty notes. RED here,
|
||||
# one round earlier than the workflow's own refusal.
|
||||
T="$(armtree unstamped 0.3.0 '# Changelog' '' '## Unreleased' '' '- **A pending thing** (#2) — prose.')"
|
||||
check "arming: a bare VERSION whose section was never stamped FAILS" 1 "" armed "$T"
|
||||
|
||||
# And a file with no '## ' heading at all is disarmed, not silently fine.
|
||||
T="$(armtree headless 0.2.1-dev '# Changelog' '' 'no sections here')"
|
||||
check "arming: a changelog with no sections FAILS" 1 "" armed "$T"
|
||||
|
||||
# --- the monotonicity rule: was a SHIPPED heading deleted? -------------------
|
||||
# #98. Arming asks about ONE heading — does the top section agree with
|
||||
# VERSION? — so it is silent about the rest of the file. The failure it cannot
|
||||
# see is an entry written under '## Unreleased' that REPLACES the heading
|
||||
# below it instead of inserting above it: git merges the one-line edit
|
||||
# cleanly, arming stays green (the top section is still right), and the
|
||||
# shipped release loses its section entirely. "A heading disappeared" is not a
|
||||
# property of a tree, it is a property of a DIFF — so unlike every check
|
||||
# above, these cases need real git repos, which is why the guard is its own
|
||||
# script rather than a function sourced here.
|
||||
MONO="$ROOT/.github/scripts/changelog-monotonic.sh"
|
||||
check "changelog-monotonic.sh: exists and is the guard under test" 0 "" test -f "$MONO"
|
||||
|
||||
# The stock changelog every case below starts from: an Unreleased section and
|
||||
# two shipped releases, committed on branch 'base' — which plays origin/main.
|
||||
# The caller then rewrites CHANGELOG.md on 'work' and commits.
|
||||
MONO_BASE=('# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.')
|
||||
monorepo() { # monorepo <name> -> prints the dir, left checked out on 'work'
|
||||
local d="$WORK/mono-$1"; mkdir -p "$d"
|
||||
git -C "$d" init -q -b base
|
||||
git -C "$d" config user.email harness@example.invalid
|
||||
git -C "$d" config user.name harness
|
||||
printf '%s\n' "${MONO_BASE[@]}" > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md
|
||||
git -C "$d" commit -qm 'base: two shipped releases'
|
||||
git -C "$d" checkout -q -b work
|
||||
printf '%s' "$d"
|
||||
}
|
||||
monowrite() { # monowrite <dir> <line...> — rewrite CHANGELOG.md and commit
|
||||
local d="$1"; shift
|
||||
printf '%s\n' "$@" > "$d/CHANGELOG.md"
|
||||
git -C "$d" commit -qam 'work: edit the changelog'
|
||||
}
|
||||
mono() { # mono <dir> [VAR=val ...] — run the guard there, base ref 'base'
|
||||
local d="$1"; shift
|
||||
( cd "$d" && env "$@" bash "$MONO" base 2>&1 )
|
||||
}
|
||||
|
||||
# An untouched branch with NO commit of its own: 'work' still points at the
|
||||
# base commit, so the merge base IS HEAD and containment compared the file
|
||||
# against itself. That is the vacuous path (#98), not a containment result —
|
||||
# the green message therefore names uniqueness, the half that actually ran.
|
||||
# A guard that prints nothing is indistinguishable from one that did nothing,
|
||||
# but a guard that prints the WRONG half is worse: it is a false receipt.
|
||||
T="$(monorepo clean)"
|
||||
check "monotonic: an untouched branch passes" 0 "uniqueness on HEAD checked 2" mono "$T"
|
||||
check "monotonic: ...saying containment was VACUOUS, not that it verified 2" 0 \
|
||||
"containment vacuous" mono "$T"
|
||||
# A negative, because the point is that the two wordings do NOT collapse: with
|
||||
# the pull_request gate gone (#98) this is the shape of EVERY push to main, and
|
||||
# "are still present" there would be a containment claim on the one event where
|
||||
# deletion is undetectable by construction.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "monotonic: ...and never claims the headings are still present" 1 "" \
|
||||
bash -c 'cd "$1" && bash "$2" base | grep -q "are still present"' _ "$T" "$MONO"
|
||||
|
||||
# The same shape against a REAL base — an unrelated commit on 'work', the
|
||||
# changelog untouched — which is what an untouched-changelog PR branch
|
||||
# actually looks like. Here containment genuinely ran and held, so this is
|
||||
# the case that pins the containment wording and its count. The two forms
|
||||
# must not collapse into one another.
|
||||
T="$(monorepo clean-realbase)"
|
||||
printf '%s\n' '# rig' > "$T/README.md"
|
||||
git -C "$T" add README.md
|
||||
git -C "$T" commit -qm 'work: an unrelated commit, changelog untouched'
|
||||
check "monotonic: an untouched changelog on a REAL base reports containment" 0 \
|
||||
"all 2 release heading(s)" mono "$T"
|
||||
check "monotonic: ...and says they are still present, the containment claim" 0 \
|
||||
"are still present" mono "$T"
|
||||
|
||||
# The legitimate edit this guard must never object to: a new entry INSERTED
|
||||
# above the shipped heading, which is left alone.
|
||||
T="$(monorepo insert)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: an entry inserted ABOVE the shipped heading passes" 0 "" mono "$T"
|
||||
|
||||
# ...and the bug itself: the same entry typed OVER '## 0.2.0'. 0.2.0's body is
|
||||
# now under '## Unreleased' and 0.2.0 has no section. RED, naming the version.
|
||||
T="$(monorepo deleted)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '### Fixed' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: a DELETED shipped heading FAILS (#98)" 1 "DELETES release heading" mono "$T"
|
||||
check "monotonic: ...and the failure names the version that vanished" 1 "## 0.2.0" mono "$T"
|
||||
|
||||
# Deleting the OLDEST release is the same defect, not a lesser one — the set
|
||||
# is a set, position in the file buys no leniency.
|
||||
T="$(monorepo deleted-old)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.'
|
||||
check "monotonic: deleting an OLDER release heading fails too" 1 "## 0.1.0" mono "$T"
|
||||
|
||||
# The duplicate half. Containment cannot catch this: the second copy is
|
||||
# head-side SURPLUS and `comm -23` (base minus head) is blind to extras on the
|
||||
# head side, so uniqueness-on-HEAD is a separate assert. rig's symptom is not
|
||||
# box's — changelog_section() has `if (found) exit`, so it stops at the second
|
||||
# copy and TRUNCATES rather than absorbing.
|
||||
T="$(monorepo dupe)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: a DUPLICATED version heading FAILS" 1 "DUPLICATE release heading" mono "$T"
|
||||
check "monotonic: ...and the failure names the repeated version" 1 "## 0.2.0" mono "$T"
|
||||
# ...and that the duplicate really does truncate, so the assert above is
|
||||
# guarding a live defect rather than a stylistic preference: extraction stops
|
||||
# at the second copy, dropping the body that sits under it.
|
||||
check "monotonic: the duplicate TRUNCATES extraction (rig's symptom, not box's)" 0 \
|
||||
"A pending thing" changelog_section "$T/CHANGELOG.md" 0.2.0
|
||||
check "monotonic: ...the real body under the second copy is dropped" 1 "" \
|
||||
sect_has "$T/CHANGELOG.md" 0.2.0 "A shipped thing"
|
||||
|
||||
# '## Unreleased' is deliberately OUTSIDE the guarded set: it fails the
|
||||
# version shape, so the ceremony stamping it away — the one edit that legally
|
||||
# removes a top heading — is invisible here. This is the case that would make
|
||||
# every release PR unshippable if the set were "all '## ' headings".
|
||||
T="$(monorepo stamp)"
|
||||
monowrite "$T" '# Changelog' '' '## 0.3.0 — 2026-07-20' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: stamping '## Unreleased' into a release passes (not guarded)" 0 "" mono "$T"
|
||||
# ...and the ceremony's re-arm — a fresh empty Unreleased above the stamp —
|
||||
# is equally fine, which is CONTRIBUTING step 1's tree.
|
||||
T="$(monorepo stamp-rearmed)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.3.0 — 2026-07-20' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: the re-armed ceremony tree passes too" 0 "" mono "$T"
|
||||
|
||||
# The skip path, both halves. A base ref that does not resolve is a sensible
|
||||
# local degradation — and a silent one, which is the failure shape this family
|
||||
# of checks exists to refuse. So STRICT flips exactly that case red.
|
||||
mono_noref() { local d="$1"; shift; ( cd "$d" && env "$@" bash "$MONO" no/such/ref 2>&1 ); }
|
||||
T="$(monorepo noref)"
|
||||
check "monotonic: an unresolvable base ref SKIPS containment locally" 0 "containment SKIPPED" mono_noref "$T"
|
||||
check "monotonic: ...and the skip says uniqueness already ran, not that nothing did" 0 \
|
||||
"already ran and passed" mono_noref "$T"
|
||||
check "monotonic: ...but is a FAILURE under STRICT=1 (what CI sets)" 1 "STRICT=1" \
|
||||
mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1
|
||||
check "monotonic: ...and the STRICT failure blames the checkout, not the script" 1 \
|
||||
"fetch-depth: 0" mono_noref "$T" CHANGELOG_MONOTONIC_STRICT=1
|
||||
|
||||
# A missing changelog is an error on any setting — it is not a degradation,
|
||||
# it is a wrong invocation.
|
||||
T="$(monorepo nofile)"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "monotonic: a missing changelog file is an error, never a skip" 1 "no such file" \
|
||||
bash -c 'cd "$1" && bash "$2" base nope.md 2>&1' _ "$T" "$MONO"
|
||||
|
||||
# --- #98: uniqueness is a property of HEAD, so nothing base-side may gate it --
|
||||
# Containment needs the merge base. Uniqueness needs only the file in front of
|
||||
# it. As first written (and as inherited from heavy-duty/box, fixed there in
|
||||
# box#144 for box#143) the duplicate check sat DOWNSTREAM of the base-ref,
|
||||
# merge-base and base-blob conditions, so each of the degradation paths below
|
||||
# exited 0 on a tree carrying a duplicate in plain sight — the base-blob one
|
||||
# not even through skip(), but a bare `exit 0` that STRICT could not reach.
|
||||
#
|
||||
# These cases pin the ORDER, which is the actual invariant. Every monorepo
|
||||
# fixture above commits MONO_BASE on 'base', so no case up there ever reaches
|
||||
# the base-absent branch at all; and asserting the exit code alone is what let
|
||||
# the original ship, since the clean base-absent case is green either way.
|
||||
mononocl() { # mononocl <name> -> a repo whose 'base' has NO changelog, on 'work'
|
||||
local d="$WORK/mono-$1"; mkdir -p "$d"
|
||||
git -C "$d" init -q -b base
|
||||
git -C "$d" config user.email harness@example.invalid
|
||||
git -C "$d" config user.name harness
|
||||
printf '%s\n' '# rig' > "$d/README.md"
|
||||
git -C "$d" add README.md
|
||||
git -C "$d" commit -qm 'base: no changelog yet'
|
||||
git -C "$d" checkout -q -b work
|
||||
printf '%s' "$d"
|
||||
}
|
||||
monoadd() { # monoadd <dir> <line...> — the branch INTRODUCES CHANGELOG.md
|
||||
local d="$1"; shift
|
||||
printf '%s\n' "$@" > "$d/CHANGELOG.md"
|
||||
git -C "$d" add CHANGELOG.md
|
||||
git -C "$d" commit -qm 'work: introduce the changelog'
|
||||
}
|
||||
|
||||
# The changelog is absent at the merge base AND the branch introduces a
|
||||
# duplicate. Before the fix this exited 0 on "nothing could have been deleted".
|
||||
T="$(mononocl 98-newdup)"
|
||||
monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.'
|
||||
check "monotonic: a duplicate introduced where the base had NO changelog is CAUGHT (#98)" 1 \
|
||||
"DUPLICATE release heading" mono "$T"
|
||||
check "monotonic: ...and STRICT does not change that (it was never a skip)" 1 \
|
||||
"DUPLICATE release heading" mono "$T" CHANGELOG_MONOTONIC_STRICT=1
|
||||
# ...and the clean counterpart still passes, now SAYING uniqueness ran. Without
|
||||
# this the case above could be satisfied by failing the base-absent path
|
||||
# outright, which would redden every changelog-introducing branch.
|
||||
T="$(mononocl 98-newok)"
|
||||
monoadd "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.'
|
||||
check "monotonic: ...while a CLEAN introduced changelog still passes" 0 \
|
||||
"nothing could have been deleted" mono "$T"
|
||||
check "monotonic: ...saying uniqueness was checked, not that nothing was" 0 \
|
||||
"uniqueness on HEAD already passed" mono "$T"
|
||||
|
||||
# No git at all (a tarball, an unpacked release): uniqueness still has
|
||||
# everything it needs, so a duplicate is caught rather than skipped past.
|
||||
mkdir -p "$WORK/mono-98-nogit"
|
||||
printf '%s\n' '# Changelog' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'## 0.2.0 — 2026-07-19' > "$WORK/mono-98-nogit/CHANGELOG.md"
|
||||
check "monotonic: a duplicate OUTSIDE a git work tree is caught (#98)" 1 \
|
||||
"DUPLICATE release heading" mono "$WORK/mono-98-nogit"
|
||||
|
||||
# An unresolvable base ref: same — the skip belongs to containment, not to the
|
||||
# script, so uniqueness has already run by the time skip() is reachable.
|
||||
T="$(monorepo 98-nobase)"
|
||||
monowrite "$T" '# Changelog' '' '## Unreleased' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A pending thing** (#2) — prose.' '' '## 0.2.0 — 2026-07-19' '' \
|
||||
'- **A shipped thing** (#1) — prose.' '' '## 0.1.0 — 2026-07-01' '' \
|
||||
'- **The first thing** (#0) — prose.'
|
||||
check "monotonic: a duplicate is caught even when the base ref will not resolve (#98)" 1 \
|
||||
"DUPLICATE release heading" mono_noref "$T"
|
||||
|
||||
# --- ci.yml: the monotonic step is actually wired (#98) ----------------------
|
||||
# The guard runs from ci.yml, not from this suite, so pin the wiring the same
|
||||
# way release.yml's is pinned — a script nothing invokes is not a check.
|
||||
CIY="$ROOT/.github/workflows/ci.yml"
|
||||
check "ci.yml: runs the monotonic guard" 0 "" \
|
||||
grep -q "changelog-monotonic.sh" "$CIY"
|
||||
check "ci.yml: ...with STRICT=1, so a skip is red rather than quietly green" 0 "" \
|
||||
grep -qF "CHANGELOG_MONOTONIC_STRICT: '1'" "$CIY"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "ci.yml: ...against the PR's base branch" 0 "" \
|
||||
grep -qF 'origin/${{ github.base_ref' "$CIY"
|
||||
# The step must NOT be pull-request-only. 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 left a duplicate reaching main by any other route
|
||||
# unasserted. Dropping the gate is only safe with the ref_name fallback:
|
||||
# `github.base_ref` is EMPTY on a push, a bare `origin/` does not resolve, and
|
||||
# STRICT=1 promotes that to a hard failure on every push to main.
|
||||
#
|
||||
# Scoped to the step's OWN block, deliberately. As a file-wide grep this
|
||||
# negative forbade any FUTURE step in ci.yml from being pull_request-gated and
|
||||
# would have failed citing #98 when one legitimately was — #98 constrains this
|
||||
# step, not the file. The companion check below is what keeps the awk honest:
|
||||
# an extractor that matched nothing would turn the negative into a tautology
|
||||
# that passes forever, including after someone renames the step and re-adds
|
||||
# the gate.
|
||||
# Terminates on a new STEP or a new JOB. The job boundary is not optional: the
|
||||
# monotonic step is the LAST step of its job, so stopping only at the next
|
||||
# `- name:` runs the block into the job below and swallows that job's
|
||||
# level `if:` — the same bug this scoping fixed, moved from "any step in the
|
||||
# file" to "this step plus the head of the next job" (found on box#144).
|
||||
mono_step_block() {
|
||||
awk '/^ - name: no shipped changelog heading/ {f=1; print; next}
|
||||
f && (/^ - / || /^ [^ ]/) {exit}
|
||||
f {print}' "$CIY"
|
||||
}
|
||||
# Anchored: an `if:` inside a `run:` line is not a step condition.
|
||||
mono_step_gated() { mono_step_block | grep -q '^ if:'; }
|
||||
check "ci.yml: the monotonic step itself is NOT pull_request-gated (#98)" 1 "" \
|
||||
mono_step_gated
|
||||
check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \
|
||||
"changelog-monotonic" mono_step_block
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "ci.yml: ...and falls back to ref_name, so a push has a base to resolve" 0 "" \
|
||||
grep -qF 'github.base_ref || github.ref_name' "$CIY"
|
||||
# Without full history the base ref does not resolve, and STRICT turns that
|
||||
# into a red run — so the fetch depth is load-bearing, not incidental.
|
||||
check "ci.yml: the checkout has full history (the base ref must resolve)" 0 "" \
|
||||
grep -qF "fetch-depth: 0" "$CIY"
|
||||
|
||||
# --- the drill rule: does the version being shipped have a record? ----------
|
||||
# CONTRIBUTING ("Releasing") has always required a real-hardware drill and
|
||||
# nothing enforced it, so no release in this family has ever carried one: every
|
||||
# other ceremony step is checked by a script, and the one that costs an
|
||||
# afternoon was checked by a reviewer remembering. A bot finally blocked on it.
|
||||
#
|
||||
# Records are ONE FILE PER VERSION, at drills/<version>.md. The first cut of
|
||||
# this guard kept them as sections in a single drill/RUNS.md and needed a
|
||||
# heading grammar, an optional-date tail, a whole-version comparison and a
|
||||
# non-blank-body rule to read them back — all of it there only because the
|
||||
# records shared a file, and both sibling repos shipped a defect out of it in
|
||||
# review. Splitting the files deletes most of these tests along with the code
|
||||
# they covered: `0.3.0.md` and `0.3.0-rc1.md` cannot be confused by any
|
||||
# grammar, because there is no grammar.
|
||||
#
|
||||
# Fixtures carry their OWN version file and their OWN drills dir, inside the
|
||||
# fixture dir. This is not tidiness — it is heavy-duty/box#146, verbatim:
|
||||
# fixtures that read the REPO's VERSION exercised the `-dev` branch on every
|
||||
# ordinary tree, so the whole bare-version half of the guard was untested and
|
||||
# went red for the first time while somebody was cutting a release. A fixture
|
||||
# must state the tree it is about.
|
||||
DRILL="$ROOT/.github/scripts/drill-recorded.sh"
|
||||
check "drill-recorded.sh: exists and is the guard under test" 0 "" test -f "$DRILL"
|
||||
check "drill-recorded.sh: is executable" 0 "" test -x "$DRILL"
|
||||
|
||||
drilltree() { # drilltree <name> <version> -> prints the dir (no drills/ yet)
|
||||
local d="$WORK/drill-$1"; mkdir -p "$d"; printf '%s\n' "$2" > "$d/VERSION"
|
||||
printf '%s' "$d"
|
||||
}
|
||||
drillrec() { # drillrec <dir> <version> <line...> — write drills/<version>.md
|
||||
mkdir -p "$1/drills"; local f="$1/drills/$2.md"; shift 2
|
||||
printf '%s\n' "$@" > "$f"
|
||||
}
|
||||
drill() { bash "$DRILL" "$1/drills" "$1/VERSION" 2>&1; }
|
||||
|
||||
# A development tree. Vacuous by construction — every ordinary PR looks like
|
||||
# this, and none of them can be asked to have drilled a release that does not
|
||||
# exist. It passes with no drills/ directory present AT ALL, which is the
|
||||
# state this repo ships in today.
|
||||
T="$(drilltree dev 0.2.1-dev)"
|
||||
check "drill: a -dev tree passes with NO drills dir at all" 0 "" drill "$T"
|
||||
check "drill: ...saying so out loud, not exiting 0 in silence" 0 \
|
||||
"nothing to assert" drill "$T"
|
||||
|
||||
# The release ceremony tree, drilled and recorded. GREEN.
|
||||
T="$(drilltree recorded 0.3.0)"
|
||||
drillrec "$T" 0.3.0 '# Release drill — 0.3.0 — 2026-07-21' '' \
|
||||
'Host: bare Debian 13. Candidate refs pinned: box@1a2b3c4, cast@9a0b1c2.' '' \
|
||||
'- convergence, then re-converge: clean' \
|
||||
'- db-integration: 14/14' '- runner lifecycle: PASS'
|
||||
drillrec "$T" 0.2.0 '# Release drill — 0.2.0 — 2026-07-01' '' 'an older run'
|
||||
check "drill: a bare VERSION with a non-empty record for it passes" 0 \
|
||||
"records a drill for 0.3.0" drill "$T"
|
||||
|
||||
# The gate itself: a release tree with no drills/ directory at all. RED,
|
||||
# naming the version, because "which release is unevidenced" is the only fact
|
||||
# the author needs. This is the state a repo is in the first time it cuts a
|
||||
# release under the gate — it must read as a to-do, not a broken invocation.
|
||||
T="$(drilltree norecord 0.3.0)"
|
||||
check "drill: a bare VERSION with NO drills dir FAILS" 1 \
|
||||
"no drill record" drill "$T"
|
||||
check "drill: ...and the failure names the version" 1 "VERSION is 0.3.0" drill "$T"
|
||||
check "drill: ...and names the file it wanted" 1 "drills/0.3.0.md" drill "$T"
|
||||
|
||||
# A drills/ that exists but holds nothing for THIS version. Same failure —
|
||||
# other releases having been drilled says nothing about this one.
|
||||
T="$(drilltree otherversion 0.4.0)"
|
||||
drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the previous release'
|
||||
check "drill: a drills dir with no file for THIS version FAILS" 1 \
|
||||
"no drill record" drill "$T"
|
||||
check "drill: ...naming the version that is unevidenced" 1 "VERSION is 0.4.0" drill "$T"
|
||||
|
||||
# ...and the failure has to say how to get out of it. Both moves are a commit
|
||||
# on the PR, and the second one is the point of asking for a RECORD rather
|
||||
# than a RESULT: a waiver is allowed, it just cannot be silent.
|
||||
check "drill: ...and the failure names the unblock — run the drill" 1 \
|
||||
"RUN THE DRILL" drill "$T"
|
||||
check "drill: ...and the waiver, recorded, as the other way out" 1 \
|
||||
"MAINTAINER WAIVER" drill "$T"
|
||||
check "drill: ...and points at the README for what a record contains" 1 \
|
||||
"README.md" drill "$T"
|
||||
|
||||
# An EMPTY file at the right path. This is the failure a laxer guard invites —
|
||||
# the ceremony PR touches the file to get green and fills it in never.
|
||||
T="$(drilltree emptyfile 0.3.0)"
|
||||
mkdir -p "$T/drills"; : > "$T/drills/0.3.0.md"
|
||||
check "drill: a PRESENT but EMPTY record FAILS" 1 "no drill record" drill "$T"
|
||||
|
||||
# ...and WHITESPACE is not a record either. This is the ONE piece of the old
|
||||
# section-parsing rule set that splitting the files did not make
|
||||
# unrepresentable, so it is the one that still needs a test. It is here because
|
||||
# the siblings got it wrong: box#149 and cast#138 both extracted with
|
||||
# `sed '/./,$!d'`, where `.` matches a space, so one tab satisfied the gate and
|
||||
# shipped an evidence-free release. All three reviewers caught it there.
|
||||
# Nothing caught it here, because there was nothing to catch — which is exactly
|
||||
# the state in which a later simplification quietly reintroduces it.
|
||||
T="$(drilltree blank 0.3.0)"
|
||||
drillrec "$T" 0.3.0 ' ' ' ' ''
|
||||
check "drill: a record of only spaces, tabs and newlines FAILS (box#149, cast#138)" \
|
||||
1 "no drill record" drill "$T"
|
||||
|
||||
# The version is matched WHOLE, both directions — and now the filesystem does
|
||||
# it, since the version IS the filename. A drill run against a release
|
||||
# candidate is not evidence for the final release, and the reverse is equally
|
||||
# false: in both cases the string that matched is not the artefact that ships.
|
||||
T="$(drilltree whole-rc 0.3.0)"
|
||||
drillrec "$T" 0.3.0-rc1 '# Release drill — 0.3.0-rc1' '' 'the rc drill'
|
||||
check "drill: an -rc1 record does NOT satisfy the bare version" 1 \
|
||||
"no drill record" drill "$T"
|
||||
T="$(drilltree whole-final 0.3.0-rc1)"
|
||||
drillrec "$T" 0.3.0 '# Release drill — 0.3.0' '' 'the final drill'
|
||||
check "drill: ...and a bare-version record does NOT satisfy the -rc1" 1 \
|
||||
"no drill record" drill "$T"
|
||||
|
||||
# A missing VERSION file is a wrong invocation, not a degradation — there is
|
||||
# no version to be lenient about, so it must never read as a pass.
|
||||
T="$(drilltree noversion 0.3.0)"
|
||||
rm -f "$T/VERSION"
|
||||
check "drill: a missing VERSION file is an error, never a pass" 1 "no such file" drill "$T"
|
||||
|
||||
# The real files, last. The shipped README must be readable, and the guard the
|
||||
# repo actually runs must pass on the VERSION the repo actually carries.
|
||||
check "drills/README.md: exists" 0 "" test -f "$ROOT/drills/README.md"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "drills/README.md: documents the one-file-per-version naming rule" 0 "" \
|
||||
bash -c 'grep -qF "drills/<version>.md" "$1"' _ "$ROOT/drills/README.md"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "drills/README.md: says a FAILED drill is still a valid record" 0 "" \
|
||||
bash -c 'grep -qi "failed drill is still a valid record" "$1"' _ "$ROOT/drills/README.md"
|
||||
# The old single-file record must be gone, not merely unreferenced: a stale
|
||||
# drill/RUNS.md would be a second place to write a record that nothing reads.
|
||||
check "drill/RUNS.md: is gone — records live one per version now" 1 "" \
|
||||
test -e "$ROOT/drill/RUNS.md"
|
||||
# The property is that the guard's VERDICT IS CORRECT FOR THIS TREE — not that
|
||||
# it always passes. Those come apart on a ceremony tree: a -dev tree is vacuous
|
||||
# and must pass, but a ceremony tree passes only once a human has run the drill
|
||||
# and written the record, which is the entire point of the gate. Asserting
|
||||
# exit 0 unconditionally made test/release.sh UN-GREENABLE on every release
|
||||
# branch before its drill, and surfaced as a `release-flow tests` failure rather
|
||||
# than as the gate doing its job — the same misattribution shape as box#146,
|
||||
# where a fixture read the repo's real VERSION and only misbehaved on the
|
||||
# ceremony tree. Caught when box#148 went red for the wrong-looking reason.
|
||||
THIS_VER="$(tr -d '[:space:]' < "$ROOT/VERSION")"
|
||||
case "$THIS_VER" in
|
||||
*-dev)
|
||||
check "drill-recorded.sh: THIS tree is -dev, and the guard is vacuous on it" 0 "" \
|
||||
bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION" ;;
|
||||
*)
|
||||
if [ -s "$ROOT/drills/$THIS_VER.md" ]; then
|
||||
check "drill-recorded.sh: THIS ceremony tree HAS its record, and the guard accepts it" 0 "" \
|
||||
bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION"
|
||||
else
|
||||
check "drill-recorded.sh: THIS ceremony tree has NO record yet, and the guard refuses it" 1 "no drill record at" \
|
||||
bash "$DRILL" "$ROOT/drills" "$ROOT/VERSION"
|
||||
fi ;;
|
||||
esac
|
||||
# ...and with no arguments at all, since that is how ci.yml invokes it. The
|
||||
# defaults must be the paths this repo actually uses.
|
||||
# ...and with its DEFAULT arguments, as CI runs it. What this pins is that the
|
||||
# defaults ARE the paths this repo uses — so it asserts the defaults reach the
|
||||
# same verdict as the explicit call above, not a fixed exit code. Hard-coding 0
|
||||
# here would fail on a ceremony tree for the same wrong reason the check above
|
||||
# used to.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "drill-recorded.sh: ...and its DEFAULT arguments agree, as CI runs it" 0 "" \
|
||||
bash -c '
|
||||
cd "$1" || exit 9
|
||||
bash .github/scripts/drill-recorded.sh >/dev/null 2>&1; d=$?
|
||||
bash .github/scripts/drill-recorded.sh drills VERSION >/dev/null 2>&1; e=$?
|
||||
[ "$d" -eq "$e" ]' _ "$ROOT"
|
||||
|
||||
# ci.yml: the guard runs from there, so pin the wiring — a script nothing
|
||||
# invokes is not a check (same reasoning as the monotonic pins above).
|
||||
check "ci.yml: runs the drill guard" 0 "" grep -q "drill-recorded.sh" "$CIY"
|
||||
# ...and is NOT trigger-gated. It is vacuous on every -dev tree already, so an
|
||||
# `if:` could only ever exempt the one tree it exists for.
|
||||
drill_step_block() {
|
||||
awk '/^ - name: a release version has a recorded drill/ {f=1; print; next}
|
||||
f && (/^ - / || /^ [^ ]/) {exit}
|
||||
f {print}' "$CIY"
|
||||
}
|
||||
drill_step_gated() { drill_step_block | grep -q '^ if:'; }
|
||||
check "ci.yml: the drill step itself is NOT trigger-gated" 1 "" drill_step_gated
|
||||
check "ci.yml: ...and the block was actually found (guards the awk above)" 0 \
|
||||
"drill-recorded" drill_step_block
|
||||
|
||||
# CONTRIBUTING must state the gate, and must state what the drill actually is.
|
||||
# The three repos' drills are INDEPENDENT — run in any order, on any schedule —
|
||||
# and what makes that safe is that each one pins the same fixed set of
|
||||
# CANDIDATE refs, so box and rig measure the same pair. That, not sequencing,
|
||||
# is what dissolves the mutual recursion (rig builds the host box runs on, and
|
||||
# box mints the seeds rig converges). An earlier draft of this doc claimed a
|
||||
# fixed box → rig → cast release order; it is wrong, and this pins the
|
||||
# correction.
|
||||
CONTRIB="$ROOT/CONTRIBUTING.md"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: the release flow names the drill gate" 0 "" \
|
||||
bash -c 'grep -qF "drills/<version>.md" "$1"' _ "$CONTRIB"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...and says the three repos' drills are INDEPENDENT" 0 "" \
|
||||
bash -c 'grep -qi "drills are independent" "$1"' _ "$CONTRIB"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...pinned to one fixed set of candidate refs" 0 "" \
|
||||
bash -c 'grep -qi "same fixed set of candidate refs" "$1"' _ "$CONTRIB"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...which is what dissolves the recursion, not sequencing" 0 "" \
|
||||
bash -c 'grep -qF "RIG_REF" "$1"' _ "$CONTRIB"
|
||||
# The negative that keeps the correction from being re-lost: no fixed release
|
||||
# order may be claimed. Nothing requires box to ship before rig.
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "CONTRIBUTING: ...and never claims a fixed box-then-rig release order" 1 "" \
|
||||
bash -c 'grep -qi "box first, then rig" "$1"' _ "$CONTRIB"
|
||||
|
||||
# --- release.yml: the pins ---------------------------------------------------
|
||||
# The workflow itself runs only on a tag push upstream, so pin its
|
||||
# load-bearing pieces the way the harness pins root-only paths (repo
|
||||
# precedent: the tag-refusal greps in test/cli.sh).
|
||||
RY="$ROOT/.github/workflows/release.yml"
|
||||
check "release.yml: exists" 0 "" test -f "$RY"
|
||||
check "release.yml: triggers on tag pushes" 0 "" grep -q "tags:" "$RY"
|
||||
check "release.yml: sources the shared lib (one extractor, not a copy)" 0 "" \
|
||||
grep -q "release-lib.sh" "$RY"
|
||||
check "release.yml: the body comes from changelog_section" 0 "" \
|
||||
grep -q "changelog_section CHANGELOG.md" "$RY"
|
||||
check "release.yml: a tag/VERSION mismatch refuses to create" 0 "" \
|
||||
grep -q "refusing to create a release" "$RY"
|
||||
check "release.yml: an empty changelog section refuses too" 0 "" \
|
||||
grep -q "has no '## " "$RY"
|
||||
check "release.yml: gh release create verifies the tag" 0 "" \
|
||||
grep -q -- "--verify-tag" "$RY"
|
||||
# Ordering: the mismatch assert must precede the create (line compare, the
|
||||
# repo's marker-then-box idiom; defaults fail closed).
|
||||
assert_at="$(grep -n "refusing to create a release" "$RY" | head -n1 | cut -d: -f1)"
|
||||
create_at="$(grep -n "gh release create" "$RY" | head -n1 | cut -d: -f1)"
|
||||
check "release.yml: the assert precedes the create" \
|
||||
0 "" test "${assert_at:-999999}" -lt "${create_at:-0}"
|
||||
|
||||
# --- release.yml, the merge path: the pins (#47; box#96's design) ------------
|
||||
# Merging the release-labeled ceremony PR IS the release. Same grep-pin
|
||||
# treatment for the merge path's load-bearing pieces: the gate, the four
|
||||
# fail-loud asserts, the same-job tag+publish, and the surviving tag-push
|
||||
# fallback.
|
||||
# The merge door rides pushes to MAIN, not pull_request events: a fork PR's
|
||||
# pull_request 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 (#48 round 1). The
|
||||
# label — the operator's intent — is read via the API off the merge commit.
|
||||
check "release.yml: the merge door rides pushes to main (fork-token-proof)" 0 "" \
|
||||
grep -qF "branches: [main]" "$RY"
|
||||
# YAML maps are last-key-wins: a second sibling push: key silently replaces
|
||||
# the first and kills a door (grok's round-2 catch — the tag fallback had
|
||||
# stopped triggering). Exactly ONE push key may exist.
|
||||
check "release.yml: exactly one on.push key (duplicate keys drop a door)" 0 "1" \
|
||||
grep -cE '^ push:' "$RY"
|
||||
check "release.yml: ...and the doors split on the ref (tag door takes tags)" 0 "" \
|
||||
grep -qF "startsWith(github.ref, 'refs/tags/')" "$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/$MERGE_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"
|
||||
# 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): work under
|
||||
# the label is a green NOTICE no-op — in the -dev steady state and in the
|
||||
# post-release window (bare, unchanged, already released) — while every
|
||||
# half-ceremony refuses. Pin each verdict's message 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"
|
||||
# shellcheck disable=SC2016 # the $-refs are the inner bash -c's, deliberately
|
||||
check "release.yml: decide gates every later step on ceremony=yes" 0 "" \
|
||||
bash -c '[ "$(grep -cF "if: steps.decide.outputs.ceremony == '\''yes'\''" "$1")" -ge 3 ]' _ "$RY"
|
||||
check "release.yml: assert 3 — an empty section refuses to publish" 0 "" \
|
||||
grep -qF "refusing to publish an empty release" "$RY"
|
||||
check "release.yml: assert 4 — an existing tag or release refuses (idempotent)" 0 "" \
|
||||
grep -qF "refusing to re-release" "$RY"
|
||||
# Same-job matters: a GITHUB_TOKEN-created tag fires no tag-push workflow,
|
||||
# so the publish must live NEXT TO the tag creation. The workflow keeps
|
||||
# release-on-merge as its last job (pinned by comment there) so the awk
|
||||
# range runs to EOF; both acts must land inside it.
|
||||
MJOB="$(awk '/^ release-on-merge:/,0' "$RY")"
|
||||
mjob_has() { printf '%s' "$MJOB" | grep -qF -e "$1"; }
|
||||
check "release.yml: the merge job API-creates the tag itself" 0 "" \
|
||||
mjob_has "git/refs"
|
||||
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
||||
check "release.yml: ...at the pushed main head (github.sha = the merge commit)" 0 "" mjob_has 'sha="$MERGE_SHA"'
|
||||
# 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"
|
||||
check "release.yml: ...and publishes in the SAME job" 0 "" \
|
||||
mjob_has "gh release create"
|
||||
# Ordering, the marker-then-box idiom again: the last assert's refusal must
|
||||
# precede the tag creation (asserts first, acts last; defaults fail closed).
|
||||
massert_at="$(grep -n "refusing to re-release" "$RY" | head -n1 | cut -d: -f1)"
|
||||
mtag_at="$(grep -n "git/refs" "$RY" | head -n1 | cut -d: -f1)"
|
||||
check "release.yml: the merge-path asserts precede the tag" \
|
||||
0 "" test "${massert_at:-999999}" -lt "${mtag_at:-0}"
|
||||
# ...and the manual path SURVIVES: tag-push trigger plus a push-gated job,
|
||||
# the documented fallback and backfill.
|
||||
check "release.yml: the tag-push trigger survives (manual fallback intact)" 0 "" \
|
||||
grep -qF "tags: ['**']" "$RY"
|
||||
check "release.yml: the fallback job is gated to push events" 0 "" \
|
||||
grep -qF "github.event_name == 'push'" "$RY"
|
||||
|
||||
# --- the installer's ref logic, extracted ------------------------------------
|
||||
# install.sh must stay a single curl|bash file, so its channel functions live
|
||||
|
|
@ -170,35 +910,6 @@ H5="$WORK/h5"; B5="$WORK/b5"
|
|||
check "channel: a ref that is neither tag nor branch dies naming both tries" \
|
||||
1 "not a tag and not a branch" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
||||
|
||||
# --- the local channel: RIG_INSTALL_SOURCE (#106) ----------------------------
|
||||
# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh
|
||||
# both install THIS checkout through it. What release.sh owes is the channel's
|
||||
# contract: a directory installs, a tarball installs, neither touches the
|
||||
# network, and a bad path refuses BY NAME — never a silent fallback to
|
||||
# downloading a release, which would leave a green CI job testing the wrong
|
||||
# tree. The stub curl's log is the network witness: any download, even an
|
||||
# attempted one, would land a URL in it.
|
||||
H6="$WORK/h6"; B6="$WORK/b6"; LOG6="$WORK/log6"
|
||||
check "channel local: a directory installs" 0 "done" \
|
||||
rinst "$H6" "$B6" RIG_INSTALL_SOURCE="$TBDIR/rig-7.7.7-relflow" CURL_STUB_LOG="$LOG6"
|
||||
check "channel local: the tree landed under its VERSION" 0 "" \
|
||||
test -x "$H6/versions/7.7.7-relflow/bin/rig"
|
||||
check "channel local: INSTALLED_FROM records local:<path>" 0 \
|
||||
"local:$TBDIR/rig-7.7.7-relflow" cat "$H6/versions/7.7.7-relflow/INSTALLED_FROM"
|
||||
check "channel local: curl was never consulted" 1 "" test -s "$LOG6"
|
||||
H7="$WORK/h7"; B7="$WORK/b7"; LOG7="$WORK/log7"
|
||||
check "channel local: a tarball installs too" 0 "done" \
|
||||
rinst "$H7" "$B7" RIG_INSTALL_SOURCE="$WORK/release.tgz" CURL_STUB_LOG="$LOG7"
|
||||
check "channel local: the tarball's tree landed" 0 "" \
|
||||
test -x "$H7/versions/7.7.7-relflow/bin/rig"
|
||||
check "channel local: ...also without a download" 1 "" test -s "$LOG7"
|
||||
H8="$WORK/h8"; B8="$WORK/b8"; LOG8="$WORK/log8"
|
||||
check "channel local: a missing path refuses BY NAME" 1 "$WORK/no-such-source" \
|
||||
rinst "$H8" "$B8" RIG_INSTALL_SOURCE="$WORK/no-such-source" CURL_STUB_LOG="$LOG8"
|
||||
check "channel local: the refusal installed NOTHING" 1 "" test -e "$H8"
|
||||
check "channel local: ...and downloaded nothing (no silent fallback)" 1 "" \
|
||||
test -s "$LOG8"
|
||||
|
||||
rm -rf "$WORK"
|
||||
|
||||
echo "---"
|
||||
|
|
|
|||
Loading…
Reference in a new issue