Compare commits
211 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb4de44bf4 | |||
| 7aed6ea098 | |||
| 5187b74fa0 | |||
| 0d36b4dc95 | |||
| 6f92b9eaa6 | |||
| f0f17ad2ff | |||
| 23965bebca | |||
| ad3133d1c0 | |||
| 7f2501d0fe | |||
| 9cb81c9f6b | |||
| 0524f288c3 | |||
| 86b749876a | |||
| 306844daa8 | |||
| ce43c021a5 | |||
| 7385707770 | |||
| d46697034d | |||
| 848e3f42ef | |||
| 9afcdf858f | |||
| a45d84892f | |||
| fd5c04efc9 | |||
| e42ba31a09 | |||
| 1daf7004d7 | |||
| 9c0e508b76 | |||
| 4b9de4818f | |||
| 47aaeda319 | |||
| 1179d3142f | |||
| 4a6e9761e5 | |||
| 7aca642ba5 | |||
| 0362e5cf43 | |||
| 12803b2b97 | |||
| a3ec47113a | |||
| 1c9a245595 | |||
| ec73c86de7 | |||
| c7d56d3b80 | |||
| 1070673562 | |||
| 1343cb9871 | |||
| df075b9ecf | |||
| 24abfbdb79 | |||
| 90cbfe0057 | |||
|
|
54efc17246 | ||
|
|
25f337494c | ||
| e7471edc41 | |||
| 0370cc9818 | |||
| fbdce5284e | |||
| 25d10b084d | |||
| ab29ee78fb | |||
| 6c3b08117f | |||
| 72ae875b90 | |||
| cf5858bb60 | |||
| 1933b07fd4 | |||
| 903d8371b3 | |||
|
|
93608e8d78 | ||
|
|
559a5b23ba | ||
|
|
29c4a0840b | ||
|
|
34ff1c8917 | ||
|
|
4703c2b570 | ||
|
|
56478d7a7d | ||
|
|
dfd2debff1 | ||
|
|
b849eb57d3 | ||
|
|
247d9b9ec5 | ||
|
|
5794a80d0e | ||
|
|
2af3b24fd8 | ||
|
|
c08bcc6622 | ||
|
|
86bf52d4a5 | ||
|
|
597e1105f4 | ||
|
|
88e59b6ec8 | ||
|
|
587a44617b | ||
|
|
535caea94b | ||
|
|
c9df5f35db | ||
|
|
c0aa5ce716 | ||
|
|
f885f4da27 | ||
|
|
bb54166bb1 | ||
|
|
5294b53f9f | ||
|
|
897779b925 | ||
|
|
e250f7f7da | ||
|
|
af81f8f4cf | ||
|
|
e7f31046d9 | ||
|
|
79363c2631 | ||
|
|
11b02eb070 | ||
|
|
b99d08ea3d | ||
|
|
c9c8ad9ba9 | ||
|
|
29563a1246 | ||
|
|
5e70c0bb66 | ||
|
|
10975d9ac8 | ||
|
|
89a7fcc5b6 | ||
|
|
728b97df8a | ||
|
|
e22e0dd4a8 | ||
|
|
20438f09e8 | ||
|
|
b6d580ea3e | ||
|
|
53ff0daeff | ||
|
|
c0272cbae6 | ||
|
|
8b4a55a712 | ||
|
|
9cc7568ca5 | ||
|
|
5a8badd532 | ||
|
|
51281cdd18 | ||
|
|
4579e65de9 | ||
|
|
243deb1a4c | ||
|
|
7f19eed3d5 | ||
|
|
506b7506a5 | ||
|
|
bd2918d0c5 | ||
|
|
f26ed51938 | ||
|
|
f9407aea43 | ||
|
|
f00c90738b | ||
|
|
277d28e01e | ||
|
|
b971471b52 | ||
|
|
8dcf52940e | ||
|
|
3698eb5fbe | ||
|
|
c02226cb66 | ||
|
|
7b2de4a9e6 | ||
|
|
5bcd8853d4 | ||
|
|
77cb4bdd64 | ||
|
|
6241e87538 | ||
|
|
df29d43a23 | ||
|
|
493ae31811 | ||
|
|
0b3c7cb055 | ||
|
|
66d304f21f | ||
|
|
3c72c1b8c3 | ||
|
|
119fb68359 | ||
|
|
36da497352 | ||
|
|
be71e1c8b2 | ||
|
|
e6584ceb2b | ||
|
|
d8a0e22894 | ||
|
|
595a152925 | ||
|
|
5bf0acd940 | ||
|
|
7f8a0e0885 | ||
|
|
c6a3c23869 | ||
|
|
9b7cb57005 | ||
|
|
c77bc1a9bf | ||
|
|
6b012ab548 | ||
|
|
186d976686 | ||
|
|
dbe04c7c36 | ||
|
|
11dfeb2ecd | ||
|
|
750d93deda | ||
|
|
b234f48e68 | ||
|
|
77a9a1ad76 | ||
|
|
b8ce8e5e1c | ||
|
|
d69ecd1104 | ||
|
|
9c82d56230 | ||
|
|
9adf45a229 | ||
|
|
1db7779b03 | ||
|
|
c199e8fd04 | ||
|
|
27a5fa282e | ||
|
|
7ffc30bacc | ||
|
|
0865ce6c02 | ||
|
|
b2e7febf08 | ||
|
|
23cca65855 | ||
|
|
ac1bb3bf76 | ||
|
|
9ba16946fd | ||
|
|
8b2ddbdd2d | ||
|
|
88d83a1aba | ||
|
|
5eddf2e2ed | ||
|
|
2dc47112f2 | ||
|
|
491055c4cf | ||
|
|
b07e734fc0 | ||
|
|
4f40cfabf2 | ||
|
|
4ea3579c2e | ||
|
|
b88495105d | ||
|
|
3f6f05766f | ||
|
|
32e17246a2 | ||
|
|
616ada76b9 | ||
|
|
108a944e1c | ||
|
|
57e37363f4 | ||
|
|
800571fdfb | ||
|
|
a0951eacf8 | ||
|
|
ef798f302c | ||
|
|
762cc9653e | ||
|
|
d2e03ce993 | ||
|
|
5c40e906a1 | ||
|
|
d6e8cdda06 | ||
|
|
e11247b467 | ||
|
|
92cdb327b9 | ||
|
|
b5e01296ee | ||
|
|
d3577b9954 | ||
|
|
91a8905a71 | ||
|
|
3cfbb8921c | ||
|
|
75ef386601 | ||
|
|
8d4f6da64c | ||
|
|
8d03184e55 | ||
|
|
30ccb638dc | ||
|
|
1ad90410bf | ||
|
|
d144ae379c | ||
|
|
880e95df21 | ||
|
|
f972a6994c | ||
|
|
26ff38390b | ||
|
|
0f2ec1c893 | ||
|
|
435693860a | ||
|
|
e2a8a647e4 | ||
|
|
4bbf1babe0 | ||
|
|
b1c1357f2b | ||
|
|
94d9628766 | ||
|
|
00f6351e28 | ||
|
|
1845468765 | ||
|
|
d3b12742fd | ||
|
|
1acc163163 | ||
|
|
0ea1933c99 | ||
|
|
fcbe363a04 | ||
|
|
71c749e818 | ||
|
|
a43d91e7f9 | ||
|
|
971f33096d | ||
|
|
af80c59942 | ||
|
|
b982399d3c | ||
|
|
f579ce038f | ||
|
|
b8dc1154c8 | ||
|
|
19b937b858 | ||
|
|
a950569832 | ||
|
|
71fd0513ab | ||
|
|
0ff520c850 | ||
|
|
67386b4303 | ||
|
|
5a30c72eae | ||
|
|
49471bde4f | ||
|
|
b8e8e79b87 |
78 changed files with 11442 additions and 1860 deletions
49
.ceremony/AGENTS.md
Normal file
49
.ceremony/AGENTS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# AGENTS.md — start here
|
||||||
|
|
||||||
|
You are an agent working in a repo governed by
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). This file is
|
||||||
|
the router: find your role below, read its file, then act. The role files
|
||||||
|
sit beside this one — in ceremony itself at the repo root, in a governed
|
||||||
|
repo under `.ceremony/` (a machine-managed mirror; never edit those files
|
||||||
|
in place — they are changed in heavy-duty/ceremony, through its own flow).
|
||||||
|
|
||||||
|
## Your role
|
||||||
|
|
||||||
|
You were told your role when you were pointed at this repo ("you are a
|
||||||
|
reviewer here"). That one word is your whole onboarding:
|
||||||
|
|
||||||
|
| you are the… | read | your job in one line |
|
||||||
|
|---|---|---|
|
||||||
|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through |
|
||||||
|
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
|
||||||
|
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
|
||||||
|
|
||||||
|
Everyone, whatever the role, also reads [LABELS.md](LABELS.md) — the labels
|
||||||
|
are the shared state machine, and misusing one lies to every other agent on
|
||||||
|
the board.
|
||||||
|
|
||||||
|
**Not told a role?** Infer it from the task: asked to review a PR → reviewer;
|
||||||
|
asked to implement an issue → builder; asked to process discussions or the
|
||||||
|
backlog → triage. Still ambiguous → ask before acting. Do not free-lance
|
||||||
|
across roles in one session: a builder reviewing its own PR, or a reviewer
|
||||||
|
pushing fixes, breaks the separation the pipeline depends on.
|
||||||
|
|
||||||
|
## The pipeline you are part of
|
||||||
|
|
||||||
|
```
|
||||||
|
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release
|
||||||
|
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
|
||||||
|
```
|
||||||
|
|
||||||
|
Two rules bind every role:
|
||||||
|
|
||||||
|
- **Only triage mints issues.** Found work? Open or extend a discussion.
|
||||||
|
- **Only humans merge.** Convergence ends at `state:needs-human`, never at
|
||||||
|
a merge button.
|
||||||
|
|
||||||
|
## Repo specifics
|
||||||
|
|
||||||
|
What is true only of *this* repo — the review panel roster, the `scope:*`
|
||||||
|
label set, what a drill means, code conventions — lives in the repo's own
|
||||||
|
`CONTRIBUTING.md`. Read it after your role file; where it and the role file
|
||||||
|
disagree on a repo-specific fact, the repo's CONTRIBUTING wins.
|
||||||
251
.ceremony/BUILDER.md
Normal file
251
.ceremony/BUILDER.md
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
# 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.
|
||||||
197
.ceremony/LABELS.md
Normal file
197
.ceremony/LABELS.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# 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.
|
||||||
14
.ceremony/README.md
Normal file
14
.ceremony/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# .ceremony/ — the vendored doctrine mirror
|
||||||
|
|
||||||
|
Machine-managed by heavy-duty/ceremony's `actions/docs-sync`. Never edit
|
||||||
|
these files here: they are byte-identical copies of
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony) at this
|
||||||
|
repository's pinned ref, and CI re-diffs them on every PR — a hand edit
|
||||||
|
goes red. They are changed in heavy-duty/ceremony, through its own flow,
|
||||||
|
and arrive here when the pin moves.
|
||||||
|
|
||||||
|
The pin lives in `.github/workflows/release.yml` — the single
|
||||||
|
`uses: heavy-duty/ceremony/.github/workflows/release.yml@<ref>` line. One
|
||||||
|
pin governs machinery and doctrine alike: bump it and re-sync this mirror
|
||||||
|
in the same PR (`docs-sync --fix`, or let the red check on the bump PR say
|
||||||
|
what is stale).
|
||||||
139
.ceremony/REVIEWER.md
Normal file
139
.ceremony/REVIEWER.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# 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)).
|
||||||
149
.ceremony/TRIAGE.md
Normal file
149
.ceremony/TRIAGE.md
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# 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
Normal file
20
.github/DISCUSSION_TEMPLATE/ideas.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# 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
Normal file
16
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# 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
Normal file
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 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
Normal file
76
.github/ISSUE_TEMPLATE/work-order.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# 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
|
||||||
25
.github/labeler.yml
vendored
25
.github/labeler.yml
vendored
|
|
@ -11,7 +11,9 @@
|
||||||
"scope:runner":
|
"scope:runner":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
["commands/runner-*.sh", "commands/lib/runner-config.sh"]
|
["commands/runner-*.sh", "commands/lib/runner-config.sh",
|
||||||
|
"commands/forgejo-runner-*.sh", "commands/lib/forgejo-runner-config.sh",
|
||||||
|
"docs/templates/ci-box/**"]
|
||||||
"scope:coolify":
|
"scope:coolify":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["commands/coolify-*.sh"]
|
- any-glob-to-any-file: ["commands/coolify-*.sh"]
|
||||||
|
|
@ -21,3 +23,24 @@
|
||||||
"scope:installer":
|
"scope:installer":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ["install.sh"]
|
- 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
Normal file
12
.github/labels.conf
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
panel=cluade-reviewer-andresmgsl codex-reviewer-andresmgsl grok-reviewer-andresmgsl kimi-reviewer-andresmgsl
|
||||||
|
triage-actors=dan-claude-bot cluade-reviewer-andresmgsl
|
||||||
|
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-* / forgejo-runner-* — CI runner lifecycle, either forge
|
||||||
|
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
Normal file
21
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!-- 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. -->
|
||||||
228
.github/scripts/labels-reconcile.sh
vendored
228
.github/scripts/labels-reconcile.sh
vendored
|
|
@ -1,228 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# labels-reconcile.sh — the automation LABELS.md promises: state labels are
|
|
||||||
# written by machinery, never by hand. Every run derives each open PR's
|
|
||||||
# state:* from GitHub's own facts (draft flag, requested reviewers, submitted
|
|
||||||
# reviews) and converges the labels to it, so a killed run or a hand-moved
|
|
||||||
# label heals on the next pass. Stale is judged from real activity — commits,
|
|
||||||
# comments, reviews — never from label churn, or the sweep would un-stale its
|
|
||||||
# own mark every tick.
|
|
||||||
#
|
|
||||||
# The verdict contract (CONTRIBUTING.md): reviews end in approve or
|
|
||||||
# request-changes. Some live bots are comment-only and post agreement as a
|
|
||||||
# COMMENTED review — a non-verdict this machine refuses to guess about (body
|
|
||||||
# parsing is a heuristic, and a wrong guess promotes an unapproved PR). The
|
|
||||||
# judgment call belongs to the PR AUTHOR, who reads the round and escalates
|
|
||||||
# by requesting the human's review — an explicit request is a fact, and it is
|
|
||||||
# the one this machine trusts (see decide_state's top precedence). The
|
|
||||||
# machine auto-requests the human only in the no-judgment-needed case: three
|
|
||||||
# formal head-current approvals. Any approval that counts must be bound to
|
|
||||||
# the CURRENT head SHA: GitHub keeps approvals alive across pushes, and a
|
|
||||||
# stale approval must never promote unreviewed code to the human.
|
|
||||||
#
|
|
||||||
# DRY_RUN=1 narrates every mutation instead of performing it (how this script
|
|
||||||
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps
|
|
||||||
# the taxonomy (label create --force) — that heal is dispatch-only; the cron
|
|
||||||
# sweep tolerates a missing label rather than recreating it.
|
|
||||||
#
|
|
||||||
# The state machine below is pure (globals in, state out) and covered by
|
|
||||||
# fixture tests in test/labels-reconcile.sh.
|
|
||||||
|
|
||||||
HUMAN="${HUMAN_REVIEWER:-danmt}"
|
|
||||||
BOTS=(claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl)
|
|
||||||
STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
|
|
||||||
STALE_AFTER=$((48 * 3600))
|
|
||||||
|
|
||||||
log() { printf 'labels: %s\n' "$*"; }
|
|
||||||
|
|
||||||
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
|
|
||||||
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The state machine. Pure functions over four globals, set per PR:
|
|
||||||
# DRAFT true|false
|
|
||||||
# HEAD_SHA the PR's current head commit
|
|
||||||
# REQUESTED newline-separated logins with a review currently requested
|
|
||||||
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
|
||||||
|
|
||||||
bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
|
|
||||||
local review state commit
|
|
||||||
review="$(jq -c --arg u "$1" \
|
|
||||||
'[.[] | select(.user.login == $u)] | sort_by(.submitted_at) | last // empty' \
|
|
||||||
<<<"$REVIEWS_JSON")"
|
|
||||||
if [ -z "$review" ]; then echo MISSING; return; fi
|
|
||||||
state="$(jq -r '.state' <<<"$review")"
|
|
||||||
commit="$(jq -r '.commit_id' <<<"$review")"
|
|
||||||
case "$state" in
|
|
||||||
CHANGES_REQUESTED)
|
|
||||||
# blocks at ANY head — GitHub's own semantic: only a newer review
|
|
||||||
# from the same reviewer clears it
|
|
||||||
echo BLOCK ;;
|
|
||||||
APPROVED)
|
|
||||||
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
|
|
||||||
*)
|
|
||||||
# COMMENTED and anything else: a non-verdict. The machine does not
|
|
||||||
# read bodies — if the comment is really an agreement, the AUTHOR
|
|
||||||
# says so by requesting the human's review.
|
|
||||||
echo FEEDBACK ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
human_request_needed() { # 0 when needs-human requires a FRESH human request
|
|
||||||
# already requested → the handoff is live; head-current human approval →
|
|
||||||
# nothing left to ask. Anything else (never reviewed, an old comment, an
|
|
||||||
# approval of an older head) stalls the handoff unless we request —
|
|
||||||
# guarding on "has the human ever reviewed" wedged exactly that way.
|
|
||||||
if requested "$HUMAN"; then return 1; fi
|
|
||||||
if [ "$(bot_verdict "$HUMAN")" = APPROVE ]; then return 1; fi
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
decide_state() { # → the one state:* label this PR should carry
|
|
||||||
if [ "$DRAFT" = true ]; then echo state:building; return; fi
|
|
||||||
# an explicit human request outranks the bot rounds — it is the final
|
|
||||||
# gate, and a maintainer pulling a PR to themselves early counts too
|
|
||||||
if requested "$HUMAN"; then echo state:needs-human; return; fi
|
|
||||||
local b v verdicts=""
|
|
||||||
for b in "${BOTS[@]}"; do
|
|
||||||
if requested "$b"; then echo state:bots-reviewing; return; fi
|
|
||||||
done
|
|
||||||
for b in "${BOTS[@]}"; do
|
|
||||||
v="$(bot_verdict "$b")"
|
|
||||||
if [ "$v" = MISSING ]; then echo state:bots-reviewing; return; fi
|
|
||||||
verdicts="$verdicts $v"
|
|
||||||
done
|
|
||||||
case "$verdicts" in
|
|
||||||
# FEEDBACK = a comment with no verdict → the agent owes the round-reply.
|
|
||||||
# STALE = a verdict for an older head → the agent owes a re-request.
|
|
||||||
*BLOCK* | *FEEDBACK* | *STALE*) echo state:addressing; return ;;
|
|
||||||
esac
|
|
||||||
# the bots all approve — but if the human's standing word is
|
|
||||||
# changes-requested (and nobody re-requested them yet), the agent owes
|
|
||||||
# fixes, not the human a nag
|
|
||||||
if [ "$(bot_verdict "$HUMAN")" = BLOCK ]; then
|
|
||||||
echo state:addressing
|
|
||||||
else
|
|
||||||
echo state:needs-human
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The sweep: fetch facts, decide, converge. One PR's failure never aborts the
|
|
||||||
# others — each PR reconciles in a subshell and a failure just logs.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
|
|
||||||
while IFS='|' read -r name color desc; do
|
|
||||||
[ -n "$name" ] || continue
|
|
||||||
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
|
||||||
done <<'EOF'
|
|
||||||
state:building|FBCA04|PR is a draft — the coding agent is still building
|
|
||||||
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
|
|
||||||
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
|
|
||||||
state:needs-human|8250DF|All bots approve — waiting on the human reviewer
|
|
||||||
stale|B60205|No activity for 48h — needs a poke (sweep-managed)
|
|
||||||
blocked|6A737D|Waiting on another PR or issue to land first
|
|
||||||
release|0E8A16|Release flow and version/packaging work
|
|
||||||
scope: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 the state:* labels ----
|
|
||||||
remove=""
|
|
||||||
for s in "${STATES[@]}"; do
|
|
||||||
if [ "$s" != "$desired" ] && has_label "$s"; then remove="$remove,$s"; fi
|
|
||||||
done
|
|
||||||
remove="${remove#,}"
|
|
||||||
if ! has_label "$desired" || [ -n "$remove" ]; then
|
|
||||||
args=(--add-label "$desired")
|
|
||||||
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
|
||||||
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
|
||||||
log "#$n: state -> $desired${remove:+ (cleared $remove)}"
|
|
||||||
else
|
|
||||||
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
|
||||||
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
|
||||||
last_activity="$(
|
|
||||||
{
|
|
||||||
jq -r '.created_at' <<<"$PR_JSON"
|
|
||||||
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
|
||||||
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
|
||||||
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
|
|
||||||
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
|
|
||||||
} | sort | tail -n1
|
|
||||||
)"
|
|
||||||
age=$((NOW - $(date -d "$last_activity" +%s)))
|
|
||||||
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then
|
|
||||||
if has_label stale; then
|
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
|
||||||
log "#$n: unstale"
|
|
||||||
fi
|
|
||||||
elif ! has_label stale; then
|
|
||||||
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
|
|
||||||
log "#$n: stale ($((age / 3600))h quiet)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
|
||||||
NOW="$(date +%s)"
|
|
||||||
|
|
||||||
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then
|
|
||||||
log "workflow_dispatch: bootstrapping the taxonomy"
|
|
||||||
bootstrap_labels
|
|
||||||
fi
|
|
||||||
|
|
||||||
local n
|
|
||||||
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do
|
|
||||||
(
|
|
||||||
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
|
|
||||||
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
|
||||||
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
|
||||||
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
|
||||||
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
|
|
||||||
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
|
||||||
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
|
||||||
| jq -s '[.[] | select(.state != "PENDING")]')"
|
|
||||||
reconcile_pr "$n"
|
|
||||||
) || log "#$n: reconcile failed — continuing with the remaining PRs"
|
|
||||||
done
|
|
||||||
log "reconciled."
|
|
||||||
}
|
|
||||||
|
|
||||||
# sourced by test/labels-reconcile.sh for the fixture tests; executed in CI
|
|
||||||
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|
||||||
main "$@"
|
|
||||||
fi
|
|
||||||
23
.github/scripts/release-lib.sh
vendored
23
.github/scripts/release-lib.sh
vendored
|
|
@ -1,23 +0,0 @@
|
||||||
#!/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"
|
|
||||||
}
|
|
||||||
95
.github/workflows/ci.yml
vendored
95
.github/workflows/ci.yml
vendored
|
|
@ -8,20 +8,109 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
||||||
- name: shellcheck
|
- name: shellcheck
|
||||||
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
|
# -x follows the `source=SCRIPTDIR/...` directives into commands/lib/.
|
||||||
# globstar so a script in a new subdirectory is linted without anyone
|
# globstar so a script in a new subdirectory is linted without anyone
|
||||||
# remembering to edit this list; bin/* covers the extensionless entrypoints.
|
# remembering to edit this list; bin/* covers the extensionless entrypoints.
|
||||||
# The file list is printed so under-coverage shows up in the log.
|
# dotglob because globs skip dot-prefixed names: without it `**/` never
|
||||||
|
# descends into `.github/`, so `.github/scripts/*.sh` — release-lib.sh
|
||||||
|
# among them — was swept up by nothing (#70). It also makes `**`
|
||||||
|
# descend into `.git/`, which holds no tracked `.sh` on a checkout.
|
||||||
|
# The file list is printed so under-coverage shows up in the log, and
|
||||||
|
# the comm below turns under-coverage into a failure rather than a
|
||||||
|
# thing someone has to notice: every tracked `.sh` must be in the set.
|
||||||
|
#
|
||||||
|
# Install shellcheck when missing (#144). rig's default Forgejo label
|
||||||
|
# maps ubuntu-latest to catthehacker's act-22.04 (slim), which does not
|
||||||
|
# ship shellcheck; GitHub-hosted ubuntu-latest does. The conditional
|
||||||
|
# keeps each forge from paying for the other.
|
||||||
|
#
|
||||||
|
# sudo: load-bearing on GitHub (job runs as `runner` with passwordless
|
||||||
|
# sudo) and a no-op on act-22.04 (jobs run as uid 0; the image has no
|
||||||
|
# `runner` account). Do not delete it as "dead weight" — that breaks
|
||||||
|
# the GitHub half the day that image stops preinstalling shellcheck.
|
||||||
run: |
|
run: |
|
||||||
shopt -s globstar
|
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y shellcheck
|
||||||
|
fi
|
||||||
|
shopt -s globstar dotglob
|
||||||
files=(bin/* **/*.sh)
|
files=(bin/* **/*.sh)
|
||||||
printf 'shellcheck: %s\n' "${files[@]}"
|
printf 'shellcheck: %s\n' "${files[@]}"
|
||||||
|
uncovered=$(comm -23 <(git ls-files '*.sh' | sort) <(printf '%s\n' "${files[@]}" | sort))
|
||||||
|
if [ -n "$uncovered" ]; then
|
||||||
|
printf 'tracked .sh files the glob does not lint:\n%s\n' "$uncovered" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
shellcheck -x "${files[@]}"
|
shellcheck -x "${files[@]}"
|
||||||
- name: cli tests
|
- name: cli tests
|
||||||
run: bash test/cli.sh
|
run: bash test/cli.sh
|
||||||
- name: release-flow tests
|
- name: release tests — rig's own surfaces
|
||||||
run: bash test/release.sh
|
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.
|
||||||
|
#
|
||||||
|
# These six are ABSOLUTE and the two callers in release.yml/labels.yml
|
||||||
|
# are BARE. That asymmetry is measured, not stylistic (#112) — do not
|
||||||
|
# "tidy" either half into the other:
|
||||||
|
# a step `uses:` resolves a bare name through DEFAULT_ACTIONS_URL,
|
||||||
|
# which this forge points at code.forgejo.org, where ceremony does not
|
||||||
|
# exist — bare 404s and every guard below fails;
|
||||||
|
# a reusable-workflow `uses:` never consults DEFAULT_ACTIONS_URL at
|
||||||
|
# all — it resolves against the runner's own instance, so bare already
|
||||||
|
# works there. And release.yml's caller is the line ceremony's own
|
||||||
|
# docs-sync greps for its pin, anchored to the bare spelling: make it
|
||||||
|
# absolute and the pin goes invisible and docs-sync exits 1.
|
||||||
|
#
|
||||||
|
# changelog-armed: the version-keyed arming rule (rig#66; the
|
||||||
|
# unconditional form rig#44 reverted — this is its correct return).
|
||||||
|
- uses: https://forgejo.heavyduty.builders/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: https://forgejo.heavyduty.builders/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: https://forgejo.heavyduty.builders/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: https://forgejo.heavyduty.builders/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: https://forgejo.heavyduty.builders/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: https://forgejo.heavyduty.builders/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
|
||||||
|
|
||||||
# Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and
|
# 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
|
# stands up throwaway containers, and a slow image pull must never delay the
|
||||||
|
|
|
||||||
62
.github/workflows/labels.yml
vendored
62
.github/workflows/labels.yml
vendored
|
|
@ -1,56 +1,22 @@
|
||||||
name: labels
|
name: labels
|
||||||
# The automation LABELS.md promises. Two halves:
|
# The automation LABELS.md promises, now implemented upstream
|
||||||
# scope — path-derived scope:* labels on PRs (actions/labeler)
|
# (heavy-duty/ceremony#13): scope labeling and the state reconciler live in
|
||||||
# reconcile — the state:* machine + the stale sweep (.github/scripts/labels-reconcile.sh)
|
# the reusable workflow this caller pins. Rig keeps the triggers and
|
||||||
#
|
# permissions (a called workflow cannot define them), its path map in
|
||||||
# pull_request_target, not pull_request: every PR here arrives from a fork,
|
# .github/labeler.yml, and its panel + scope taxonomy in .github/labels.conf.
|
||||||
# where pull_request (and pull_request_review) run with a READ-ONLY token and
|
|
||||||
# cannot label anything. _target is safe in this workflow because no PR code
|
|
||||||
# is ever checked out or executed — labeler reads changed paths via the API,
|
|
||||||
# and reconcile checks out the BASE branch only. Keep it that way.
|
|
||||||
#
|
|
||||||
# Review-submitted transitions (bots finishing a round) ride the cron: there
|
|
||||||
# is no pull_request_review_target, so the 15-minute tick is the wake signal —
|
|
||||||
# the same cadence the reviewer bots poll at.
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
|
||||||
- cron: "*/15 * * * *"
|
workflow_dispatch: # bootstraps missing labels on a fresh repo
|
||||||
workflow_dispatch: # also bootstraps missing labels — run once on a fresh repo
|
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize]
|
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
|
||||||
|
issues:
|
||||||
|
types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
checks: read
|
||||||
|
statuses: read
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
scope:
|
labels:
|
||||||
if: github.event_name == 'pull_request_target'
|
uses: heavy-duty/ceremony/.github/workflows/labels.yml@0.3.0
|
||||||
runs-on: ubuntu-latest
|
|
||||||
concurrency:
|
|
||||||
group: labels-scope-${{ github.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
steps:
|
|
||||||
- uses: actions/labeler@v5
|
|
||||||
with:
|
|
||||||
# additive only — a hand-applied scope must survive the machine
|
|
||||||
sync-labels: false
|
|
||||||
|
|
||||||
reconcile:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# ONE shared group: every reconcile sweeps every open PR, so cron and
|
|
||||||
# PR-event runs must serialize or two sweeps race the same PR's labels
|
|
||||||
# and both pass the request-the-human-once guard. GitHub keeps at most
|
|
||||||
# one queued run per group (older queued runs are superseded), which
|
|
||||||
# coalesces bursts instead of piling them up.
|
|
||||||
concurrency:
|
|
||||||
group: labels-reconcile
|
|
||||||
cancel-in-progress: false
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4 # base branch only — never the PR's code
|
|
||||||
- name: reconcile state + stale
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPO: ${{ github.repository }}
|
|
||||||
run: bash .github/scripts/labels-reconcile.sh
|
|
||||||
|
|
|
||||||
244
.github/workflows/release.yml
vendored
244
.github/workflows/release.yml
vendored
|
|
@ -1,238 +1,22 @@
|
||||||
name: release
|
name: release
|
||||||
# Two ways in, one release out (#47; box#96's design — the merge path — on
|
# The ceremony moved upstream (heavy-duty/ceremony — the doctrine is its
|
||||||
# top of #32/box#83's tag flow, kept verbatim as the fallback):
|
# 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
|
||||||
# - MERGE (the paved road): a release is a PR — `release: X.Y.Z`, carrying
|
# called workflow cannot define: triggers and permissions.
|
||||||
# the `release` label, bumping VERSION and stamping CHANGELOG.md's
|
# Triggers and permissions MUST live here (a called workflow cannot define them):
|
||||||
# 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:
|
on:
|
||||||
# ONE push key, both filters — YAML maps are last-key-wins, so a second
|
# ONE push key, both filters — YAML maps are last-key-wins; a second sibling
|
||||||
# sibling `push:` would silently REPLACE the first and kill a door
|
# `push:` silently replaces the first and kills a door (rig's review catch).
|
||||||
# (grok's round-2 catch: the tag fallback had stopped triggering).
|
|
||||||
push:
|
push:
|
||||||
# Every tag, not a shape filter: a tag that mismatches VERSION must fail
|
tags: ["**"] # every tag — a wrong tag must FAIL the assert loudly,
|
||||||
# LOUDLY below, not be silently skipped by a pattern that didn't match.
|
# never be skipped by a shape filter 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]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # the tag ref, the release publish, the bump push
|
contents: write # tag ref create + release create + the bump push
|
||||||
# Two consumers (a declared permissions: block zeroes every unspecified
|
pull-requests: write # decide's label read; the bump-fallback `gh pr create`
|
||||||
# scope): the decide step's label read (commits/<sha>/pulls) and the bump
|
issues: write # --label on that fallback PR rides the issues API
|
||||||
# fallback's `gh pr create --label`.
|
|
||||||
pull-requests: write
|
|
||||||
# ...and the --label on that fallback PR rides the ISSUES API (labels.yml
|
|
||||||
# grants the same pair for the same reason).
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
# The tag-push path — a pushed TAG ref. The merge path (a pushed main
|
uses: heavy-duty/ceremony/.github/workflows/release.yml@0.3.0
|
||||||
# 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:
|
with:
|
||||||
# The pushed head is what ships. Full history so the before-side
|
version-source: file
|
||||||
# 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
|
|
||||||
|
|
|
||||||
7
AGENTS.md
Normal file
7
AGENTS.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# AGENTS.md — start at .ceremony/
|
||||||
|
|
||||||
|
This repository is governed by
|
||||||
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). Read
|
||||||
|
`.ceremony/AGENTS.md` first — it routes you to your role file, vendored
|
||||||
|
beside it. Repo specifics (the review panel roster, the scope labels, what
|
||||||
|
a drill means here, code conventions) live in CONTRIBUTING.md.
|
||||||
165
CHANGELOG.md
165
CHANGELOG.md
|
|
@ -4,85 +4,100 @@ 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)
|
`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.
|
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)
|
||||||
|
|
||||||
|
## 0.3.0 — 2026-07-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Deleting a shipped release heading from `CHANGELOG.md` is caught on every PR
|
||||||
|
(#98, heavy-duty/box#122)
|
||||||
|
- The heading-uniqueness check no longer sits behind git conditions it does not
|
||||||
|
need (#98, heavy-duty/box#143)
|
||||||
|
- An unreadable check rollup no longer reads as "nothing is failing" (#90)
|
||||||
|
- CI runs `test/labels-reconcile.sh`, which it had never run (#90)
|
||||||
|
- `state:needs-human` no longer appears on PRs a human cannot merge
|
||||||
|
(#87, heavy-duty/box#136)
|
||||||
|
- A missing `/run/sshd` no longer reads as a broken sshd config (#92)
|
||||||
|
- CI's shellcheck sweep reaches `.github/scripts/` (#70)
|
||||||
|
- Ctrl-D at the `rig uninstall` confirm aborts out loud (#68)
|
||||||
|
- `users apply` tells "revoke everyone" apart from a truncated users file (#65)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- CI refuses a release PR with no drill record at `drills/<version>.md`
|
||||||
|
- `rig platform` — what this machine is, computed at run time, stored nowhere
|
||||||
|
(#64)
|
||||||
|
- `/etc/rig/manifest` records which rig converged a machine, and when (#61)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `state:needs-human` is set at handoff, not by the cron (#96)
|
||||||
|
- PR labels split into two axes: `state:*` (whose ball) and `blocker:*` (what
|
||||||
|
is in the way) (heavy-duty/box#137)
|
||||||
|
- BREAKING: `--class human|server` is now `--root-door closed|open`; old
|
||||||
|
markers still resolve (#77)
|
||||||
|
- BREAKING: the box tenant roles carry a `-box` suffix (#76)
|
||||||
|
- BREAKING: machine roles carry a `-server` suffix, and `staging-server` is
|
||||||
|
back (#76)
|
||||||
|
- Changelog entries are one line each, and the whole file now follows the rule
|
||||||
|
(#100)
|
||||||
|
|
||||||
|
## 0.2.0 — 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `users apply` grants the box *tier*, not just its socket (#49)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- BREAKING: `rig bootstrap` takes the users file, and requires it (#51)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A release no longer disarms the changelog under the PRs still in flight (#67)
|
||||||
|
- A `host=no` box with an `incus` group no longer hands out the bare socket
|
||||||
|
(#58)
|
||||||
|
- Dropping the box role revokes through `box`, not behind its back (#50)
|
||||||
|
- `rig bootstrap` refuses a users file that names no users (#57)
|
||||||
|
|
||||||
## 0.1.0 — 2026-07-19
|
## 0.1.0 — 2026-07-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **The release suite accepts the ceremony's own tree** (#44) —
|
- The release suite accepts the ceremony's own tree (#44)
|
||||||
`test/release.sh` demanded a literal `## Unreleased` heading in the real
|
- The installer survives an environment with no `$HOME` (#39, #41)
|
||||||
`CHANGELOG.md`, extracting non-empty and containing `#32`. All three are
|
- Headless credential prompts refuse loudly instead of dying silently (#42)
|
||||||
false by construction on the `release: X.Y.Z` tree the ceremony's own PR
|
|
||||||
produces (it stamps that heading into `## X.Y.Z — date`), so the first
|
|
||||||
real release PR turned CI red and the flow blocked itself — invisible to
|
|
||||||
both fork rehearsals, which tag a branch (`release.yml` runs; `ci.yml`
|
|
||||||
never does). The guard now asserts what it was for: whatever the TOP
|
|
||||||
`## ` section is — `Unreleased` between releases, the stamped version on
|
|
||||||
and right after one — the exact `changelog_section` the workflow runs
|
|
||||||
extracts it non-empty. The rotting issue-number grep is gone.
|
|
||||||
|
|
||||||
- **The installer survives an environment with no `$HOME`** (#39) —
|
|
||||||
cloud-init's `runcmd` runs `install.sh` with no `$HOME` set, and under
|
|
||||||
`set -u` the first expansion died with a bash unbound-variable stack
|
|
||||||
instead of an install — found live by box#88's template seed, which
|
|
||||||
pins `HOME=/root` as its own scar. The installer now derives the home
|
|
||||||
from `getent` for the effective user (root included) before any path
|
|
||||||
is built from `$HOME`, and when getent has no answer either it refuses
|
|
||||||
by name. Driven with a shim getent both ways: the derived-home install
|
|
||||||
lands, the no-answer refusal is pinned. (#41 — merged without its
|
|
||||||
entry; restored here at the release gate.)
|
|
||||||
- **Headless credential prompts refuse loudly instead of dying silently**
|
|
||||||
(#42) — the interactive credential prompts (`TS_AUTHKEY` in `bootstrap`,
|
|
||||||
`RUNNER_TOKEN` in `runner install`, `RUNNER_REMOVE_TOKEN` in
|
|
||||||
`runner remove`, and both tokens in `runner repoint` — a site the new
|
|
||||||
no-bare-read test caught after the issue counted three) were bare
|
|
||||||
`read -rsp`: with stdin not a tty (CI,
|
|
||||||
`box exec`, any script), `read` fails, `set -e` ends the run, and the
|
|
||||||
log just *stops* — exit 1, no last word, measured live in the
|
|
||||||
2026-07-19 release drill. Each prompt now checks for a tty first and
|
|
||||||
dies naming the variable that unblocks an unattended run (`runner
|
|
||||||
remove` also names `--local`), and every `read` is `|| die`-guarded so
|
|
||||||
EOF at a real prompt gets the same courtesy. `db.sh` already held the
|
|
||||||
line here; now all of rig does.
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Merging a release-labeled PR IS the release — and the release re-arms
|
- Merging a release-labeled PR IS the release, and the release re-arms main
|
||||||
main itself** (#47) — the rig twin of heavy-duty/box#96, born of the
|
(#47)
|
||||||
ceremony retro: the tag was a separate, manual, silent-when-forgotten
|
- Tagged releases, and an installer that installs them (#32)
|
||||||
step, and a forgotten tag produces no red X. `release.yml` now fires on
|
|
||||||
pushes to main (fork-sourced ceremony PRs get a read-only token on
|
|
||||||
`pull_request` events), reading the transition from the push itself:
|
|
||||||
`event.before` to the pushed head. A decide step answers four states —
|
|
||||||
release-flow *work* merged under the `release` label (`-dev` endstates,
|
|
||||||
the post-release window) no-ops green with a NOTICE; the two genuinely
|
|
||||||
ambiguous bare states refuse loudly; a true transition then requires a
|
|
||||||
merged, `release`-labeled PR behind the commit (read via the API — the
|
|
||||||
label is the operator's declared intent). Then, in the same job, it
|
|
||||||
API-creates the tag at the merge commit, publishes with the extracted
|
|
||||||
notes — and bumps main to `X.Y.(Z+1)-dev` itself, direct push with a
|
|
||||||
loud open-a-PR fallback, so no follow-up bump PR exists on the paved
|
|
||||||
road. A `GITHUB_TOKEN`-created tag never fires the tag-push trigger, so
|
|
||||||
the paths cannot double-publish — and that tag-push path survives intact
|
|
||||||
as the documented manual fallback and backfill.
|
|
||||||
|
|
||||||
- **Tagged releases, and an installer that installs them** (#32) — the rig
|
|
||||||
half of the flow designed in heavy-duty/box#83, near-verbatim. A release
|
|
||||||
is a PR, then a tag: the `release: X.Y.Z` PR bumps `VERSION` and stamps
|
|
||||||
this file's Unreleased section with version + date; the merge commit is
|
|
||||||
tagged bare `X.Y.Z` (box's tag scheme — no `v` prefix). `release.yml`
|
|
||||||
turns the tag into the GitHub release — after asserting tag == `VERSION`
|
|
||||||
(mismatch fails loudly and creates nothing) — with that version's section
|
|
||||||
of this file as the body, extracted by the same `changelog_section` the
|
|
||||||
test harness drives. No assets: for a pure-bash tree, GitHub's source
|
|
||||||
tarball for the tag IS the package. `install.sh` now defaults to the
|
|
||||||
**latest release**: the tag is resolved by following the
|
|
||||||
`releases/latest` redirect and reading the `Location` header — no API, no
|
|
||||||
token — and the download is `archive/refs/tags/<tag>.tar.gz`. `RIG_REF`
|
|
||||||
picks the other two channels: a tag pins (`refs/tags` outranks a
|
|
||||||
same-named branch), a branch (`RIG_REF=main`) tracks the development
|
|
||||||
tree. Until 0.1.0 is cut the default channel has nothing to resolve and
|
|
||||||
dies saying exactly that, naming `RIG_REF=main` as the way to install
|
|
||||||
today — it never falls back to main silently, because "I installed the
|
|
||||||
latest release" must not quietly mean "I installed whatever main was that
|
|
||||||
second". Step 5 of #32 — pinning `BOX_REF` in the host-installs-box path
|
|
||||||
— stays open until box cuts its next tagged release.
|
|
||||||
|
|
|
||||||
155
CONTRIBUTING.md
155
CONTRIBUTING.md
|
|
@ -1,92 +1,113 @@
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
How change lands in this repo. The short version: PRs are born as drafts,
|
This repo is governed by
|
||||||
three reviewer bots take the first rounds, a human takes the last word — and
|
[heavy-duty/ceremony](https://github.com/heavy-duty/ceremony). **Agents:
|
||||||
labels tell you where everything is without opening anything.
|
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.
|
||||||
|
|
||||||
## The PR loop
|
## The PR loop, rig specifics
|
||||||
|
|
||||||
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
1. **Fork and branch.** Contributors work from forks; upstream branches are
|
||||||
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
for maintainers. Title the PR conventionally (`feat:`, `fix:`, `docs:`).
|
||||||
2. **Open as a draft** while you build. Drafts are invisible to the reviewer
|
2. **The review panel** (`.github/labels.conf`'s `panel=` line):
|
||||||
bots on purpose.
|
`cluade-reviewer-andresmgsl`, `codex-reviewer-andresmgsl`,
|
||||||
3. **When it's ready**: mark ready-for-review and request all three bots —
|
`grok-reviewer-andresmgsl`, `kimi-reviewer-andresmgsl` —
|
||||||
`claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`.
|
the required verdicts for a PR are the panel minus its author. The
|
||||||
They poll roughly every 15 minutes.
|
maintainer (`danmt`) takes the last word and merges.
|
||||||
4. **Rounds are answered whole.** Wait until all three have reviewed, then
|
3. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
||||||
answer the entire round in a **single reply**, push the fixes, and
|
|
||||||
re-request the bots that didn't approve. Prefer verification over
|
|
||||||
argument: a test settles what a comment thread can't.
|
|
||||||
5. **Reviews end in a verdict.** A reviewer — bot or human — either
|
|
||||||
**approves** or **requests changes**, never a bare comment. A
|
|
||||||
comment-only review is a non-verdict: it doesn't say whether the round
|
|
||||||
passed, and the state machine (and anyone scanning the board) has to
|
|
||||||
guess. The verdict carries *blockingness only*, the body carries the
|
|
||||||
feedback: non-blocking nits ride an **approval** and the author addresses
|
|
||||||
them at their discretion; anything blocking — including a question that
|
|
||||||
gates the verdict — is **request changes**, saying what unblocks it. The
|
|
||||||
reconciler treats a comment-only review as not-approved, so commenting
|
|
||||||
without a verdict only stalls the PR. The machine never reads review
|
|
||||||
bodies: when a comment-only reviewer's line is really an agreement, that
|
|
||||||
judgment belongs to the **author** — escalate by requesting the
|
|
||||||
maintainer's review (step 6), and the reconciler flips the label on that
|
|
||||||
request, because an explicit request is a fact it can trust.
|
|
||||||
6. **When the round passes, the author hands the PR to the maintainer** by
|
|
||||||
requesting their review — that request is what flips `state:needs-human`.
|
|
||||||
With three formal head-current approvals the labels workflow requests it
|
|
||||||
automatically; when part of the panel is comment-only, reading their
|
|
||||||
agreement is the author's judgment, so the author makes the request.
|
|
||||||
7. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
|
||||||
`bash test/release.sh` locally mirror what CI runs; the db dump/restore
|
`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
|
round-trip (`test/db-integration.sh`) executes in CI where Docker is
|
||||||
present.
|
present. The release guards (`changelog-armed`, `changelog-monotonic`,
|
||||||
8. **Feature PRs land their changelog entry as part of the PR** (box's
|
`changelog-assembled`, `drill-recorded`, `runner-isolated`, `docs-sync`)
|
||||||
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
|
run as ceremony's pinned actions.
|
||||||
that section becomes the release notes verbatim when a release is cut.
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- **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,
|
||||||
|
which is where anyone chasing the reasoning already goes. This file answers
|
||||||
|
one question: what is different in this version.
|
||||||
|
- **Any word that can be removed, is removed.**
|
||||||
|
- **Lead with the surface, not the mechanism.** "`state:needs-human` is set at
|
||||||
|
handoff" beats "the labels workflow now also wakes on `labeled`".
|
||||||
|
- **Cite the issue or PR** — `(#96)` — and let the reader follow it for the
|
||||||
|
rest.
|
||||||
|
- **Mark a breaking change** with a leading `BREAKING:`.
|
||||||
|
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`.
|
||||||
|
- No bold run-in headings, no sub-paragraphs, no code blocks, no prose essays.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
- `state:needs-human` is set at handoff, not by the cron (#96)
|
||||||
|
- An unreadable check rollup no longer reads as "nothing is failing" (#90)
|
||||||
|
- BREAKING: `--class human|server` is now `--root-door closed|open` (#77)
|
||||||
|
|
||||||
|
Not an entry — that is a PR body:
|
||||||
|
|
||||||
|
- **`state:needs-human` no longer waits on the cron to become true** (#96) —
|
||||||
|
the labels workflow now also wakes on `pull_request_target: labeled` and
|
||||||
|
`unlabeled`, and the author sets it themselves when handing a PR over. A
|
||||||
|
review landing was never a trigger. There is no `pull_request_review_target`,
|
||||||
|
and on fork PRs — which is all of them here — ...
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
A release is a PR, and merging it is the release (#47; box#96's design, on
|
A release is a PR, and merging it is the release. The ceremony — the two
|
||||||
top of #32/box#83's tag flow):
|
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.
|
||||||
|
|
||||||
1. A small PR — `release: X.Y.Z`, carrying the `release` label — bumps
|
What stays rig's is the **drill** — the real-hardware gate before the
|
||||||
`VERSION` from `X.Y.Z-dev` and stamps `CHANGELOG.md`'s Unreleased
|
handoff of a release PR, run by `drill/drill.sh` (#105): `rig bootstrap`
|
||||||
section as `## X.Y.Z — YYYY-MM-DD`. CI green on it, same loop as any PR.
|
converging the machine to its role twice with the second run diffed empty,
|
||||||
2. Merge it — that IS the ship decision. `release.yml`'s
|
`test/db-integration.sh`, the runner lifecycle against a fork, a coolify
|
||||||
`release-on-merge` job asserts, in order, fail-loud, creating nothing:
|
install. Rig's drill asserts **convergence** (a machine reaches its role,
|
||||||
the merged tree's `VERSION` is non-`-dev`; this PR is the one that
|
idempotently), it runs `--host yes` with `BOX_REF=release/<box-version>` so
|
||||||
changed it (a mislabeled ordinary PR fails here); the changelog section
|
it exercises the box that will actually ship, and drills that share a
|
||||||
for that version extracts non-empty; no tag or release exists yet.
|
substrate share **one run ID** so the per-repo records can be joined after
|
||||||
Then, same job, it tags the merge commit bare `X.Y.Z` (no `v` prefix —
|
the fact. The full meaning — the fixed candidate-ref pinning that dissolves
|
||||||
box's tag scheme) and publishes the GitHub release with that section as
|
the box↔rig recursion, the per-version record files, the waiver rule — is
|
||||||
the body. No assets — the source tarball for the tag is the package
|
[`drills/README.md`](drills/README.md); the `drill-recorded` guard enforces
|
||||||
`install.sh` downloads.
|
the record on every release tree.
|
||||||
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
|
## Labels — who sets what
|
||||||
|
|
||||||
The full taxonomy lives in [LABELS.md](LABELS.md). What matters day to day is
|
The taxonomy and state machine are
|
||||||
who sets each kind — most of it is machinery, and hand-moving a
|
[`.ceremony/LABELS.md`](.ceremony/LABELS.md); rig's `scope:*` rows live in
|
||||||
machine-owned label just gets corrected on the next pass:
|
`.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:
|
||||||
|
|
||||||
| Labels | Set by |
|
| Labels | Set by |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `state:*` | the labels workflow ([.github/workflows/labels.yml](.github/workflows/labels.yml)) — recomputed from GitHub's own facts every 15 minutes and on PR events. Never by hand. |
|
| `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.* |
|
||||||
|
| `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. |
|
| `stale` | the same workflow — 48h without commits, comments, or reviews. `blocked` PRs are exempt: they are quiet legitimately. |
|
||||||
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
|
| `scope:*` on PRs | actions/labeler, from the changed paths ([.github/labeler.yml](.github/labeler.yml)). Additive — you may add more, the machine won't remove them. |
|
||||||
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
|
| `scope:*` on issues | you, when opening or triaging — issues have no paths to derive from. |
|
||||||
| `blocked`, `release` | you — automation never guesses intent. |
|
| `blocked`, `release` | you — automation never guesses intent. |
|
||||||
|
| `merge-next` | you or the agent owning the queue. Which PR lands first is a judgement about how they conflict, so the workflow never sets it — it only **clears** it, the moment the PR stops being something a human could merge. |
|
||||||
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
|
| `bug` / `enhancement` / `documentation` | you, on issues only — a PR's type already lives in its title. |
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
|
||||||
87
LABELS.md
87
LABELS.md
|
|
@ -1,87 +0,0 @@
|
||||||
# Labels
|
|
||||||
|
|
||||||
How this repo uses GitHub labels. The taxonomy is shared across the
|
|
||||||
heavy-duty repos (box, rig, cast) — only the `scope:` set differs per repo,
|
|
||||||
because it names this repo's actual surfaces.
|
|
||||||
|
|
||||||
## State — who is the ball with? (PRs, exactly one)
|
|
||||||
|
|
||||||
Every open PR carries exactly one `state:` label, and it answers the only
|
|
||||||
question a board scan actually asks: *who is this PR waiting on?* The states
|
|
||||||
mirror the review loop this repo runs — PRs open as drafts, three reviewer
|
|
||||||
bots pick up ready PRs with reviews requested, each round is answered in a
|
|
||||||
single reply, and a human takes the final review.
|
|
||||||
|
|
||||||
| Label | Color | Waiting on | Enters when | Leaves when |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `state:building` | `#FBCA04` | the coding agent, still building | PR opened as draft | marked ready + bot reviews requested |
|
|
||||||
| `state:bots-reviewing` | `#1D76DB` | the reviewer bots to finish the round | ready with reviews requested, or fixes pushed and reviews re-requested | all three bots have reviewed the round |
|
|
||||||
| `state:addressing` | `#D93F0B` | the coding agent to reply and push fixes | all bots reviewed the round, not all approved | the single round-reply is posted and fixes pushed |
|
|
||||||
| `state:needs-human` | `#8250DF` | the human reviewer | the human review is requested — by the author when the round passes, or automatically on three formal head-current approvals | merged — or changes requested, which cycles back to `state:addressing` |
|
|
||||||
|
|
||||||
`bots-reviewing` and `addressing` are deliberately distinct: staleness in the
|
|
||||||
first means *poke the bots*, staleness in the second means *the agent dropped
|
|
||||||
the ball*. Collapsing them loses exactly the information a sweep needs.
|
|
||||||
|
|
||||||
## Cross-cutting (PRs and issues)
|
|
||||||
|
|
||||||
| Label | Color | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `stale` | `#B60205` | No activity for 48h. Sweep-managed, never hand-applied. `state:building` + `stale` is precisely a forgotten draft. |
|
|
||||||
| `blocked` | `#6A737D` | Waiting on another PR or issue to land first. Quiet *legitimately* — the staleness sweep skips it. |
|
|
||||||
| `release` | `#0E8A16` | Release flow, versioning, and packaging work. |
|
|
||||||
|
|
||||||
## Scope — which surface? (PRs and issues, any number)
|
|
||||||
|
|
||||||
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert.
|
|
||||||
|
|
||||||
| Label | Covers |
|
|
||||||
|---|---|
|
|
||||||
| `scope:bootstrap` | `commands/bootstrap.sh` — hardening a pristine server into a node |
|
|
||||||
| `scope:users` | `commands/users-*` — the class 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 written by automation, never by hand. Every state above is
|
|
||||||
derivable from GitHub's own facts — the draft flag, requested reviewers,
|
|
||||||
review states, push timestamps — so the labels workflow
|
|
||||||
([.github/workflows/labels.yml](.github/workflows/labels.yml)) recomputes the
|
|
||||||
state and reconciles labels statelessly, on a 15-minute cron plus PR events.
|
|
||||||
A hand-moved label is a lie waiting to happen; the workflow asserts the
|
|
||||||
effective state instead. `scope:` labels on PRs are applied from the changed
|
|
||||||
paths by actions/labeler ([.github/labeler.yml](.github/labeler.yml));
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) says who sets what.
|
|
||||||
|
|
||||||
The same workflow bootstraps the taxonomy: a manual dispatch creates any
|
|
||||||
missing label idempotently. To create them by hand (needs push access):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
gh label create "state:building" --color FBCA04 --description "PR is a draft — the coding agent is still building" --force
|
|
||||||
gh label create "state:bots-reviewing" --color 1D76DB --description "Waiting on the bot reviewers to finish the round" --force
|
|
||||||
gh label create "state:addressing" --color D93F0B --description "All bots reviewed — coding agent owes the single reply + fixes" --force
|
|
||||||
gh label create "state:needs-human" --color 8250DF --description "All bots approve — waiting on the human reviewer" --force
|
|
||||||
gh label create "stale" --color B60205 --description "No activity for 48h — needs a poke (sweep-managed)" --force
|
|
||||||
gh label create "blocked" --color 6A737D --description "Waiting on another PR or issue to land first" --force
|
|
||||||
gh label create "release" --color 0E8A16 --description "Release flow and version/packaging work" --force
|
|
||||||
gh label create "scope:bootstrap" --color C5DEF5 --description "bootstrap — hardening a pristine server into a node" --force
|
|
||||||
gh label create "scope:users" --color C5DEF5 --description "users-* — class 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
|
|
||||||
```
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.1.0
|
0.3.2-dev
|
||||||
|
|
|
||||||
139
bin/rig
139
bin/rig
|
|
@ -13,25 +13,43 @@ usage() {
|
||||||
usage: rig <command> [args]
|
usage: rig <command> [args]
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
bootstrap <control-plane|workload|runner|dev|workstation|custom>
|
bootstrap <control-plane-server|workload-server|runner-server|staging-server|
|
||||||
|
dev-server|workstation|custom>
|
||||||
|
(--users <path> | --no-users)
|
||||||
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
|
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
|
||||||
[--join <authkey|login>]
|
[--join <authkey|login>]
|
||||||
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
|
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
|
||||||
tailscale join. Roles are presets over the three traits; any flag
|
tailscale join, then this box's operators. --users is REQUIRED on
|
||||||
|
every role — it converges the users file as bootstrap's last phase
|
||||||
|
(identical to 'rig users apply --file <path>', marker written first)
|
||||||
|
so one command leaves a box with its people on it; --no-users is the
|
||||||
|
deliberate opt-out and leaves root as the only door. The tenant roles
|
||||||
|
below take neither. Roles are presets over the three traits; any flag
|
||||||
overrides its trait, and custom states all of them. Prompts for a
|
overrides its trait, and custom states all of them. Prompts for a
|
||||||
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
|
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
|
||||||
prompt); the key's tags are the tailnet tag, verified after join —
|
prompt); the key's tags are the tailnet tag, verified after join —
|
||||||
only control-plane and workload may carry tag:server. join=login
|
only control-plane-server and workload-server may carry tag:server.
|
||||||
|
The '-server' suffix names the FAMILY (a fleet machine, vs the '-box'
|
||||||
|
tenant roles); custom and workstation take none. join=login
|
||||||
(workstation) needs no key: interactive login, node must come up
|
(workstation) needs no key: interactive login, node must come up
|
||||||
untagged. Run as root.
|
untagged. Run as root.
|
||||||
bootstrap <claude|codex|grok|staging> [--user <name>]
|
bootstrap --undo
|
||||||
The box TENANT roles: converge a box-minted guest. claude|codex|grok
|
Leave the tailnet only when /etc/rig/role proves rig performed the
|
||||||
land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on
|
join, then remove that marker. Refuses for pre-existing or old unknown
|
||||||
the system PATH, and the agent-context file (with the box#80 guard:
|
joins, and while a GitHub runner is installed. Run as root.
|
||||||
never run box setup-host inside a box). staging lands box#69's server
|
bootstrap <role>-box [--user <name>]
|
||||||
posture — docker + sshd hardening; its tailnet join stays operator-run
|
The box TENANT roles: converge a box-minted guest. The '-box' suffix
|
||||||
via 'rig bootstrap workload'. Creds-free and non-interactive: box
|
names the family (a guest, vs the '-server' machine roles above).
|
||||||
auto-runs these at mint. Run as root, inside the box.
|
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.
|
||||||
coolify install --version <pin>
|
coolify install --version <pin>
|
||||||
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
||||||
coolify backup install [options]
|
coolify backup install [options]
|
||||||
|
|
@ -44,6 +62,15 @@ commands:
|
||||||
writes a gzipped SQL artifact (--no-owner --no-acl, so it restores
|
writes a gzipped SQL artifact (--no-owner --no-acl, so it restores
|
||||||
onto a different instance); `restore` loads one back, connecting as
|
onto a different instance); `restore` loads one back, connecting as
|
||||||
the container's own superuser, behind a confirm gate. Run as root.
|
the container's own superuser, behind a confirm gate. Run as root.
|
||||||
|
platform
|
||||||
|
What is this machine: hostname, OS, kernel, CPU, memory, disk and
|
||||||
|
virtualization, then rig's own provenance (which rig, when, and the
|
||||||
|
role marker bootstrap wrote). Computed at run time from /proc, uname,
|
||||||
|
/etc/os-release, df and systemd-detect-virt and stored NOWHERE — a
|
||||||
|
spec goes stale the moment someone adds RAM, so there is nothing to
|
||||||
|
go stale here. Writes nothing, needs no root, makes no network call,
|
||||||
|
and therefore also runs on a pristine Debian box rig has never
|
||||||
|
bootstrapped, where the provenance block reads 'not bootstrapped'.
|
||||||
runner install --repo <owner/repo> [options]
|
runner install --repo <owner/repo> [options]
|
||||||
GitHub Actions runner as a systemd service under an unprivileged
|
GitHub Actions runner as a systemd service under an unprivileged
|
||||||
user — outbound-only, no Docker. Prompts for the short-lived
|
user — outbound-only, no Docker. Prompts for the short-lived
|
||||||
|
|
@ -60,6 +87,23 @@ commands:
|
||||||
re-register, reusing the binary already on the box. Needs a removal
|
re-register, reusing the binary already on the box. Needs a removal
|
||||||
token for the old repo and a registration token for the new one.
|
token for the old repo and a registration token for the new one.
|
||||||
Run as root.
|
Run as root.
|
||||||
|
forgejo-runner install --instance <url> [options]
|
||||||
|
Forgejo Actions runner as a systemd service under an unprivileged
|
||||||
|
user — outbound-only, jobs in containers on this box's own dockerd.
|
||||||
|
Its home is a ci-box tenant, where 'rig bootstrap ci-box' already
|
||||||
|
installed that daemon. Registers to an INSTANCE: whether the runner
|
||||||
|
serves that instance, one org or one repo is a property of the
|
||||||
|
registration token, so there is no --repo. Prompts for the token
|
||||||
|
(FORGEJO_RUNNER_TOKEN env overrides). Run as root.
|
||||||
|
forgejo-runner status [--user <name>]
|
||||||
|
Which instance this box's Forgejo runner is registered to: instance,
|
||||||
|
name, labels, unit. Reads the box only — no token, no network call,
|
||||||
|
and never prints the registration secret .runner holds. Run as root.
|
||||||
|
forgejo-runner remove [--user <name>]
|
||||||
|
Take the service down and wipe this box's registration. Always
|
||||||
|
local-only: Forgejo has no deregistration endpoint, so the runner
|
||||||
|
stays listed offline until you delete it in the instance. Needs no
|
||||||
|
token. Run as root.
|
||||||
users apply --file <path>
|
users apply --file <path>
|
||||||
Converge named operator accounts from a declarative users file, on
|
Converge named operator accounts from a declarative users file, on
|
||||||
every class: groups by role (admin/rig/box), passwords locked always,
|
every class: groups by role (admin/rig/box), passwords locked always,
|
||||||
|
|
@ -72,6 +116,19 @@ commands:
|
||||||
Shut root SSH on a class=human box once an admin key works. Refuses
|
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 —
|
on class=server — root there is the control plane's automation door —
|
||||||
and while no admin holds a key. Run as root.
|
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
|
||||||
|
name the latest). With a key, print that value alone for shell callers.
|
||||||
|
Written by 'rig bootstrap' only; read-only here, and no root needed.
|
||||||
|
Provenance is DECIDED, so it is stored; the machine's specs are
|
||||||
|
OBSERVED, so they are not — those are 'rig platform'.
|
||||||
versions
|
versions
|
||||||
List the installed rig versions — install.sh lands each one side by
|
List the installed rig versions — install.sh lands each one side by
|
||||||
side at <root>/versions/<v>, a 'current' symlink tracks the default
|
side at <root>/versions/<v>, a 'current' symlink tracks the default
|
||||||
|
|
@ -95,10 +152,12 @@ commands:
|
||||||
|
|
||||||
install/upgrade:
|
install/upgrade:
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||||
Installs the latest RELEASE (RIG_REF=<tag> pins one, RIG_REF=main
|
Installs the latest RELEASE from GitHub (the default RIG_HOST).
|
||||||
tracks the development tree). Re-run any time: an installed version
|
RIG_REF=<tag> pins one, RIG_REF=main tracks the development tree.
|
||||||
converges (no-op), a new one installs side by side at
|
From a Forgejo host: set RIG_HOST to that origin and fetch install.sh
|
||||||
<root>/versions/<v> and becomes the default.
|
from <host>/heavy-duty/rig/raw/branch/main/install.sh (#111).
|
||||||
|
Re-run any time: an installed version converges (no-op), a new one
|
||||||
|
installs side by side at <root>/versions/<v> and becomes the default.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +290,12 @@ uninstall_confirm() { # $1 = question
|
||||||
fi
|
fi
|
||||||
local reply
|
local reply
|
||||||
printf 'rig: %s? [y/N] ' "$1"
|
printf 'rig: %s? [y/N] ' "$1"
|
||||||
read -r reply
|
# `|| reply=""` is load-bearing under `set -e`: Ctrl-D makes read return
|
||||||
|
# non-zero, and as a plain-statement call site this function would die HERE,
|
||||||
|
# before the case — the abort would be real but completely silent. Empty
|
||||||
|
# falls through to the `*)` arm, so EOF aborts with exactly one message,
|
||||||
|
# the same spelling commands/db.sh uses for the same [y/N] shape.
|
||||||
|
read -r reply || reply=""
|
||||||
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,6 +432,10 @@ case "$cmd" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
platform)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/platform.sh" "$@"
|
||||||
|
;;
|
||||||
runner)
|
runner)
|
||||||
shift
|
shift
|
||||||
sub="${1:-}"
|
sub="${1:-}"
|
||||||
|
|
@ -394,6 +462,37 @@ case "$cmd" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
forgejo-runner)
|
||||||
|
shift
|
||||||
|
sub="${1:-}"
|
||||||
|
case "$sub" in
|
||||||
|
install)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/forgejo-runner-install.sh" "$@"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/forgejo-runner-status.sh" "$@"
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/forgejo-runner-remove.sh" "$@"
|
||||||
|
;;
|
||||||
|
repoint)
|
||||||
|
# The GitHub sibling HAS this verb, so an operator will try it. Say why
|
||||||
|
# it cannot exist here rather than printing usage and leaving them to
|
||||||
|
# infer it: 'repoint' is atomic because GitHub lets rig deregister from
|
||||||
|
# the old repo; Forgejo has no such endpoint, so the honest shape is
|
||||||
|
# two acts, and one of them leaves a stale entry behind.
|
||||||
|
printf 'rig: forgejo-runner has no repoint: Forgejo has no deregistration endpoint, so a move cannot be one atomic act. Use "rig forgejo-runner remove" then "rig forgejo-runner install --instance <url>", and delete the stale runner in the old instance under Actions > Runners.\n' >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
users)
|
users)
|
||||||
shift
|
shift
|
||||||
sub="${1:-}"
|
sub="${1:-}"
|
||||||
|
|
@ -416,6 +515,14 @@ case "$cmd" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
template-lint)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/template-lint.sh" "$@"
|
||||||
|
;;
|
||||||
|
manifest)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/manifest.sh" "$@"
|
||||||
|
;;
|
||||||
versions)
|
versions)
|
||||||
shift
|
shift
|
||||||
cmd_versions "$@"
|
cmd_versions "$@"
|
||||||
|
|
|
||||||
4
changelog.d/109.md
Normal file
4
changelog.d/109.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `rig forgejo-runner install|status|remove` registers a Forgejo Actions runner against an instance, jobs in containers on the box's own dockerd (#109)
|
||||||
|
- The template registry can be served from any forge — `RIG_TEMPLATES_HOST` (#109)
|
||||||
3
changelog.d/111.md
Normal file
3
changelog.d/111.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `install.sh` and bootstrap's box fetch take `RIG_HOST` / `BOX_HOST` so a Forgejo origin can serve the install channel (#111)
|
||||||
3
changelog.d/112.md
Normal file
3
changelog.d/112.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- rig's six ceremony CI guards resolve on Forgejo (#112)
|
||||||
3
changelog.d/116.md
Normal file
3
changelog.d/116.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Ceremony recognizes the Forgejo review panel and both forges' triage actors (#116)
|
||||||
3
changelog.d/129.md
Normal file
3
changelog.d/129.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The release drill exercises `rig forgejo-runner` beside `rig runner`, so both shipped runner families carry evidence (#129)
|
||||||
3
changelog.d/131.md
Normal file
3
changelog.d/131.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The README's install quick start documents the Forgejo channel — `RIG_HOST` shipped in #111 but was documented nowhere (#131)
|
||||||
3
changelog.d/133.md
Normal file
3
changelog.d/133.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `rig forgejo-runner status` no longer lets a service's `(active)` stand as proof the runner is fetching jobs (#133)
|
||||||
3
changelog.d/135.md
Normal file
3
changelog.d/135.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Forgejo runners installed by rig can start their cache server — `$HOME/.cache` is created and punched through `ProtectHome` (#135)
|
||||||
4
changelog.d/139.md
Normal file
4
changelog.d/139.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `rig forgejo-runner install`, `rig runner install`, `rig users apply` and `rig bootstrap <tenant>` refuse with a named remedy when root's `PATH` carries no `/usr/sbin`, instead of dying on `useradd: command not found` after prompting for a token (#139)
|
||||||
|
- `rig users apply` no longer reports success having silently skipped the sudoers drop-in when `visudo` is off `PATH` (#139)
|
||||||
8
changelog.d/144.md
Normal file
8
changelog.d/144.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Default Forgejo runner labels include opt-in `ubuntu-latest-full` for the GitHub-parity image (#144)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `ci.yml` installs `shellcheck` when the runner image lacks it, so Forgejo's slim `ubuntu-latest` can run `check` (#144)
|
||||||
|
- Plain `rig forgejo-runner install` warns when recorded labels are a retired rig default, without nagging custom `--labels` (#144)
|
||||||
3
changelog.d/152.md
Normal file
3
changelog.d/152.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Machine-role templates can declare bootstrap traits and an optional final root install hook (#152)
|
||||||
3
changelog.d/153.md
Normal file
3
changelog.d/153.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Pinned template registries install with rig and serve default converges offline (#153)
|
||||||
3
changelog.d/160.md
Normal file
3
changelog.d/160.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### 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)
|
||||||
3
changelog.d/162.md
Normal file
3
changelog.d/162.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Agent tenant boxes ship cron — binary asserted, service enabled and active — so the duty engine can arm its timer (#162)
|
||||||
10
changelog.d/README.md
Normal file
10
changelog.d/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# 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,27 +1,42 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# rig bootstrap <claude|codex|grok|staging> — the box TENANT roles: what a
|
# rig bootstrap <role>-box — the box TENANT roles ('-box' names the family: a
|
||||||
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed
|
# guest, vs the '-server' machine roles): what a box-minted guest becomes
|
||||||
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the
|
# (issue #31). box mints the thin, creds-free seed (base image, user, rig
|
||||||
# tenant content that used to live in the templates' cloud-init, idempotent and
|
# preinstalled — heavy-duty/box#81); rig converges the tenant content that
|
||||||
# effective-state asserted, so an EXISTING box can be re-run to a new spec
|
# used to live in the templates' cloud-init, idempotent and effective-state
|
||||||
# instead of re-minted. One mechanism, parameterized per tenant through
|
# asserted, so an EXISTING box can be re-run to a new spec instead of
|
||||||
# lib/tenant-config.sh — never four hand-maintained copies.
|
# 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.
|
||||||
#
|
#
|
||||||
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
|
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
|
||||||
# bootstrap claude'), so every path here is non-interactive and nothing joins
|
# bootstrap claude-box'), so every path here is non-interactive and nothing joins
|
||||||
# or admits — no tailnet, no keys, no prompts. staging's tailnet join stays
|
# or admits — no tailnet, no keys, no prompts. That is also why the registry
|
||||||
# operator-run ('rig bootstrap workload' through 'box shell'), exactly the
|
# fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
|
||||||
# creds split box#69 designed.
|
# 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.
|
# Convergent: safe to re-run; a second run changes nothing.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
|
# shellcheck source=SCRIPTDIR/lib/templates.sh
|
||||||
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
|
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
|
||||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
. "$HERE/lib/users-config.sh" # read_role_marker
|
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
||||||
|
. "$HERE/lib/admin-path.sh" # require_admin_bins
|
||||||
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||||
. "$HERE/lib/sshd.sh" # harden_sshd (the staging tenant)
|
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/manifest.sh
|
||||||
|
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
|
||||||
|
|
||||||
log() { printf 'rig-bootstrap: %s\n' "$*"; }
|
log() { printf 'rig-bootstrap: %s\n' "$*"; }
|
||||||
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
|
||||||
|
|
@ -29,28 +44,39 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: rig bootstrap <claude|codex|grok|staging> [--user <name>]
|
usage: rig bootstrap <role>-box [--user <name>]
|
||||||
|
|
||||||
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
|
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
|
||||||
creds-free seed (base image, user, rig preinstalled); this converges the
|
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.
|
tenant on top, and re-runs converge an existing box to a new spec.
|
||||||
|
|
||||||
claude|codex|grok the agent tenants: base tooling (git, gh, tmux, …),
|
<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, …),
|
||||||
docker, the agent's CLI on the system PATH, and the
|
docker, the agent's CLI on the system PATH, and the
|
||||||
agent-context file — including the box#80 guard: never
|
agent-context file — including the box#80 guard: never
|
||||||
run `box setup-host` or the drill inside a box.
|
run `box setup-host` or the drill inside a box.
|
||||||
staging the server tenant (box#69's posture): docker + sshd
|
staging-box the server tenant (box#69's posture), in rig's own
|
||||||
hardening. The tailnet workload join is deliberately
|
tree: docker + sshd hardening. The tailnet workload
|
||||||
NOT here — it holds a credential, so it stays
|
join is deliberately NOT here — it holds a credential,
|
||||||
operator-run: `box shell` → `sudo rig bootstrap
|
so it stays operator-run: `box shell` → `sudo rig
|
||||||
workload` with a tagged pre-auth key.
|
bootstrap workload-server` with a tagged pre-auth key.
|
||||||
|
|
||||||
--user <name> the tenant user the box seed created (default: the
|
--user <name> the tenant user the box seed created (default: the
|
||||||
role's name; staging defaults to `ops`)
|
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)
|
||||||
|
|
||||||
Tenant roles are creds-free and non-interactive by contract — box auto-runs
|
Tenant roles are creds-free and non-interactive by contract — box auto-runs
|
||||||
them at mint (`box exec … rig bootstrap claude`). They take none of the
|
them at mint (`box exec … rig bootstrap claude-box`). They take none of the
|
||||||
machine-role traits (--hostname/--class/--host/--join): a tenant is a guest,
|
machine-role traits (--hostname/--root-door/--host/--join): a tenant is a guest,
|
||||||
not a tailnet machine. Run as root, inside the box.
|
not a tailnet machine. Run as root, inside the box.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
@ -58,36 +84,48 @@ EOF
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
ROLE="${1:-}"
|
ROLE="${1:-}"
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
claude|codex|grok|staging) shift ;;
|
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 ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
"") usage >&2; die "tenant role required (claude|codex|grok|staging)" 2 ;;
|
"") usage >&2; die "tenant role required (a '-box' role from the template registry, or staging-box)" 2 ;;
|
||||||
*) die "unknown tenant role: $ROLE (want claude|codex|grok|staging)" 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 ;;
|
||||||
esac
|
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="$(tenant_user "$ROLE")"
|
TENANT_USER_OVERRIDE=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
--user)
|
--user)
|
||||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||||
TENANT_USER="$2"; shift 2 ;;
|
TENANT_USER_OVERRIDE="$2"; shift 2
|
||||||
--hostname|--class|--host|--join)
|
# 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 ;;
|
||||||
|
--hostname|--root-door|--host|--join)
|
||||||
# The machine-role traits, refused with a story rather than "unknown
|
# The machine-role traits, refused with a story rather than "unknown
|
||||||
# flag": a tenant is a guest, not a tailnet machine — its shape comes
|
# flag": a tenant is a guest, not a tailnet machine — its shape comes
|
||||||
# from the box seed, and the one trait-shaped thing a staging guest
|
# from the box seed, and the one trait-shaped thing a staging-box guest
|
||||||
# eventually does (join the tailnet as a workload) is deliberately not
|
# eventually does (join the tailnet as a workload) is deliberately not
|
||||||
# here: it holds a credential, so it stays operator-run.
|
# here: it holds a credential, so it stays operator-run.
|
||||||
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane|workload|runner|dev|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload'." 2 ;;
|
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging-box's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 2 ;;
|
||||||
--ts-tag)
|
--ts-tag)
|
||||||
[ $# -ge 2 ] && shift
|
[ $# -ge 2 ] && shift
|
||||||
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload', where the tag comes from the pre-auth key." 2 ;;
|
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging-box's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;;
|
||||||
*) die "unknown flag: $1" 2 ;;
|
*) die "unknown flag: $1" 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
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 ------------------------------------------------------------------
|
# --- guards ------------------------------------------------------------------
|
||||||
# A tenant role converges a box GUEST. A box already carrying a machine-role
|
# A tenant role converges a box GUEST. A box already carrying a machine-role
|
||||||
|
|
@ -97,32 +135,82 @@ done
|
||||||
# markers (repo precedent: the coolify marker warning). Two refusals, one
|
# markers (repo precedent: the coolify marker warning). Two refusals, one
|
||||||
# tolerance:
|
# tolerance:
|
||||||
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
|
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
|
||||||
# Names the staging rename out loud — before #31, `staging` was the VM-host
|
# Names the staging PAIR out loud, because whoever lands here has the two
|
||||||
# PRESET; that shape is now spelled through the traits.
|
# halves confused: the metal is `staging-server`, the guest `staging-box`.
|
||||||
# - class= (agent tenants) → refuse: an agent box is never a tailnet machine.
|
# - a root-door policy (agent tenants) → refuse: an agent box is never a
|
||||||
# - class=server with host=no (staging only) → PROCEED, and leave the marker
|
# tailnet machine.
|
||||||
# alone: that is the staging guest AFTER its operator-run workload join, and
|
# - root-door=open with host=no (staging-box only) → PROCEED, and leave the
|
||||||
|
# marker alone: that is the guest AFTER its operator-run workload join, and
|
||||||
# re-converging docker+hardening on it is exactly what convergence is for.
|
# re-converging docker+hardening on it is exactly what convergence is for.
|
||||||
# ONLY that shape — any other class (say class=human, via `custom`) is a
|
# ONLY that shape — any other door policy (say root-door=closed, via
|
||||||
# machine rig built on purpose, and staging hardening it with server rules
|
# `custom`) is a machine rig built on purpose, and staging-box hardening it
|
||||||
# would die with server-specific messaging on a box that was never one.
|
# with open-door rules would die with root-door=open-specific messaging on a
|
||||||
|
# box that was never one.
|
||||||
|
#
|
||||||
|
# "Names a root-door policy" IS this guard's "is this a machine marker?" test —
|
||||||
|
# a tenant marker deliberately carries none — so it must be asked through
|
||||||
|
# root_door_of, which reads the pre-#77 `class=` spelling as well as the current
|
||||||
|
# `root-door=` one. Pattern-matching the marker for one spelling is what this
|
||||||
|
# guard used to do, and after the rename that is a fail-OPEN bug in the
|
||||||
|
# dangerous direction: every box bootstrapped in the OTHER vocabulary stops
|
||||||
|
# looking like a machine, the refusals below never fire, and a tenant converge
|
||||||
|
# clobbers a real fleet box's marker. The resolver is the only reader.
|
||||||
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
|
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
|
||||||
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
|
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
|
||||||
|
EXISTING_ROOT_DOOR="$(root_door_of "$EXISTING_MARKER")"
|
||||||
case "$EXISTING_MARKER" in
|
case "$EXISTING_MARKER" in
|
||||||
*host=yes*)
|
*host=yes*)
|
||||||
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. Note: before rig#31, 'staging' was the VM-host preset; that shape is now 'rig bootstrap custom --class server --host yes --join authkey' (or 'dev --class server')." ;;
|
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;;
|
||||||
*class=*)
|
esac
|
||||||
if [ "$ROLE" != "staging" ]; then
|
if [ -n "$EXISTING_ROOT_DOOR" ]; then
|
||||||
|
if [ "$ROLE" != "staging-box" ]; then
|
||||||
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
|
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
|
||||||
fi
|
fi
|
||||||
case "$EXISTING_MARKER" in
|
# A `conflict` marker lands here too, and refuses: a box whose two door
|
||||||
*class=server*) ;;
|
# claims disagree is emphatically not the one shape staging-box tolerates.
|
||||||
*)
|
if [ "$EXISTING_ROOT_DOOR" != "open" ]; then
|
||||||
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging tolerates only the workload-joined guest (class=server host=no). If this really is a staging guest, remove ${MARKER_PATH} and re-run." ;;
|
die "this box carries a machine role whose root door is not open (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (root-door=open host=no, or its pre-#77 spelling class=server). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run."
|
||||||
esac ;;
|
fi
|
||||||
esac
|
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"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
# Root is not enough here either (#139). This mint adds the tenant user to the
|
||||||
|
# docker group with `usermod` far below — AFTER installing docker and node,
|
||||||
|
# which is what makes it the worst-placed of the four call sites: on a
|
||||||
|
# PATH-shorn root it dies mid-convergence with a bare `usermod: command not
|
||||||
|
# found`, having already changed the machine, rather than before touching it.
|
||||||
|
#
|
||||||
|
# Unconditional because the docker block below is unconditional — "every tenant
|
||||||
|
# gets docker" is the stated rule there, so every tenant reaches the usermod.
|
||||||
|
require_admin_bins usermod
|
||||||
if [ -r /etc/os-release ]; then
|
if [ -r /etc/os-release ]; then
|
||||||
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
|
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
|
||||||
# sourcing it in the main shell silently clobbers same-named script vars.
|
# sourcing it in the main shell silently clobbers same-named script vars.
|
||||||
|
|
@ -156,34 +244,60 @@ append_line_once() {
|
||||||
chown "$TENANT_USER:$TENANT_GROUP" "$file"
|
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 ----------------------------------------------------------------
|
# --- packages ----------------------------------------------------------------
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
log "installing base packages (tenant ${ROLE})"
|
log "installing base packages (tenant ${ROLE})"
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
case "$ROLE" in
|
if [ "$ROLE" = "staging-box" ]; then
|
||||||
claude)
|
|
||||||
# The claude 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|grok)
|
|
||||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
|
|
||||||
staging)
|
|
||||||
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
|
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
|
||||||
# which only exists once the package is installed — pristine container/VM
|
# which only exists once the package is installed — pristine container/VM
|
||||||
# images (and thin seeds) do not ship it.
|
# images (and thin seeds) do not ship it.
|
||||||
apt-get install -y -qq curl ca-certificates tmux openssh-server ;;
|
apt-get install -y -qq curl ca-certificates tmux openssh-server
|
||||||
esac
|
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
|
||||||
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
|
# 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
|
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
|
||||||
# the operator's git credential lands.
|
# the operator's git credential lands.
|
||||||
command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it"
|
command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it"
|
||||||
if [ "$ROLE" != "staging" ]; then
|
if [ "$ROLE" != "staging-box" ]; then
|
||||||
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
|
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 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
|
fi
|
||||||
|
|
||||||
# --- docker ------------------------------------------------------------------
|
# --- docker ------------------------------------------------------------------
|
||||||
# Every tenant gets docker (the templates all carried it; staging's guests run
|
# Every tenant gets docker (the templates all carried it; staging-box's workloads run
|
||||||
# their workloads in it). Docker's own installer, convergence-guarded — its
|
# their workloads in it). Docker's own installer, convergence-guarded — its
|
||||||
# script is not a no-op when docker exists, so rig supplies the guard.
|
# script is not a no-op when docker exists, so rig supplies the guard.
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
|
@ -214,17 +328,19 @@ else
|
||||||
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
|
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- node (claude, codex) ----------------------------------------------------
|
# --- node (definitions carrying NEEDS_NODE="yes") ----------------------------
|
||||||
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified
|
# An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
|
||||||
# upstream when the template was written); the claude tenant ships node as part
|
# verified upstream when the template was written); claude ships node as part
|
||||||
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node.
|
# 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_ok() {
|
node_ok() {
|
||||||
command -v node >/dev/null 2>&1 || return 1
|
command -v node >/dev/null 2>&1 || return 1
|
||||||
local major
|
local major
|
||||||
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
|
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
|
||||||
[ "${major:-0}" -ge 22 ] 2>/dev/null
|
[ "${major:-0}" -ge 22 ] 2>/dev/null
|
||||||
}
|
}
|
||||||
if [ "$ROLE" = "claude" ] || [ "$ROLE" = "codex" ]; then
|
if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
|
||||||
if node_ok; then
|
if node_ok; then
|
||||||
log "node $(node --version) already present"
|
log "node $(node --version) already present"
|
||||||
else
|
else
|
||||||
|
|
@ -236,46 +352,53 @@ if [ "$ROLE" = "claude" ] || [ "$ROLE" = "codex" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- the agent CLI -----------------------------------------------------------
|
# --- the agent CLI -----------------------------------------------------------
|
||||||
# Per-agent install, shared discipline: install only when the CLI is absent
|
# Per-definition install, shared discipline: install only when the CLI is
|
||||||
# (upgrades are the CLI's own business), then put it on the SYSTEM path —
|
# absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
|
||||||
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc
|
# definition names one, `command -v` when it does not (an npm global's path
|
||||||
# files, so a PATH export alone is invisible to it (the #15 lesson) — and
|
# is the prefix's fact, not the data file's) — then put it on the SYSTEM
|
||||||
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is
|
# path: 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads
|
||||||
# what cost the last drill (the grok template's scar).
|
# 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.
|
||||||
CLI="" CLI_SRC=""
|
CLI="" CLI_SRC=""
|
||||||
case "$ROLE" in
|
if [ "$ROLE" != "staging-box" ]; then
|
||||||
claude)
|
CLI="$TPL_CLI_NAME"
|
||||||
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude"
|
# '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
|
||||||
if [ ! -e "$CLI_SRC" ]; then
|
# substitution, never by the shell (hence the literal quoted tilde, SC2088).
|
||||||
log "installing the Claude Code CLI as ${TENANT_USER}"
|
# shellcheck disable=SC2088
|
||||||
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash'
|
case "$TPL_CLI_SRC" in
|
||||||
else
|
'~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
|
||||||
log "claude CLI already installed"
|
*) CLI_SRC="$TPL_CLI_SRC" ;;
|
||||||
fi ;;
|
esac
|
||||||
codex)
|
installed=""
|
||||||
CLI=codex
|
if [ -n "$CLI_SRC" ]; then
|
||||||
if ! command -v codex >/dev/null 2>&1; then
|
[ -e "$CLI_SRC" ] && installed=1
|
||||||
log "installing the Codex CLI (npm global)"
|
elif command -v "$CLI" >/dev/null 2>&1; then
|
||||||
npm install -g @openai/codex
|
installed=1
|
||||||
else
|
|
||||||
log "codex CLI already installed"
|
|
||||||
fi
|
fi
|
||||||
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
|
if [ -z "$installed" ]; then
|
||||||
grok)
|
log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
|
||||||
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`,
|
TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
|
||||||
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir.
|
TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
|
||||||
# Run it AS the tenant user, never root: a symlink into root's 0700 home
|
bash "$TPL_DIR/install.sh" \
|
||||||
# would be a CLI that exists and cannot run.
|
|| die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
|
||||||
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
|
else
|
||||||
log "grok CLI already installed"
|
log "${CLI} CLI already installed"
|
||||||
fi ;;
|
fi
|
||||||
staging) ;; # no agent lives on the staging tenant
|
if [ -z "$CLI_SRC" ]; then
|
||||||
esac
|
CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
|
||||||
if [ -n "$CLI" ]; then
|
[ -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
|
||||||
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
|
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
|
||||||
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
|
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
|
||||||
# One capture serves both the assert and the log line; emptiness IS the
|
# One capture serves both the assert and the log line; emptiness IS the
|
||||||
|
|
@ -284,27 +407,23 @@ if [ -n "$CLI" ]; 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"
|
[ -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})"
|
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
|
||||||
|
|
||||||
# The interactive-shell PATH exports the templates carried, converged as
|
# The interactive-shell PATH export the templates carried, converged as a
|
||||||
# literal rc lines (written once, never duplicated). Single quotes are the
|
# literal rc line (written once, never duplicated). The definition's
|
||||||
# point: the line must expand in the USER's shell, not here.
|
# PATH_LINE is DATA, appended verbatim: it must expand in the USER's
|
||||||
# shellcheck disable=SC2016
|
# shell, not here.
|
||||||
case "$ROLE" in
|
append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
|
||||||
claude)
|
|
||||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
|
|
||||||
codex)
|
|
||||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
|
|
||||||
grok)
|
|
||||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- the agent-context file --------------------------------------------------
|
# --- the agent-context file --------------------------------------------------
|
||||||
# The one file every agent reads before touching anything. Rendered from
|
# The one file every agent reads before touching anything. The skeleton —
|
||||||
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or
|
# including the box#80 guard note ("never run box setup-host or the drill
|
||||||
# the drill inside a box; the box you are in is not a host you own") lives
|
# inside a box; the box you are in is not a host you own") — is MECHANISM,
|
||||||
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded
|
# rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
|
||||||
# like every file rig converges.
|
# template; only the creds paragraph is the definition's (creds.md).
|
||||||
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
# 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"
|
||||||
CTX_DIR="$(dirname "$CTX_PATH")"
|
CTX_DIR="$(dirname "$CTX_PATH")"
|
||||||
if [ ! -d "$CTX_DIR" ]; then
|
if [ ! -d "$CTX_DIR" ]; then
|
||||||
mkdir -p "$CTX_DIR"
|
mkdir -p "$CTX_DIR"
|
||||||
|
|
@ -314,7 +433,7 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
||||||
# its ownership is converged on every run, not only on creation.
|
# its ownership is converged on every run, not only on creation.
|
||||||
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
|
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
|
||||||
CTX_TMP="$(mktemp)"
|
CTX_TMP="$(mktemp)"
|
||||||
render_tenant_context "$ROLE" > "$CTX_TMP"
|
render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$CTX_TMP"
|
||||||
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
|
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
|
||||||
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
|
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
|
||||||
log "agent-context file written: ${CTX_PATH}"
|
log "agent-context file written: ${CTX_PATH}"
|
||||||
|
|
@ -324,50 +443,31 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
||||||
rm -f "$CTX_TMP"
|
rm -f "$CTX_TMP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- claude shell niceties ---------------------------------------------------
|
# --- staging-box server posture --------------------------------------------------
|
||||||
# The claude 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" ]; 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 server posture --------------------------------------------------
|
|
||||||
# box#69's posture, minus the join: docker (above) + sshd hardening, through
|
# box#69's posture, minus the join: docker (above) + sshd hardening, through
|
||||||
# the SAME code the machine roles use (lib/sshd.sh) — the staging guest is a
|
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a
|
||||||
# workload server in waiting, and its door must never be password-open even
|
# workload server in waiting, and its door must never be password-open even
|
||||||
# before the operator joins it. class=server: root SSH stays the control
|
# before the operator joins it. root-door=open: root SSH stays the control
|
||||||
# plane's future automation door.
|
# plane's future automation door.
|
||||||
if [ "$ROLE" = "staging" ]; then
|
if [ "$ROLE" = "staging-box" ]; then
|
||||||
harden_sshd server
|
harden_sshd open
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- role marker --------------------------------------------------------------
|
# --- role marker --------------------------------------------------------------
|
||||||
# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant
|
# Same ground truth the machine roles write, tenant-shaped: no root-door trait
|
||||||
# has no root-door policy of its own — close-root fails closed on it), and
|
# at all (a tenant has no root-door policy of its own — close-root fails closed
|
||||||
# host=no so `rig users apply` box-role gating keeps working. staging SKIPS the
|
# on it), and host=no so `rig users apply` box-role gating keeps working.
|
||||||
# write when a machine marker is already present: after the operator-run
|
# staging-box SKIPS the write when a machine marker is already present: after
|
||||||
# workload join, the workload marker is the truer statement and rig never
|
# the operator-run workload join, the workload marker is the truer statement and
|
||||||
# clobbers state a joined box earned.
|
# rig never clobbers state a joined box earned.
|
||||||
if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then
|
#
|
||||||
|
# "Is a machine marker already here?" is the same question the guard above
|
||||||
|
# asks, and is asked the same way — through root_door_of, so both the current
|
||||||
|
# `root-door=` spelling and the pre-#77 `class=` one count (#77). Testing for
|
||||||
|
# one spelling would let this write CLOBBER a marker written in the other, which
|
||||||
|
# on a joined workload box means silently replacing its root-door policy with a
|
||||||
|
# tenant line that close-root then refuses on.
|
||||||
|
if [ -z "$EXISTING_ROOT_DOOR" ]; then
|
||||||
MARKER_TMP="$(mktemp)"
|
MARKER_TMP="$(mktemp)"
|
||||||
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
|
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
|
||||||
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
|
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
|
||||||
|
|
@ -382,9 +482,34 @@ else
|
||||||
log "machine role marker present (${EXISTING_MARKER}); leaving it alone"
|
log "machine role marker present (${EXISTING_MARKER}); leaving it alone"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- provenance manifest ------------------------------------------------------
|
||||||
|
# A tenant gets a manifest, in the SAME /etc/rig/manifest, through the same
|
||||||
|
# writer — and UNCONDITIONALLY, outside the marker gate above (#61's open
|
||||||
|
# question, answered here).
|
||||||
|
#
|
||||||
|
# The reason the marker needs that gate is that it holds TRAITS, and a guest's
|
||||||
|
# traits and the traits it earns after an operator-run `rig bootstrap workload`
|
||||||
|
# join are two different, competing statements about one box — so the marker
|
||||||
|
# has to pick, and it picks the truer one. Provenance has no such conflict.
|
||||||
|
# "Which rig converged this guest, and when" is a fact whichever bootstrap ran,
|
||||||
|
# and the two-pair shape composes across them exactly as designed: a staging
|
||||||
|
# guest later joined as a workload keeps the TENANT bootstrap as its birth —
|
||||||
|
# that genuinely is when this machine was first converged — and the machine
|
||||||
|
# bootstrap moves converged_* forward. Skipping the write on a joined guest
|
||||||
|
# would lose the birth stamp that only this run knows.
|
||||||
|
#
|
||||||
|
# One file, not a tenant-shaped second one: the manifest answers a question
|
||||||
|
# about the MACHINE, and a guest is a machine. The marker already carries
|
||||||
|
# `tenant=yes` for anyone who needs to know which kind.
|
||||||
|
if manifest_stamp "$(manifest_running_version "$HERE/..")"; then
|
||||||
|
log "provenance manifest written: $(manifest_path)"
|
||||||
|
else
|
||||||
|
log "provenance manifest already current"
|
||||||
|
fi
|
||||||
|
|
||||||
log "done — tenant ${ROLE}, user ${TENANT_USER}"
|
log "done — tenant ${ROLE}, user ${TENANT_USER}"
|
||||||
if [ "$ROLE" = "staging" ]; then
|
if [ "$ROLE" = "staging-box" ]; then
|
||||||
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname <name> with a tagged pre-auth key"
|
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --hostname <name> with a tagged pre-auth key"
|
||||||
else
|
else
|
||||||
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
|
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
74
commands/bootstrap-undo.sh
Executable file
74
commands/bootstrap-undo.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
||||||
|
#!/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
|
||||||
|
|
||||||
|
# The same hazard, the other forge (#109): leaving the tailnet under a live
|
||||||
|
# Forgejo runner strands a registration this box can no longer serve, and
|
||||||
|
# Forgejo has no deregistration endpoint — so the ghost it leaves is one
|
||||||
|
# somebody has to delete BY HAND in the instance's admin UI. That makes the
|
||||||
|
# refusal more load-bearing here than for GitHub, not less.
|
||||||
|
#
|
||||||
|
# RIG_FORGEJO_RUNNER_DIR mirrors RIG_RUNNER_DIR above so tests can point this
|
||||||
|
# at a fixture. The glob covers the tenant default (`ci`) and the dedicated
|
||||||
|
# account alike, because both are reachable defaults of `install --user`.
|
||||||
|
forgejo_runner_installed=0
|
||||||
|
if [ -n "${RIG_FORGEJO_RUNNER_DIR:-}" ]; then
|
||||||
|
[ -e "$RIG_FORGEJO_RUNNER_DIR/.runner" ] && forgejo_runner_installed=1
|
||||||
|
else
|
||||||
|
for runner_config in /home/*/forgejo-runner/.runner /root/forgejo-runner/.runner; do
|
||||||
|
[ -e "$runner_config" ] && forgejo_runner_installed=1
|
||||||
|
done
|
||||||
|
[ -e /etc/systemd/system/forgejo-runner.service ] && forgejo_runner_installed=1
|
||||||
|
fi
|
||||||
|
if [ "$forgejo_runner_installed" -eq 1 ]; then
|
||||||
|
die "a Forgejo runner is installed — run 'rig forgejo-runner remove' first so undo does not leave a ghost runner in the instance"
|
||||||
|
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"
|
||||||
|
|
@ -8,6 +8,17 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
. "$HERE/lib/runner-config.sh" # json_field / json_string_array read the netmap
|
. "$HERE/lib/runner-config.sh" # json_field / json_string_array read the netmap
|
||||||
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||||
. "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant
|
. "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
|
. "$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
|
||||||
|
# bad --root-door — before apt, before the tailnet join, before a pre-auth key is
|
||||||
|
# spent — instead of at the very end of a run the operator already paid for.
|
||||||
|
|
||||||
log() { printf 'rig-bootstrap: %s\n' "$*"; }
|
log() { printf 'rig-bootstrap: %s\n' "$*"; }
|
||||||
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
|
||||||
|
|
@ -15,40 +26,88 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: rig bootstrap <control-plane|workload|runner|dev|workstation|custom>
|
usage: rig bootstrap <control-plane-server|workload-server|runner-server|
|
||||||
[--hostname <name>] [--class <human|server>]
|
staging-server|dev-server|workstation|custom>
|
||||||
|
(--users <path> | --no-users)
|
||||||
|
[--hostname <name>] [--root-door <closed|open>]
|
||||||
[--host <yes|no>] [--join <authkey|login>]
|
[--host <yes|no>] [--join <authkey|login>]
|
||||||
rig bootstrap <claude|codex|grok|staging> [--user <name>]
|
rig bootstrap <role>-box [--user <name>]
|
||||||
(the box TENANT roles — see their own --help)
|
(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
|
||||||
|
|
||||||
|
--users the users file this box's operators come from — REQUIRED. It is
|
||||||
|
applied as bootstrap's last phase, exactly as `rig users apply
|
||||||
|
--file <path>` would, so one command leaves a box with its
|
||||||
|
people on it. Passed per invocation and never persisted.
|
||||||
|
--no-users the deliberate opt-out — bootstrap converges the OS and the
|
||||||
|
tailnet and leaves the box with root as its only door.
|
||||||
--hostname system + tailnet hostname (default: the role name; custom has
|
--hostname system + tailnet hostname (default: the role name; custom has
|
||||||
no default and requires it)
|
no default and requires it)
|
||||||
--class who lives here — human|server. Decides root SSH's fate after
|
--root-door what happens to root SSH after the users phase — closed|open.
|
||||||
`rig users apply`: human closes it, server keeps it as the
|
closed: `rig users close-root` shuts it once named operators can
|
||||||
control plane's automation door.
|
get in. open: it stays, as the control plane's automation door.
|
||||||
|
(Named --class human|server before #77 — same trait, renamed for
|
||||||
|
what it decides rather than for who lives on the box. Markers
|
||||||
|
written by the old flag are still read.)
|
||||||
--host does this box host VMs (box/Incus) — yes|no
|
--host does this box host VMs (box/Incus) — yes|no
|
||||||
--join how it enters the tailnet — authkey|login
|
--join how it enters the tailnet — authkey|login
|
||||||
|
|
||||||
|
One of --users/--no-users is required on every role, root-door=open included:
|
||||||
|
a box nobody logs into routinely is exactly where shared-root access rots,
|
||||||
|
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
|
||||||
|
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
|
||||||
|
incus grants, which the host's own users file already converged. A fleet-wide
|
||||||
|
operator file has nothing to converge in there.
|
||||||
|
|
||||||
Roles are presets over the three traits; any flag overrides its trait.
|
Roles are presets over the three traits; any flag overrides its trait.
|
||||||
custom presets nothing and requires --hostname plus all three traits.
|
custom presets nothing and requires --hostname plus all three traits.
|
||||||
|
|
||||||
role class host join
|
role root-door host join
|
||||||
control-plane server no authkey
|
control-plane-server open no authkey
|
||||||
workload server no authkey
|
workload-server open no authkey
|
||||||
runner server no authkey
|
runner-server open no authkey
|
||||||
dev human yes authkey
|
staging-server open yes authkey
|
||||||
workstation human yes login
|
dev-server closed yes authkey
|
||||||
|
workstation closed yes login
|
||||||
|
|
||||||
The former staging VM-host preset is now spelled through the traits:
|
THE SUFFIX NAMES THE FAMILY, not the door policy. '-server' means this role builds a
|
||||||
'custom --class server --host yes --join authkey' (or 'dev --class server').
|
fleet MACHINE — a tailnet node rig converges; '-box' (the tenant roles) means a
|
||||||
'staging' names the box TENANT role today — the guest, not the host.
|
GUEST a box mints. Two families lived in one flat namespace and nothing in a
|
||||||
|
name said which you were asking for; 'staging' made that concrete by naming
|
||||||
|
both the metal and the guests on it.
|
||||||
|
|
||||||
|
custom no suffix: it presets nothing and can be any shape, a guest
|
||||||
|
included, so a family claim would be one it cannot make.
|
||||||
|
workstation no suffix: somebody's own device, not fleet infrastructure —
|
||||||
|
it joins by interactive login and comes up user-owned and
|
||||||
|
untagged, and the tailnet never manages it.
|
||||||
|
|
||||||
|
'dev-server --root-door closed' says what is true and says it once: the suffix
|
||||||
|
names the FAMILY (a fleet machine), the trait names the DOOR (operators enter a
|
||||||
|
dev box as themselves, so 'users close-root' shuts its door). Until #77 this
|
||||||
|
trait was '--class human|server', which named the wrong axis — who lives on the
|
||||||
|
box — and left 'dev-server' reading as a class=human contradiction. Nobody
|
||||||
|
lives on a dev box; what makes it different is that its root door closes.
|
||||||
|
|
||||||
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
|
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
|
||||||
so the key is the single source of truth: rig no longer requests a tag it might
|
so the key is the single source of truth: rig no longer requests a tag it might
|
||||||
disagree with. After the box joins, rig reads the tag control actually GRANTED
|
disagree with. After the box joins, rig reads the tag control actually GRANTED
|
||||||
(tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused
|
(tailscale status .Self.Tags) and asserts on THAT — an untagged key is refused
|
||||||
outright, and only control-plane and workload may carry tag:server (they are
|
outright, and only control-plane-server and workload-server may carry
|
||||||
the only shapes the control plane manages). Mint a correctly-tagged key.
|
tag:server (they are the only shapes the control plane manages). Mint a
|
||||||
|
correctly-tagged key.
|
||||||
|
|
||||||
join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY
|
join=authkey: provide the single-use tailscale pre-auth key via the TS_AUTHKEY
|
||||||
env var, or enter it at the interactive prompt. Used once, never written to disk.
|
env var, or enter it at the interactive prompt. Used once, never written to disk.
|
||||||
|
|
@ -62,46 +121,80 @@ EOF
|
||||||
|
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
ROLE="${1:-}"
|
ROLE="${1:-}"
|
||||||
|
MACHINE_TEMPLATE_DIR=""
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
control-plane|workload|runner|dev|workstation|custom) shift ;;
|
--undo)
|
||||||
claude|codex|grok|staging)
|
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)
|
||||||
# The box TENANT roles (#31) are a different family — guests a box mints,
|
# The box TENANT roles (#31) are a different family — guests a box mints,
|
||||||
# never tailnet machines — and live in their own mechanism, one script
|
# never tailnet machines — and live in their own mechanism, one script
|
||||||
# parameterized per tenant. Dispatched here so `rig bootstrap <role>`
|
# parameterized per DEFINITION fetched from the template registry (#110;
|
||||||
# stays the single entrypoint for both families.
|
# 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.
|
||||||
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
"") usage >&2; die "role required (control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
|
"") 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 ;;
|
||||||
*) die "unknown role: $ROLE (want control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 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 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Role→traits map — the single place a role's shape is declared (issue #26).
|
# Role→traits map — the single place a role's shape is declared (issue #26).
|
||||||
# Roles are presets, nothing more: every behavior below keys off the traits,
|
# Roles are presets, nothing more: every behavior below keys off the traits,
|
||||||
# so a flag override changes behavior without a new role, and custom exists
|
# so a flag override changes behavior without a new role, and custom exists
|
||||||
# for the shape nobody foresaw — it declares nothing and must state all three.
|
# for the shape nobody foresaw — it declares nothing and must state all three.
|
||||||
CLASS="" HOST="" JOIN=""
|
ROOT_DOOR="" HOST="" JOIN=""
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
control-plane) CLASS=server HOST=no JOIN=authkey ;;
|
control-plane-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
|
||||||
workload) CLASS=server HOST=no JOIN=authkey ;;
|
workload-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
|
||||||
runner) CLASS=server HOST=no JOIN=authkey ;;
|
runner-server) ROOT_DOOR=open HOST=no JOIN=authkey ;;
|
||||||
dev) CLASS=human HOST=yes JOIN=authkey ;;
|
# The unattended VM host — the shape #31 retired when 'staging' moved to the
|
||||||
workstation) CLASS=human HOST=yes JOIN=login ;;
|
# tenant family, restored under a name that cannot be confused with its own
|
||||||
|
# guests. host=yes is the whole point: it is what installs the box CLI and
|
||||||
|
# runs box's setup-host further down, so this is a table row, not machinery.
|
||||||
|
staging-server) ROOT_DOOR=open HOST=yes JOIN=authkey ;;
|
||||||
|
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
|
||||||
|
workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
|
||||||
custom) ;;
|
custom) ;;
|
||||||
|
*) ROOT_DOOR="$TPL_ROOT_DOOR" HOST="$TPL_HOST" JOIN="$TPL_JOIN" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
|
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
|
||||||
TS_HOSTNAME="$ROLE"
|
TS_HOSTNAME="$ROLE"
|
||||||
[ "$ROLE" = "custom" ] && TS_HOSTNAME=""
|
[ "$ROLE" = "custom" ] && TS_HOSTNAME=""
|
||||||
|
USERS_FILE=""
|
||||||
|
NO_USERS=0
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
--users)
|
||||||
|
[ $# -ge 2 ] || die "--users needs a value" 2
|
||||||
|
USERS_FILE="$2"; shift 2 ;;
|
||||||
|
--no-users)
|
||||||
|
NO_USERS=1; shift ;;
|
||||||
--hostname)
|
--hostname)
|
||||||
[ $# -ge 2 ] || die "--hostname needs a value" 2
|
[ $# -ge 2 ] || die "--hostname needs a value" 2
|
||||||
TS_HOSTNAME="$2"; shift 2 ;;
|
TS_HOSTNAME="$2"; shift 2 ;;
|
||||||
--class)
|
--root-door)
|
||||||
[ $# -ge 2 ] || die "--class needs a value" 2
|
[ $# -ge 2 ] || die "--root-door needs a value" 2
|
||||||
case "$2" in
|
case "$2" in
|
||||||
human|server) CLASS="$2" ;;
|
closed|open) ROOT_DOOR="$2" ;;
|
||||||
*) die "bad --class: $2 (want human|server)" 2 ;;
|
*) die "bad --root-door: $2 (want closed|open)" 2 ;;
|
||||||
esac
|
esac
|
||||||
shift 2 ;;
|
shift 2 ;;
|
||||||
--host)
|
--host)
|
||||||
|
|
@ -137,7 +230,7 @@ done
|
||||||
if [ "$ROLE" = "custom" ]; then
|
if [ "$ROLE" = "custom" ]; then
|
||||||
MISSING=""
|
MISSING=""
|
||||||
[ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname"
|
[ -n "$TS_HOSTNAME" ] || MISSING="$MISSING --hostname"
|
||||||
[ -n "$CLASS" ] || MISSING="$MISSING --class"
|
[ -n "$ROOT_DOOR" ] || MISSING="$MISSING --root-door"
|
||||||
[ -n "$HOST" ] || MISSING="$MISSING --host"
|
[ -n "$HOST" ] || MISSING="$MISSING --host"
|
||||||
[ -n "$JOIN" ] || MISSING="$MISSING --join"
|
[ -n "$JOIN" ] || MISSING="$MISSING --join"
|
||||||
[ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2
|
[ -z "$MISSING" ] || die "role custom has no presets; missing:$MISSING" 2
|
||||||
|
|
@ -150,6 +243,121 @@ if [ "$JOIN" = "login" ] && [ -n "${TS_AUTHKEY:-}" ]; then
|
||||||
die "join=login is interactive: unset TS_AUTHKEY or pass --join authkey" 2
|
die "join=login is interactive: unset TS_AUTHKEY or pass --join authkey" 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- who lives here (#51) -----------------------------------------------------
|
||||||
|
# The users file is the last piece of "what this box is" that bootstrap did not
|
||||||
|
# take, and it is REQUIRED rather than optional: a bootstrapped box with no
|
||||||
|
# users converges to a box only root can enter, and on root-door=closed that is a
|
||||||
|
# half-built machine waiting for a second command the operator has to remember
|
||||||
|
# (`rig users close-root` is itself gated behind "once your admin key works" —
|
||||||
|
# which needs an admin to exist). root-door=open gets the same requirement on
|
||||||
|
# purpose: a server nobody logs into routinely is exactly where shared-root
|
||||||
|
# access rots, and per-human accounts keep attribution intact for the times
|
||||||
|
# someone does go in.
|
||||||
|
#
|
||||||
|
# The opt-out is a FLAG, not a default. Both states are then something the
|
||||||
|
# operator said out loud, which is the whole point — an omitted --users used to
|
||||||
|
# be indistinguishable from "I meant to and forgot", and the box that resulted
|
||||||
|
# looked identical either way. Contradicting yourself is a usage error too:
|
||||||
|
# --users and --no-users together is not a precedence puzzle rig should silently
|
||||||
|
# resolve, because whichever way it resolved would be the wrong one half the
|
||||||
|
# time.
|
||||||
|
if [ -n "$USERS_FILE" ] && [ "$NO_USERS" -eq 1 ]; then
|
||||||
|
die "--users and --no-users are contradictory: pass the file, or say --no-users, not both" 2
|
||||||
|
fi
|
||||||
|
if [ -z "$USERS_FILE" ] && [ "$NO_USERS" -eq 0 ]; then
|
||||||
|
die "one of --users <path> or --no-users is required: bootstrap converges this box's operators as its last phase, and a box with no named users is one only root can enter. Pass --users <path>, or --no-users to leave it root-only deliberately" 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The users file is PRE-FLIGHTED here and applied at the very end: everything
|
||||||
|
# below this point costs the operator something — apt, a hostname change, a
|
||||||
|
# single-use pre-auth key — and a users file with a typo in it must not be
|
||||||
|
# discovered after all of that was already spent. Same reason every other flag
|
||||||
|
# is validated before the root check: errors belong at the top of the run.
|
||||||
|
USERS_HAS_BOX_ROLE=0
|
||||||
|
if [ -n "$USERS_FILE" ]; then
|
||||||
|
# '-' (stdin) is apply's own convenience and cannot survive the trip through
|
||||||
|
# bootstrap: stdin here belongs to the pre-auth key prompt, and a users file
|
||||||
|
# piped in would either eat that prompt or be eaten by it. Refuse the token
|
||||||
|
# rather than let the two credentials-shaped reads fight over one pipe.
|
||||||
|
[ "$USERS_FILE" != "-" ] \
|
||||||
|
|| die "--users needs a real path: bootstrap's stdin is the pre-auth key prompt's, so it cannot also carry the users file. Write it to a file, or run 'rig users apply --file -' separately after --no-users" 2
|
||||||
|
[ -r "$USERS_FILE" ] || die "cannot read users file: $USERS_FILE" 2
|
||||||
|
if ! USERS_PARSED="$(parse_users_file "$USERS_FILE")"; then
|
||||||
|
die "invalid users file: $USERS_FILE — every error is listed above; nothing was changed" 2
|
||||||
|
fi
|
||||||
|
# A file that parses to ZERO users is not a parse error — empty, comments-only
|
||||||
|
# and whitespace-only files are all perfectly valid input, and the parser is
|
||||||
|
# right to accept them. But they walk straight through the requirement #51
|
||||||
|
# built: `--users ./empty-file` and `--no-users` converge the identical
|
||||||
|
# root-only box, and only one of them says so. The ambiguity the required flag
|
||||||
|
# exists to kill would survive in a narrower form — an operator who pointed at
|
||||||
|
# the wrong path and one who meant root-only would again produce indis-
|
||||||
|
# tinguishable boxes, which is precisely the state the flag was made to end.
|
||||||
|
#
|
||||||
|
# Refuse, and name --no-users: the outcome is reachable, it just has to be
|
||||||
|
# said out loud. That is the whole shape of #51's contract — both answers are
|
||||||
|
# available, neither is a side effect of what you did not type.
|
||||||
|
#
|
||||||
|
# The sharper reason this belongs at pre-flight rather than nowhere: against a
|
||||||
|
# box that ALREADY has operators, a truncated file does not converge nothing,
|
||||||
|
# it revokes every one of them. That is apply's correct and documented
|
||||||
|
# drop-semantics and it warns per user, so it is loud rather than silent — but
|
||||||
|
# a stray '>' is all it takes to produce that file, and every other failure
|
||||||
|
# mode on this command was deliberately made to fail before apt, the hostname
|
||||||
|
# change, or a spent pre-auth key. This one should not be the exception that
|
||||||
|
# fails after them.
|
||||||
|
#
|
||||||
|
# Deliberately scoped to BOOTSTRAP, not to the parser and not to apply. The
|
||||||
|
# lib stays a parser — "zero users is not allowed here" is bootstrap's policy,
|
||||||
|
# not a property of the file format — and a standalone `rig users apply`
|
||||||
|
# against an emptied file remains a real de-provisioning operation that must
|
||||||
|
# keep working. Bootstrap is where the claim "this box's people are these" is
|
||||||
|
# being made, so bootstrap is where an empty answer is a contradiction.
|
||||||
|
if [ -z "$USERS_PARSED" ]; then
|
||||||
|
die "users file names no users: $USERS_FILE parsed to zero operators (it is empty, or only comments and blank lines). Bootstrapping with it would converge a box only root can enter — the same outcome as --no-users, reached by the flag that exists to guarantee the opposite. Check the path, or pass --no-users to leave this box root-only deliberately" 2
|
||||||
|
fi
|
||||||
|
# Does anyone in the file carry role box? That single fact decides whether the
|
||||||
|
# incus precondition below applies at all — a users file naming only admins
|
||||||
|
# converges perfectly well on a host that has never seen Incus, and refusing
|
||||||
|
# it there would be rig inventing a prerequisite its own apply does not have.
|
||||||
|
if printf '%s\n' "$USERS_PARSED" | cut -d'|' -f2 | grep -qE '(^|,)box(,|$)'; then
|
||||||
|
USERS_HAS_BOX_ROLE=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# host=yes + a box-role user + no incus group = the refusal `users apply` already
|
||||||
|
# owns ("rig NEVER installs Incus: box's setup-host owns the daemon and its
|
||||||
|
# group"). rig does not build that stack here and does not call setup-host —
|
||||||
|
# whether rig should install box on a VM host is an open boundary question, and
|
||||||
|
# resolving it by accident inside a users change would be the worst way to
|
||||||
|
# answer it. What bootstrap CAN do is stop early instead of late.
|
||||||
|
#
|
||||||
|
# Early only where the outcome is already PROVEN, though. The ordinary host=yes
|
||||||
|
# path installs box further down, and box's own installer runs setup-host — so
|
||||||
|
# the group that is missing now will exist by the time the users phase runs, and
|
||||||
|
# an unconditional refusal here would reject the exact bring-up this issue is
|
||||||
|
# about. RIG_SKIP_BOX_INSTALL=1 is the one case with no such rescue: the
|
||||||
|
# operator has said this run will not touch box, so the group's absence is final
|
||||||
|
# and the run is doomed a hundred lines before it notices. The other failure
|
||||||
|
# shapes (no network, box's installer breaking) are not knowable this early and
|
||||||
|
# land in apply's own refusal at the end — the same message, one phase later.
|
||||||
|
#
|
||||||
|
# #49 (merged) added a SECOND host-level refusal to apply: the box CLI itself
|
||||||
|
# missing on host=yes, because the group is only the socket and the tier is
|
||||||
|
# `box grant`, which apply calls rather than reimplements. Under
|
||||||
|
# RIG_SKIP_BOX_INSTALL=1 that absence is just as final and just as knowable
|
||||||
|
# now as the group's, so the early check mirrors both rather than being a
|
||||||
|
# weaker proxy for one of them. Either one alone dooms the run.
|
||||||
|
if [ "$USERS_HAS_BOX_ROLE" -eq 1 ] && [ "$HOST" = "yes" ] \
|
||||||
|
&& [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
||||||
|
if ! getent group incus >/dev/null 2>&1; then
|
||||||
|
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent and RIG_SKIP_BOX_INSTALL=1 means this run will not install box — install the box CLI and run 'box setup-host' first; rig never installs Incus. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2
|
||||||
|
fi
|
||||||
|
if ! command -v box >/dev/null 2>&1; then
|
||||||
|
die "a user carries role box and this box hosts VMs (host=yes) but the box CLI is not on PATH and RIG_SKIP_BOX_INSTALL=1 means this run will not install it — the incus group is only the socket; the restricted tier is 'box grant', which rig calls rather than reimplements. Install the box CLI first. (Or drop RIG_SKIP_BOX_INSTALL and let bootstrap install box as it normally does.)" 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- guards ------------------------------------------------------------------
|
# --- guards ------------------------------------------------------------------
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
if [ -r /etc/os-release ]; then
|
if [ -r /etc/os-release ]; then
|
||||||
|
|
@ -198,9 +406,9 @@ EOF
|
||||||
# The whole block lives in lib/sshd.sh, shared with the staging TENANT role —
|
# The whole block lives in lib/sshd.sh, shared with the staging TENANT role —
|
||||||
# one drop-in, one converger, never two copies drifting apart. Everything the
|
# one drop-in, one converger, never two copies drifting apart. Everything the
|
||||||
# block learned the hard way (00- beats cloud-init's 50- under first-wins,
|
# block learned the hard way (00- beats cloud-init's 50- under first-wins,
|
||||||
# validate-then-restart, assert sshd -T not the file, the class-gated
|
# validate-then-restart, assert sshd -T not the file, the root-door-gated
|
||||||
# permitrootlogin acceptance) moved with it, verbatim.
|
# permitrootlogin acceptance) moved with it, verbatim.
|
||||||
harden_sshd "$CLASS"
|
harden_sshd "$ROOT_DOOR"
|
||||||
|
|
||||||
# --- system hostname ----------------------------------------------------------
|
# --- system hostname ----------------------------------------------------------
|
||||||
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
|
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
|
||||||
|
|
@ -280,25 +488,26 @@ verify_effective_tag() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# tag:server policy is DERIVED, not a trait: it means "the control plane
|
# tag:server policy is DERIVED, not a trait: it means "the control plane
|
||||||
# manages this box", and only control-plane and workload are shapes the
|
# manages this box", and only control-plane-server and workload-server are shapes the
|
||||||
# control plane manages. Everything else refuses it on the EFFECTIVE tag —
|
# control plane manages. Everything else refuses it on the EFFECTIVE tag —
|
||||||
# strictly stronger than the old request-time check, which only guarded the
|
# strictly stronger than the old request-time check, which only guarded the
|
||||||
# tag rig HOPED for. The fleet has been bitten both ways: a runner carrying
|
# tag rig HOPED for. The fleet has been bitten both ways: a runner-server carrying
|
||||||
# tag:server extends every server grant to repo-controlled code, and a
|
# tag:server extends every server grant to repo-controlled code, and a
|
||||||
# staging host carrying it extends them to a box the control plane does not
|
# staging host carrying it extends them to a box the control plane does not
|
||||||
# even know. Refused, never warned; rig can DETECT this but cannot FIX it,
|
# even know. Refused, never warned; rig can DETECT this but cannot FIX it,
|
||||||
# so each refusal names its repair.
|
# so each refusal names its repair.
|
||||||
if printf '%s\n' "$tags" | grep -qx 'tag:server'; then
|
if printf '%s\n' "$tags" | grep -qx 'tag:server'; then
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
control-plane|workload) ;;
|
control-plane-server|workload-server) ;;
|
||||||
runner)
|
runner-server)
|
||||||
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;;
|
die "role runner-server joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;;
|
||||||
*)
|
*)
|
||||||
# This arm now also owns the VM-host shape the old staging preset
|
# This arm owns the VM-host shape too — 'staging-server' by name now,
|
||||||
# covered (custom/dev --class server): a host is never managed by the
|
# plus custom/dev-server --root-door open: a host is never managed by the
|
||||||
# control plane — its guest VMs are — so tag:server is refused there
|
# control plane — its guest VMs are — so tag:server is refused there
|
||||||
# like everywhere else outside control-plane|workload.
|
# like everywhere else outside the two control-plane-managed shapes.
|
||||||
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;;
|
# Mint the metal's key with tag:local.
|
||||||
|
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane-server and workload-server are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -355,6 +564,7 @@ if ! command -v tailscale >/dev/null 2>&1; then
|
||||||
log "installing tailscale"
|
log "installing tailscale"
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
fi
|
fi
|
||||||
|
JOIN_BY=preexisting
|
||||||
if tailscale status >/dev/null 2>&1; then
|
if tailscale status >/dev/null 2>&1; then
|
||||||
log "tailnet already joined; skipping tailscale up (no pre-auth key needed)"
|
log "tailnet already joined; skipping tailscale up (no pre-auth key needed)"
|
||||||
# ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name
|
# ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name
|
||||||
|
|
@ -395,6 +605,7 @@ elif [ "$JOIN" = "login" ]; then
|
||||||
log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)"
|
log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)"
|
||||||
tailscale up --hostname="$TS_HOSTNAME"
|
tailscale up --hostname="$TS_HOSTNAME"
|
||||||
verify_user_owned back-out
|
verify_user_owned back-out
|
||||||
|
JOIN_BY=rig
|
||||||
else
|
else
|
||||||
# env override, else prompt; never touches disk. The prompt only fires on a
|
# 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
|
# tty: with no terminal, a bare `read` exits non-zero and `set -e` would end
|
||||||
|
|
@ -415,26 +626,61 @@ else
|
||||||
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
|
log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)"
|
||||||
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
|
tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME"
|
||||||
verify_effective_tag back-out
|
verify_effective_tag back-out
|
||||||
|
JOIN_BY=rig
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- role marker --------------------------------------------------------------
|
# --- role marker --------------------------------------------------------------
|
||||||
# /etc/rig/role is the traits' ground truth for later rig commands (`rig users`
|
# /etc/rig/role is the traits' ground truth for later rig commands (`rig users`
|
||||||
# reads class from it to decide root SSH's fate). Written only AFTER the tag
|
# reads root-door= from it to decide root SSH's fate). Written only AFTER the
|
||||||
# verification, so a marker never describes a box that failed to become what it
|
# tag verification, so a marker never describes a box that failed to become what
|
||||||
# claims — and cmp-guarded like every file rig converges.
|
# it claims — and cmp-guarded like every file rig converges.
|
||||||
|
#
|
||||||
|
# The line is written in the CURRENT vocabulary only — `root-door=`, never the
|
||||||
|
# `class=` it replaced (#77). Writing both would keep an old rig reading a new
|
||||||
|
# marker, but it would also entrench the retired spelling on every box rig ever
|
||||||
|
# converges, and make the both-fields-disagree case reachable from rig's own
|
||||||
|
# hand instead of only from a text editor. The compat obligation runs the other
|
||||||
|
# way and is discharged in root_door_of: NEW rig reads OLD markers, because
|
||||||
|
# those exist in the field by the thousand and nothing will rewrite them.
|
||||||
MARKER=/etc/rig/role
|
MARKER=/etc/rig/role
|
||||||
MARKER_TMP="$(mktemp)"
|
MARKER_TMP="$(mktemp)"
|
||||||
printf 'role=%s class=%s host=%s join=%s\n' "$ROLE" "$CLASS" "$HOST" "$JOIN" > "$MARKER_TMP"
|
printf 'role=%s root-door=%s host=%s join=%s join-by=%s\n' \
|
||||||
|
"$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" "$JOIN_BY" > "$MARKER_TMP"
|
||||||
if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then
|
if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then
|
||||||
mkdir -p /etc/rig
|
mkdir -p /etc/rig
|
||||||
install -m 0644 "$MARKER_TMP" "$MARKER"
|
install -m 0644 "$MARKER_TMP" "$MARKER"
|
||||||
log "role marker written: role=$ROLE class=$CLASS host=$HOST join=$JOIN"
|
log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN join-by=$JOIN_BY"
|
||||||
else
|
else
|
||||||
log "role marker already current"
|
log "role marker already current"
|
||||||
fi
|
fi
|
||||||
rm -f "$MARKER_TMP"
|
rm -f "$MARKER_TMP"
|
||||||
|
|
||||||
# --- box install (host-class only) -------------------------------------------
|
# --- provenance manifest ------------------------------------------------------
|
||||||
|
# /etc/rig/manifest records WHICH rig converged this machine and WHEN (#61).
|
||||||
|
# The marker above says what the box IS; this says what BUILT it — two files,
|
||||||
|
# two jobs, and the marker is deliberately untouched (it has six readers,
|
||||||
|
# install.sh:82-90 among them).
|
||||||
|
#
|
||||||
|
# Placed HERE, immediately after the marker, so the two agree by construction
|
||||||
|
# and both inherit the marker's discipline verbatim: written only AFTER the tag
|
||||||
|
# verification, so neither ever describes a box that failed to become what it
|
||||||
|
# claims. A manifest that survives a failed run is worse than no manifest — it
|
||||||
|
# is a confident wrong answer. It deliberately does NOT trail the box install
|
||||||
|
# and the users phase below: those are the host EXTRA and the operator phase,
|
||||||
|
# and a box whose people failed to converge was still converged BY this rig at
|
||||||
|
# this time. Stamping provenance is not a claim that everything after it
|
||||||
|
# succeeded — the marker beside it makes exactly the same claim, and the two
|
||||||
|
# landing together is what keeps them readable as one statement.
|
||||||
|
#
|
||||||
|
# The version stamped is the one that RAN, captured now — not what `rig
|
||||||
|
# --version` would answer after a later upgrade.
|
||||||
|
if manifest_stamp "$(manifest_running_version "$HERE/..")"; then
|
||||||
|
log "provenance manifest written: $(manifest_path)"
|
||||||
|
else
|
||||||
|
log "provenance manifest already current"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- box install (host=yes only) -------------------------------------------
|
||||||
# A host=yes box exists to run guest boxes, so bootstrap finishes the job rather
|
# A host=yes box exists to run guest boxes, so bootstrap finishes the job rather
|
||||||
# than printing a to-do: it installs the box CLI globally and lets box's OWN
|
# than printing a to-do: it installs the box CLI globally and lets box's OWN
|
||||||
# setup-host build the Incus stack. Placed AFTER the role marker write on
|
# setup-host build the Incus stack. Placed AFTER the role marker write on
|
||||||
|
|
@ -459,7 +705,9 @@ rm -f "$MARKER_TMP"
|
||||||
# hardening + the tailnet, and box is the host EXTRA, so a failed box install
|
# hardening + the tailnet, and box is the host EXTRA, so a failed box install
|
||||||
# must never abort a bootstrap that otherwise fully succeeded.
|
# must never abort a bootstrap that otherwise fully succeeded.
|
||||||
#
|
#
|
||||||
# PIN POINTS: BOX_REPO / BOX_REF override the source (default heavy-duty/box@main).
|
# 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.
|
||||||
# BOX_YES=1 makes box's installer non-interactive AND keeps setup-host (so the
|
# 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).
|
# Incus stack is actually built, not just the CLI dropped on PATH).
|
||||||
#
|
#
|
||||||
|
|
@ -472,24 +720,114 @@ rm -f "$MARKER_TMP"
|
||||||
# on box PR #71. Until that merges, box's root install lands in /root and non-root
|
# 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.
|
# users cannot reach it, so this step is only fully correct once box#71 is merged.
|
||||||
if [ "$HOST" = "yes" ]; then
|
if [ "$HOST" = "yes" ]; then
|
||||||
|
BOX_RELEASE=0.9.0
|
||||||
BOX_REPO="${BOX_REPO:-heavy-duty/box}"
|
BOX_REPO="${BOX_REPO:-heavy-duty/box}"
|
||||||
BOX_REF="${BOX_REF:-main}"
|
BOX_REF="${BOX_REF:-$BOX_RELEASE}"
|
||||||
BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh"
|
# BOX_HOST: which forge serves box's *installer script* (#111). Parallel to
|
||||||
BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 bash"
|
# RIG_HOST / RIG_TEMPLATES_HOST. Defaults to RIG_HOST when set, else GitHub,
|
||||||
|
# so a Forgejo-sourced rig stays Forgejo-native for this fetch without a
|
||||||
|
# second knob — override with BOX_HOST when the two must diverge.
|
||||||
|
# Raw-file grammar:
|
||||||
|
# GitHub raw.githubusercontent.com/<repo>/<ref>/install.sh
|
||||||
|
# Forgejo <host>/<repo>/raw/{tag|branch}/<ref>/install.sh
|
||||||
|
# Forgejo's bare /raw/<ref>/ is branch-first (opposite of /archive/<ref>),
|
||||||
|
# so we never guess kind from spelling: try /raw/tag/ then /raw/branch/
|
||||||
|
# and let the fetch decide (same pin-wins rule as ref_candidate_urls).
|
||||||
|
# SCOPE: this only moves the script fetch. box@0.9.0's installer still
|
||||||
|
# hardcodes GitHub for its own archive — zero-GitHub bootstrap needs a
|
||||||
|
# BOX_HOST knob in heavy-duty/box (tracked separately).
|
||||||
|
BOX_HOST="${BOX_HOST:-${RIG_HOST:-https://github.com}}"
|
||||||
|
BOX_HOST="${BOX_HOST%/}"
|
||||||
|
box_install_urls() {
|
||||||
|
case "$BOX_HOST" in
|
||||||
|
https://github.com|http://github.com|*//github.com)
|
||||||
|
printf 'https://raw.githubusercontent.com/%s/%s/install.sh\n' "$BOX_REPO" "$BOX_REF" ;;
|
||||||
|
*)
|
||||||
|
printf '%s/%s/raw/tag/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF"
|
||||||
|
printf '%s/%s/raw/branch/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
# One pasteable recovery command per candidate URL. Never join candidates
|
||||||
|
# with English prose or shell metacharacters — "curl A | bash; if that
|
||||||
|
# 404s: curl B | bash" is not valid shell (`bash -n` exits 2) and is the
|
||||||
|
# same class of operator-facing failure #111 exists to remove (#125 /
|
||||||
|
# codex REQUEST_CHANGES on !114). Multi-candidate display uses separate
|
||||||
|
# prefixed lines (try: / or:); a single candidate (GitHub default) is a
|
||||||
|
# bare pasteable command — a try: prefix turns paste into a silent no-op
|
||||||
|
# (`try:` is not a command; the pipe's bash still exits 0). After a live
|
||||||
|
# probe succeeds the install loop rewrites BOX_MANUAL to the single URL
|
||||||
|
# that worked. Consumers MUST emit via box_manual_emit — never interpolate
|
||||||
|
# ${BOX_MANUAL} into a single log/warn string (multi-line orphans the or:
|
||||||
|
# line; claude REQUEST_CHANGES on 1c9a245).
|
||||||
|
box_manual_cmd() { # box_manual_cmd <url> — one pasteable install line
|
||||||
|
printf 'curl -fsSL %s | BOX_YES=1 BOX_REF=%s bash\n' "$1" "$BOX_REF"
|
||||||
|
}
|
||||||
|
box_manual_text() {
|
||||||
|
local _n=0 _url _cmd _urls=()
|
||||||
|
while IFS= read -r _url; do
|
||||||
|
[ -n "$_url" ] && _urls+=("$_url")
|
||||||
|
done < <(box_install_urls)
|
||||||
|
# Single candidate: bare command (no try:). Multi: try:/or: lines.
|
||||||
|
if [ "${#_urls[@]}" -le 1 ]; then
|
||||||
|
if [ "${#_urls[@]}" -eq 1 ]; then
|
||||||
|
_cmd="$(box_manual_cmd "${_urls[0]}")"
|
||||||
|
printf '%s' "$_cmd"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for _url in "${_urls[@]}"; do
|
||||||
|
_cmd="$(box_manual_cmd "$_url")"
|
||||||
|
_cmd="${_cmd%$'\n'}"
|
||||||
|
_n=$((_n + 1))
|
||||||
|
if [ "$_n" -eq 1 ]; then
|
||||||
|
printf 'try: %s\n' "$_cmd"
|
||||||
|
else
|
||||||
|
printf 'or: %s\n' "$_cmd"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
# Emit BOX_MANUAL one line at a time through log or warn. Never splice the
|
||||||
|
# multi-line value into a prose sentence.
|
||||||
|
box_manual_emit() { # box_manual_emit log|warn
|
||||||
|
local _fn="$1" _line
|
||||||
|
while IFS= read -r _line; do
|
||||||
|
[ -n "$_line" ] && "$_fn" " ${_line}"
|
||||||
|
done <<EOF
|
||||||
|
$BOX_MANUAL
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
BOX_INSTALL_URL="$(box_install_urls | head -n1)"
|
||||||
|
# Newline-separated recovery lines — each command (after optional try:/or:
|
||||||
|
# prefix) is independently pasteable.
|
||||||
|
BOX_MANUAL="$(box_manual_text)"
|
||||||
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
||||||
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}"
|
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later:"
|
||||||
|
box_manual_emit log
|
||||||
elif ! command -v curl >/dev/null 2>&1; then
|
elif ! command -v curl >/dev/null 2>&1; then
|
||||||
warn "curl not found — skipping box install; once curl is present, prepare Incus with: ${BOX_MANUAL}"
|
warn "curl not found — skipping box install; once curl is present, prepare Incus with:"
|
||||||
|
box_manual_emit warn
|
||||||
else
|
else
|
||||||
log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig"
|
log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig"
|
||||||
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
|
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
|
||||||
# builds the Incus stack rather than only dropping the CLI on PATH. Running as
|
# builds the Incus stack rather than only dropping the CLI on PATH. Running as
|
||||||
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
|
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
|
||||||
# already installed, so re-running bootstrap converges instead of reinstalling.
|
# already installed, so re-running bootstrap converges instead of reinstalling.
|
||||||
# A curl failure (no network) fails the pipe under pipefail and lands in the
|
# Download and execute are separate so a 404 on /raw/tag/ can fall through
|
||||||
# else — a warning, never an abort: box is the host extra, the OS+tailnet core
|
# to /raw/branch/ without running a half-fetched body, and so an installer
|
||||||
# is already done.
|
# that runs and fails is NOT retried against the next candidate.
|
||||||
if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 bash; then
|
# A curl failure (no network) lands in the else — a warning, never an
|
||||||
|
# abort: box is the host extra, the OS+tailnet core is already done.
|
||||||
|
BOX_SCRIPT="$(mktemp)"
|
||||||
|
BOX_GOT=""
|
||||||
|
while IFS= read -r _box_url; do
|
||||||
|
if curl -fsSL "$_box_url" -o "$BOX_SCRIPT"; then
|
||||||
|
BOX_GOT="$_box_url"
|
||||||
|
BOX_INSTALL_URL="$_box_url"
|
||||||
|
BOX_MANUAL="$(box_manual_cmd "$BOX_INSTALL_URL" | tr -d '\n')"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done < <(box_install_urls)
|
||||||
|
if [ -n "$BOX_GOT" ] && BOX_YES=1 BOX_REF="$BOX_REF" bash "$BOX_SCRIPT"; then
|
||||||
# Don't trust the exit code — prove the effective state (issue #12). An
|
# 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
|
# 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
|
# is written for a sudo-capable user, and one of its paths exits 0 after
|
||||||
|
|
@ -509,28 +847,85 @@ if [ "$HOST" = "yes" ]; then
|
||||||
if box doctor >/dev/null 2>&1; then
|
if box doctor >/dev/null 2>&1; then
|
||||||
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
|
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
|
||||||
else
|
else
|
||||||
warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand: ${BOX_MANUAL})"
|
warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand:)"
|
||||||
|
box_manual_emit warn
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand: ${BOX_MANUAL}"
|
warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand:"
|
||||||
|
box_manual_emit warn
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand: ${BOX_MANUAL}"
|
warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand:"
|
||||||
|
box_manual_emit warn
|
||||||
fi
|
fi
|
||||||
|
rm -f "$BOX_SCRIPT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- users (the last phase, and it must be last) -------------------------------
|
||||||
|
# Ordering is a correctness property, not a preference. `users apply` READS
|
||||||
|
# /etc/rig/role: root-door= decides which root-SSH note it prints, and host= decides
|
||||||
|
# what an absent incus group means (refuse on yes, skip the box role with a
|
||||||
|
# warning on no). Run before the marker write, apply would see no marker at all
|
||||||
|
# and warn "re-run rig bootstrap so this box knows what it is" — in the middle
|
||||||
|
# of the very bootstrap that is teaching it. Run before the box install, a
|
||||||
|
# host=yes box would refuse its own box-role users for a group arriving twenty
|
||||||
|
# lines later. So: traits, then join, then marker, then box, then people.
|
||||||
|
#
|
||||||
|
# NOT persisted. rig takes the path, reads it once through apply, and keeps
|
||||||
|
# nothing — the users file lives in your private infra repo and is passed per
|
||||||
|
# invocation (README: "rig never persists it"). Taking it as a bootstrap flag
|
||||||
|
# must not quietly turn it into box state, so nothing here copies it anywhere,
|
||||||
|
# and /etc/rig keeps only the ledger of NAMES apply already wrote.
|
||||||
|
#
|
||||||
|
# Invoked as a child, not exec'd: bootstrap still owns the last word (the
|
||||||
|
# next-steps below), and a failing apply must fail the bootstrap — under
|
||||||
|
# `set -e` a non-zero apply ends the run right here, which is correct. The box
|
||||||
|
# is already hardened and joined at this point; what failed is one named phase,
|
||||||
|
# and it is re-runnable on its own with `rig users apply --file <path>`.
|
||||||
|
#
|
||||||
|
# A child also keeps apply's INVOKER gate intact, which is the point: SUDO_USER
|
||||||
|
# rides through, so `sudo rig bootstrap --users <file-naming-me-admin>` by a
|
||||||
|
# role-rig user refuses exactly as `sudo rig users apply` would. Bootstrap must
|
||||||
|
# not become a laundering path around the one gate that stops rig's scoped sudo
|
||||||
|
# from being root-equivalent. Bring-up runs as real root and is unaffected.
|
||||||
|
if [ -n "$USERS_FILE" ]; then
|
||||||
|
log "converging operators from ${USERS_FILE} (rig users apply)"
|
||||||
|
"$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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
|
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
|
||||||
if [ "$ROLE" = "control-plane" ]; then
|
if [ "$ROLE" = "control-plane-server" ]; then
|
||||||
log "next: rig coolify install --version <pin>"
|
log "next: rig coolify install --version <pin>"
|
||||||
elif [ "$ROLE" = "runner" ]; then
|
elif [ "$ROLE" = "runner-server" ]; then
|
||||||
log "next: rig runner install --repo <owner/repo> --version <pin>"
|
log "next: rig runner install --repo <owner/repo> --version <pin>"
|
||||||
fi
|
fi
|
||||||
# Every class gets operators: humans always enter as themselves and elevate via
|
# Every box gets operators: humans always enter as themselves and elevate via
|
||||||
# sudo — a shared root login is unattributable. What differs by class is root
|
# sudo — a shared root login is unattributable. What differs, per the root-door
|
||||||
# SSH's fate once named users exist.
|
# trait, is root SSH's fate once named users exist. With --users the accounts exist already, so
|
||||||
if [ "$CLASS" = "human" ]; then
|
# the note that used to point at the missing command now points at what is left
|
||||||
log "next: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
|
# to do; --no-users still owes the box its people, and says so.
|
||||||
|
if [ -n "$USERS_FILE" ]; then
|
||||||
|
if [ "$ROOT_DOOR" = "closed" ]; then
|
||||||
|
log "next: 'rig users close-root' once your admin key works — verify you can SSH in as an admin FIRST"
|
||||||
|
else
|
||||||
|
log "operators are converged; root SSH stays — it is the control plane's automation door"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
log "next: rig users apply --file <users-file> for named operator logins; root SSH stays — it is the control plane's automation door"
|
if [ "$ROOT_DOOR" = "closed" ]; then
|
||||||
|
log "--no-users: this box has no named operators — root is its only door. When you want them: rig users apply --file <users-file>, then 'rig users close-root' once your admin key works"
|
||||||
|
else
|
||||||
|
log "--no-users: this box has no named operators — root SSH is its only door, and it stays (the control plane's automation door). For named logins: rig users apply --file <users-file>"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,13 @@ done
|
||||||
# certainly means the wrong SSH session — but the marker is advisory and may be
|
# certainly means the wrong SSH session — but the marker is advisory and may be
|
||||||
# absent, so WARN, never die, and warn before the root check so the harness can
|
# absent, so WARN, never die, and warn before the root check so the harness can
|
||||||
# prove it non-root (RIG_ROLE_MARKER points it at fixtures, repo precedent).
|
# prove it non-root (RIG_ROLE_MARKER points it at fixtures, repo precedent).
|
||||||
|
# Matches the ROLE NAME, so #76's rename reaches it the same way it reaches
|
||||||
|
# `coolify install` — a pre-rename marker takes the warning branch, which is
|
||||||
|
# the hard cut behaving as designed rather than a regression.
|
||||||
MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
|
MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
|
||||||
case "$MARKER_LINE" in
|
case "$MARKER_LINE" in
|
||||||
""|"role=control-plane"|"role=control-plane "*) ;;
|
""|"role=control-plane-server"|"role=control-plane-server "*) ;;
|
||||||
*) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane; if this is the wrong box, stop here and re-check your SSH session." ;;
|
*) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. The nightly dump targets Coolify's own database, which lives on role control-plane-server; if this is the wrong box, stop here and re-check your SSH session." ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# --- guards ----------------------------------------------------------------
|
# --- guards ----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,17 @@ fi
|
||||||
# are: the harness proves it non-root, and reading a 0644 file needs no
|
# are: the harness proves it non-root, and reading a 0644 file needs no
|
||||||
# privilege. RIG_ROLE_MARKER overrides the path so tests point it at fixtures
|
# privilege. RIG_ROLE_MARKER overrides the path so tests point it at fixtures
|
||||||
# (repo precedent: users-apply, users-close-root).
|
# (repo precedent: users-apply, users-close-root).
|
||||||
|
#
|
||||||
|
# This match is on the ROLE NAME, which #76's rename therefore reaches: a box
|
||||||
|
# bootstrapped before the rename carries 'role=control-plane' and now takes the
|
||||||
|
# warning branch. That is the hard cut behaving as designed — the marker is
|
||||||
|
# advisory, the run still proceeds, and the warning names the re-bootstrap that
|
||||||
|
# makes the marker true again. Nothing here is load-bearing enough to justify
|
||||||
|
# carrying the old name forever.
|
||||||
MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
|
MARKER_LINE="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
|
||||||
case "$MARKER_LINE" in
|
case "$MARKER_LINE" in
|
||||||
""|"role=control-plane"|"role=control-plane "*) ;;
|
""|"role=control-plane-server"|"role=control-plane-server "*) ;;
|
||||||
*) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane' first so the marker tells the truth." ;;
|
*) warn "this box's role marker says '${MARKER_LINE}' — not a control-plane box. Coolify belongs on role control-plane-server; if this is the wrong box, stop here and re-check your SSH session. Repurposing it on purpose? Re-run 'rig bootstrap control-plane-server' first so the marker tells the truth." ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
|
|
||||||
557
commands/forgejo-runner-install.sh
Executable file
557
commands/forgejo-runner-install.sh
Executable file
|
|
@ -0,0 +1,557 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig forgejo-runner install — Forgejo Actions runner as a systemd service
|
||||||
|
# under an unprivileged user. Outbound-only (long-poll to the instance), no
|
||||||
|
# inbound ports. Convergent toward --instance: re-running against the instance
|
||||||
|
# the box is already on leaves it alone; a box registered to a DIFFERENT
|
||||||
|
# instance is refused, never silently restarted on the old one.
|
||||||
|
#
|
||||||
|
# The GitHub sibling (runner-install.sh) refuses Docker outright: it converges
|
||||||
|
# a fleet MACHINE, where `docker` group membership is root-equivalent and the
|
||||||
|
# blast radius is the machine. This command's home is a ci-box TENANT, where
|
||||||
|
# bootstrap-tenant.sh has already installed Docker and added the tenant user to
|
||||||
|
# the group, and where the blast radius is a disposable guest with no inbound
|
||||||
|
# path. Same trade, different machine, opposite answer — which is why this is a
|
||||||
|
# separate command and not a flag on that one.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
|
||||||
|
. "$HERE/lib/forgejo-runner-config.sh"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
||||||
|
. "$HERE/lib/admin-path.sh"
|
||||||
|
|
||||||
|
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
|
||||||
|
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
# The default label map. `runs-on: ubuntu-latest` is what a workflow written
|
||||||
|
# for GitHub says, so it must mean something here or every workflow needs
|
||||||
|
# editing to migrate; catthehacker's image is the act/Forgejo ecosystem's
|
||||||
|
# stand-in for GitHub's runner image. `docker` is the lean second option.
|
||||||
|
#
|
||||||
|
# Both are `docker://` — jobs run in CONTAINERS on the box's own dockerd, not
|
||||||
|
# on the box itself. No docker-in-docker: the guide this came from stacks a
|
||||||
|
# privileged dind sidecar with a plaintext tcp://…:2375 daemon to isolate jobs
|
||||||
|
# from a shared CI server, and inside a box that boundary is already paid for.
|
||||||
|
#
|
||||||
|
# WHY act-22.04 (slim) for ubuntu-latest, not full-22.04 — measured 2026-08-01
|
||||||
|
# against ghcr manifests (#144):
|
||||||
|
# act-22.04: ~0.55 GB compressed / ~2.2 GB on disk — no shellcheck
|
||||||
|
# full-22.04: ~18.67 GB compressed / ~54.5 GB on disk — has shellcheck 0.8.0
|
||||||
|
# A normal box-class ci tenant cannot hold 54.5 GB (typical free space ~34 GB).
|
||||||
|
# So ubuntu-latest stays slim, and workflows must not assume GitHub-image tools
|
||||||
|
# (rig's own ci.yml installs shellcheck when missing). Operators who need the
|
||||||
|
# full tool surface opt in with runs-on: ubuntu-latest-full — that label is
|
||||||
|
# inert until matched, so boxes that never ask pay nothing.
|
||||||
|
DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,ubuntu-latest-full:docker://ghcr.io/catthehacker/ubuntu:full-22.04,docker:docker://node:22-bookworm'
|
||||||
|
|
||||||
|
# labels_are_a_retired_default <recorded>
|
||||||
|
#
|
||||||
|
# True when <recorded> is a past DEFAULT_LABELS value rig has shipped — the
|
||||||
|
# only plain-converge case that should warn about re-registration (#144).
|
||||||
|
# Custom operator maps (drill's Leg 3, any --labels) must return false so a
|
||||||
|
# bare re-run stays quiet. One pattern per past default; add when the string
|
||||||
|
# changes. Extracted and driven by test/cli.sh — a grep pin alone cannot prove
|
||||||
|
# the match is exact.
|
||||||
|
labels_are_a_retired_default() {
|
||||||
|
case "${1:-}" in
|
||||||
|
'ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm') return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
|
||||||
|
#
|
||||||
|
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
|
||||||
|
# an asset and prove the download matches it. Prints the reason on stderr and
|
||||||
|
# returns 1 on any failure; the caller supplies the refusal in its own voice.
|
||||||
|
#
|
||||||
|
# BYTE-IDENTICAL to the copy in docs/templates/ci-box/install.sh, diffed by
|
||||||
|
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
|
||||||
|
# downloaders cannot share a lib: this one sources commands/lib/, and that one
|
||||||
|
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
|
||||||
|
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
|
||||||
|
# that keeps one policy from becoming two.
|
||||||
|
#
|
||||||
|
# Review !110 is the evidence for why that matters: a fail-open branch lived in
|
||||||
|
# BOTH copies while a grep for "checksum mismatch" passed against both, because
|
||||||
|
# the string it looked for sat right beside the branch it could not see. The
|
||||||
|
# next checksum-policy change must not be able to land in one file only.
|
||||||
|
#
|
||||||
|
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
|
||||||
|
# reasoning ("do not let an upstream layout change break installs") reasons
|
||||||
|
# about the wrong failure: a layout change moves the BINARY url too, so the
|
||||||
|
# download would already have died. "Binary yes, checksum no" is not what a
|
||||||
|
# layout change looks like — it is what an interfered fetch looks like, which
|
||||||
|
# is precisely what a checksum exists to catch. Failing open would hand an
|
||||||
|
# unverified root install to anyone able to block a single URL. There is
|
||||||
|
# deliberately no bypass flag: if upstream really does move its assets, that is
|
||||||
|
# a rig PR editing the URL, not an operator improvising past a security gate.
|
||||||
|
fetch_and_verify_sha256() {
|
||||||
|
local url="$1" file="$2" sumfile="$3" label="$4" want got
|
||||||
|
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
|
||||||
|
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# The published .sha256 names the asset, not our temp path. Compare the
|
||||||
|
# digest itself rather than rewriting the file into sha256sum -c's format:
|
||||||
|
# one comparison, no parsing of a file we did not write.
|
||||||
|
want="$(tr -d '\r' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
|
||||||
|
got="$(sha256sum "$file" | awk '{print $1}')"
|
||||||
|
if [ -z "$want" ]; then
|
||||||
|
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$want" != "$got" ]; then
|
||||||
|
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf 'checksum verified (%s)\n' "$got"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: rig forgejo-runner install --instance <url> [options]
|
||||||
|
|
||||||
|
--instance <url> Forgejo instance the runner registers to (required),
|
||||||
|
e.g. https://forgejo.example.com
|
||||||
|
--version <pin> forgejo-runner release to install, e.g. 12.13.2
|
||||||
|
(default: the latest release, resolved at install
|
||||||
|
time). Pin it for a deterministic, auditable install.
|
||||||
|
--name <name> runner name (default: this host's hostname)
|
||||||
|
--labels <csv> runner labels; replaces the default. The default maps
|
||||||
|
ubuntu-latest (slim act image), ubuntu-latest-full
|
||||||
|
(opt-in parity image), and docker onto containers so
|
||||||
|
a workflow written for GitHub runs; full tools need
|
||||||
|
runs-on: ubuntu-latest-full or an install step.
|
||||||
|
--user <name> unprivileged service user (default: the tenant user
|
||||||
|
`ci` when it exists, else forgejo-runner; created if
|
||||||
|
absent; never root)
|
||||||
|
|
||||||
|
Installs forgejo-runner as a systemd service under an unprivileged user. The
|
||||||
|
runner is an agent, not a server: it long-polls the instance outbound and
|
||||||
|
receives jobs down that already-established connection, so it needs ZERO
|
||||||
|
inbound ports.
|
||||||
|
|
||||||
|
Jobs run in Docker containers on this box's own daemon. Inside a ci-box tenant
|
||||||
|
that daemon is already there — `rig bootstrap ci-box` installs it and puts the
|
||||||
|
tenant user in the `docker` group.
|
||||||
|
|
||||||
|
Provide the runner registration token via the FORGEJO_RUNNER_TOKEN env var or
|
||||||
|
the interactive prompt. Get one from the scope you want the runner to serve:
|
||||||
|
instance Site Administration > Actions > Runners > Create new Runner
|
||||||
|
org Org > Settings > Actions > Runners
|
||||||
|
repo Repo > Settings > Actions > Runners
|
||||||
|
The SCOPE IS THE TOKEN'S, not a flag here. It is consumed at registration and
|
||||||
|
never written to disk by rig.
|
||||||
|
|
||||||
|
Convergent toward --instance: re-running against the instance this box is
|
||||||
|
already on re-uses the binary, skips registration, and never asks for a token.
|
||||||
|
A box registered to a DIFFERENT instance is refused — take it off the old one
|
||||||
|
with `rig forgejo-runner remove` first.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
|
INSTANCE=""
|
||||||
|
VERSION=""
|
||||||
|
RUNNER_NAME="$(hostname)"
|
||||||
|
LABELS="$DEFAULT_LABELS"
|
||||||
|
# Whether --labels was ASKED FOR, distinct from what it resolved to. A rerun
|
||||||
|
# cannot apply labels (Forgejo owns them from registration time), and the
|
||||||
|
# difference between "operator requested a change" and "operator passed
|
||||||
|
# nothing" is what separates a warning worth printing from noise on every
|
||||||
|
# converge.
|
||||||
|
LABELS_EXPLICIT=0
|
||||||
|
RUNNER_USER=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--instance)
|
||||||
|
[ $# -ge 2 ] || die "--instance needs a value" 2
|
||||||
|
INSTANCE="$2"; shift 2 ;;
|
||||||
|
--version)
|
||||||
|
[ $# -ge 2 ] || die "--version needs a value" 2
|
||||||
|
VERSION="$2"; shift 2 ;;
|
||||||
|
--name)
|
||||||
|
[ $# -ge 2 ] || die "--name needs a value" 2
|
||||||
|
RUNNER_NAME="$2"; shift 2 ;;
|
||||||
|
--labels)
|
||||||
|
[ $# -ge 2 ] || die "--labels needs a value" 2
|
||||||
|
LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;;
|
||||||
|
--user)
|
||||||
|
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||||
|
RUNNER_USER="$2"; shift 2 ;;
|
||||||
|
--repo)
|
||||||
|
# Named, not "unknown flag": everyone arrives here from `rig runner
|
||||||
|
# install --repo`, and the honest answer is that the argument does not
|
||||||
|
# exist on this forge rather than that it is misspelled.
|
||||||
|
[ $# -ge 2 ] && shift
|
||||||
|
die "--repo does not exist here: a Forgejo runner registers to an INSTANCE, and whether it serves that whole instance, one org, or one repo is a property of the registration TOKEN you mint in Forgejo's UI. Pass --instance <url> and mint the token at the scope you want." 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown flag: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- validation ----------------------------------------------------------
|
||||||
|
[ -n "$INSTANCE" ] || die "--instance <url> is required" 2
|
||||||
|
case "$INSTANCE" in
|
||||||
|
https://*|http://*) ;;
|
||||||
|
*) die "--instance must be a URL with a scheme, e.g. https://forgejo.example.com (got: $INSTANCE)" 2 ;;
|
||||||
|
esac
|
||||||
|
# A path component would be a repo URL — the GitHub habit, and the one mistake
|
||||||
|
# that produces a runner registered somewhere subtly wrong rather than a clean
|
||||||
|
# failure. Refuse it by name.
|
||||||
|
case "${INSTANCE#*://}" in
|
||||||
|
*/*[!/]*) die "--instance takes the instance ROOT, not a repository URL: got ${INSTANCE}. Scope comes from the token, not the URL." 2 ;;
|
||||||
|
esac
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
# The same sane-version charset the resolve-latest path enforces further down.
|
||||||
|
# An explicit pin skipped it entirely and went straight into the download URL,
|
||||||
|
# so a value carrying `/` or `..` was interpolated into a URL PATH rather than
|
||||||
|
# refused. Not a trust boundary — this command is already root, and the operator
|
||||||
|
# typed the flag — but a pin that cannot name a release should fail BY NAME at
|
||||||
|
# parse time, not as an opaque 404 forty lines later. install.sh's
|
||||||
|
# valid_version is the same instinct, and the asymmetry was the whole defect:
|
||||||
|
# the value rig resolves for itself was checked, the one it is handed was not.
|
||||||
|
case "$VERSION" in
|
||||||
|
"") ;; # unset — the latest release is resolved and validated below
|
||||||
|
*[!0-9.]*|.*|*.)
|
||||||
|
die "--version must be a release number like 12.13.2 (got: ${VERSION})" 2 ;;
|
||||||
|
esac
|
||||||
|
[ -n "$LABELS" ] || die "--labels must not be empty" 2
|
||||||
|
|
||||||
|
# The tenant user is the default when it is there: inside a ci-box the runner
|
||||||
|
# IS the tenant, and inventing a second service account beside it would leave
|
||||||
|
# the docker-group membership bootstrap-tenant.sh converged on the wrong user.
|
||||||
|
# Falls back to a dedicated account so this still works on a plain machine.
|
||||||
|
if [ -z "$RUNNER_USER" ]; then
|
||||||
|
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
|
||||||
|
fi
|
||||||
|
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
|
||||||
|
|
||||||
|
# --- guards ----------------------------------------------------------------
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
# Root is not enough: the admin binaries must also be reachable (#139). This
|
||||||
|
# sits beside the root check so identity and capability are asserted together,
|
||||||
|
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
|
||||||
|
#
|
||||||
|
# BOTH binaries this command goes on to call: useradd at the user-create below,
|
||||||
|
# usermod at the docker-group add further down. Naming only the first would
|
||||||
|
# still consume the token on a PATH that happened to resolve useradd but not
|
||||||
|
# usermod — the same failure one step later, which is the shape #75 exists to
|
||||||
|
# refuse (a sweep that covers most of its call sites is the hole the next bug
|
||||||
|
# arrives through).
|
||||||
|
require_admin_bins useradd usermod
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
|
||||||
|
# which would clobber this script's $VERSION.
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
|
||||||
|
case "$OS_FAMILY" in
|
||||||
|
*debian*) ;;
|
||||||
|
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
warn "cannot read /etc/os-release; proceeding anyway"
|
||||||
|
fi
|
||||||
|
command -v curl >/dev/null || die "curl is required (run rig bootstrap first)"
|
||||||
|
command -v systemctl >/dev/null || die "systemctl is required — this command installs the runner as a systemd service"
|
||||||
|
|
||||||
|
# --- is this box already registered somewhere else? --------------------------
|
||||||
|
# Before anything is prompted for, downloaded, or started: --instance must
|
||||||
|
# agree with what is already on the box. Everything below treats an existing
|
||||||
|
# .runner as "nothing to do" — right for the instance the box is already on,
|
||||||
|
# silently wrong for any other. See assert_runner_instance.
|
||||||
|
REG_PENDING=1
|
||||||
|
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
|
||||||
|
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
||||||
|
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
||||||
|
assert_runner_instance "$RUNNER_DIR" "$INSTANCE" || exit 1
|
||||||
|
if [ -e "$RUNNER_DIR/.runner" ]; then
|
||||||
|
REG_PENDING=0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- registration token — only when registration is actually pending -------
|
||||||
|
if [ "$REG_PENDING" -eq 1 ]; then
|
||||||
|
FORGEJO_RUNNER_TOKEN="${FORGEJO_RUNNER_TOKEN:-}"
|
||||||
|
# Prompt only on a tty: headless, a bare `read` dies under set -e with no
|
||||||
|
# message at all. Refuse loudly, naming the variable.
|
||||||
|
if [ -z "$FORGEJO_RUNNER_TOKEN" ]; then
|
||||||
|
[ -t 0 ] || die "FORGEJO_RUNNER_TOKEN is unset and stdin is not a tty — set FORGEJO_RUNNER_TOKEN to run unattended"
|
||||||
|
read -rsp "forgejo runner registration token: " FORGEJO_RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set FORGEJO_RUNNER_TOKEN to run unattended"; }
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
[ -n "$FORGEJO_RUNNER_TOKEN" ] || die "empty registration token"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- user --------------------------------------------------------------------
|
||||||
|
if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then
|
||||||
|
useradd --create-home --shell /bin/bash "$RUNNER_USER"
|
||||||
|
log "created user ${RUNNER_USER}"
|
||||||
|
else
|
||||||
|
log "user exists"
|
||||||
|
fi
|
||||||
|
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
||||||
|
RUNNER_GROUP="$(id -gn "$RUNNER_USER")"
|
||||||
|
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
||||||
|
BIN=/usr/local/bin/forgejo-runner
|
||||||
|
|
||||||
|
# The runner talks to dockerd over its socket, so it needs the group. In a
|
||||||
|
# ci-box bootstrap-tenant.sh already did this for the tenant user; on a plain
|
||||||
|
# machine, or for a --user that is not the tenant, it has not.
|
||||||
|
if getent group docker >/dev/null 2>&1; then
|
||||||
|
if id -nG "$RUNNER_USER" | tr ' ' '\n' | grep -qx docker; then
|
||||||
|
log "${RUNNER_USER} already in the docker group"
|
||||||
|
else
|
||||||
|
usermod -aG docker "$RUNNER_USER"
|
||||||
|
log "added ${RUNNER_USER} to the docker group"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "no docker group on this box — jobs using docker:// labels will fail. Inside a ci-box, 'rig bootstrap ci-box' installs docker; elsewhere install it before running jobs."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- download ----------------------------------------------------------------
|
||||||
|
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
|
||||||
|
# one. Taking that checksum is nearly free and makes the install auditable —
|
||||||
|
# the same instinct as `coolify install`'s mandatory version pin.
|
||||||
|
#
|
||||||
|
# "Already present" is NOT enough to skip here, and this is where the GitHub
|
||||||
|
# sibling's shape must not be copied. Its skip is justified by "self-update
|
||||||
|
# owns upgrades" — actions/runner updates itself, and GitHub refuses jobs from
|
||||||
|
# stale runners, so freezing it would be pointless. **forgejo-runner does not
|
||||||
|
# self-update.** Nothing else ever moves the version, so a bare presence check
|
||||||
|
# would mean the binary a box first happened to get is the binary it keeps
|
||||||
|
# forever.
|
||||||
|
#
|
||||||
|
# That lands hardest on the path this command is FOR: a ci-box's template
|
||||||
|
# install.sh preinstalls /usr/local/bin/forgejo-runner at mint, so the
|
||||||
|
# executable always exists before an operator ever runs this — and --version,
|
||||||
|
# documented as the deterministic-pin lever, would silently do nothing on
|
||||||
|
# every ci-box in the fleet.
|
||||||
|
#
|
||||||
|
# So: converge toward --version when it is given, exactly as this command
|
||||||
|
# converges toward --instance. A pin is not a trust boundary the way an
|
||||||
|
# instance is (that one refuses), it is an instruction — including downward,
|
||||||
|
# which is what a pin is for. Absent a pin, an existing binary is left alone:
|
||||||
|
# chasing "latest" on every converge would make a re-run an unrequested
|
||||||
|
# upgrade, and convergence must not be a moving target.
|
||||||
|
|
||||||
|
# runner_version_of / runner_download_decision live in the lib, so the rule can
|
||||||
|
# be driven by test/cli.sh without root — see there for the full reasoning.
|
||||||
|
PRESENT_VER=""
|
||||||
|
HAVE_BIN=no
|
||||||
|
if [ -x "$BIN" ]; then
|
||||||
|
HAVE_BIN=yes
|
||||||
|
PRESENT_VER="$(runner_version_of "$BIN")"
|
||||||
|
fi
|
||||||
|
case "$(runner_download_decision "$HAVE_BIN" "$PRESENT_VER" "$VERSION")" in
|
||||||
|
skip)
|
||||||
|
NEED_DOWNLOAD=0
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
log "forgejo-runner ${VERSION} already installed; skipping download"
|
||||||
|
else
|
||||||
|
log "forgejo-runner ${PRESENT_VER:-(version unreadable)} already present at ${BIN}; skipping download (pass --version <pin> to converge to a specific release)"
|
||||||
|
fi ;;
|
||||||
|
converge)
|
||||||
|
NEED_DOWNLOAD=1
|
||||||
|
log "converging ${BIN}: ${PRESENT_VER:-unreadable} -> ${VERSION} (--version)" ;;
|
||||||
|
*)
|
||||||
|
NEED_DOWNLOAD=1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$NEED_DOWNLOAD" -eq 1 ]; then
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64) ARCH="arm64" ;;
|
||||||
|
*) die "unsupported arch: $(uname -m)" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
# No pin given: resolve the latest release by following the redirect on
|
||||||
|
# the /releases/latest page — no API call, no token, no JSON to parse on
|
||||||
|
# a dependency-free box (install.sh's resolve_latest_tag idiom).
|
||||||
|
LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
|
||||||
|
https://code.forgejo.org/forgejo/runner/releases/latest)" \
|
||||||
|
|| die "could not resolve the latest forgejo-runner release"
|
||||||
|
VERSION="${LATEST_URL##*/}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
case "$VERSION" in
|
||||||
|
""|*[!0-9.]*) die "could not parse a version from ${LATEST_URL}" ;;
|
||||||
|
esac
|
||||||
|
log "resolved latest forgejo-runner: ${VERSION}"
|
||||||
|
fi
|
||||||
|
ASSET="forgejo-runner-${VERSION}-linux-${ARCH}"
|
||||||
|
URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}"
|
||||||
|
WORKDIR="$(mktemp -d)"
|
||||||
|
cleanup() { rm -rf "$WORKDIR"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
log "downloading forgejo-runner ${VERSION} (${ARCH})"
|
||||||
|
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
||||||
|
|| die "could not download ${URL}"
|
||||||
|
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|
||||||
|
|| die "refusing to install an unverified ${ASSET} — it lands as root and runs under a systemd unit. See the checksum failure above."
|
||||||
|
|
||||||
|
# Staged beside the target and RENAMED into place, never written over.
|
||||||
|
# Replacing a running executable in place fails with ETXTBSY, and this path
|
||||||
|
# now runs on boxes where the daemon is live (a --version converge). A
|
||||||
|
# rename is atomic and leaves the running process on the old inode until the
|
||||||
|
# restart below picks up the new one.
|
||||||
|
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN.rig-new"
|
||||||
|
mv -f "$BIN.rig-new" "$BIN"
|
||||||
|
log "installed ${BIN}"
|
||||||
|
fi
|
||||||
|
# `|| true` so the refusal BELOW is the one that fires. Under set -euo pipefail
|
||||||
|
# a bare `VAR="$(cmd | head)"` dies at the assignment when cmd exits non-zero,
|
||||||
|
# which is precisely the case this line exists to diagnose — see the lib.
|
||||||
|
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1 || true)"
|
||||||
|
[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run"
|
||||||
|
# The converge actually took — asserted, not assumed. A pin that silently did
|
||||||
|
# not land is exactly the failure --version exists to make impossible.
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
EFFECTIVE_VER="$(runner_version_of "$BIN")"
|
||||||
|
[ "$EFFECTIVE_VER" = "$VERSION" ] \
|
||||||
|
|| die "asked for forgejo-runner ${VERSION} but ${BIN} reports ${EFFECTIVE_VER:-nothing} after install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- register ----------------------------------------------------------------
|
||||||
|
# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register`
|
||||||
|
# and `create-runner-file` carry "(deprecated)" in their help). It is chosen
|
||||||
|
# here anyway, deliberately, and this is the reasoning to revisit when it
|
||||||
|
# finally goes:
|
||||||
|
#
|
||||||
|
# - It still works. `daemon` reads the `.runner` this writes, resolves the
|
||||||
|
# instance from it, and connects — verified against a live instance, where
|
||||||
|
# a planted `.runner` got as far as "Unauthenticated: unregistered runner".
|
||||||
|
# The mechanism is intact; only the credential was fake.
|
||||||
|
# - The successor needs MORE than rig can honestly ask for at this layer:
|
||||||
|
# `daemon --url --uuid --token-url` requires the runner to already exist on
|
||||||
|
# the instance, so the operator would have to create it via API/UI and
|
||||||
|
# carry back a UUID. That is a second, differently-shaped credential dance
|
||||||
|
# for no gain today.
|
||||||
|
# - `register` writes a file `status` can read back. The successor's config
|
||||||
|
# lives in flags on a unit line, where "what is this box registered to" has
|
||||||
|
# no on-disk answer that is not just rig's own copy of what it was told.
|
||||||
|
#
|
||||||
|
# When upstream removes it: the shape becomes `daemon --url/--uuid`, the unit
|
||||||
|
# gains those flags, and forgejo-runner-config.sh's readers move to whatever
|
||||||
|
# holds the UUID. assert_runner_instance's contract survives either way — it
|
||||||
|
# asks about the instance, which both spellings record.
|
||||||
|
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR"
|
||||||
|
# forgejo-runner's cache server writes to $HOME/.cache, which ProtectHome makes
|
||||||
|
# read-only below. Create it HERE, before the unit can reference it: a
|
||||||
|
# ReadWritePaths entry naming a path that does not exist makes systemd refuse
|
||||||
|
# to start the unit at all ("Failed to set up mount namespacing"), which is
|
||||||
|
# worse than the disabled cache it was meant to fix. Measured on a live runner,
|
||||||
|
# 2026-07-31 (#135). Root-owned would fail the same way under User=, so it
|
||||||
|
# carries the runner's own ownership like RUNNER_DIR above.
|
||||||
|
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$USER_HOME/.cache"
|
||||||
|
if [ -e "$RUNNER_DIR/.runner" ]; then
|
||||||
|
log "already registered; skipping registration"
|
||||||
|
# Registration was skipped, so the labels on the instance are the ones it was
|
||||||
|
# registered with — NOT whatever this invocation was passed. Forgejo owns
|
||||||
|
# labels from registration time; a re-run never rewrites them.
|
||||||
|
#
|
||||||
|
# Two warn paths (#144):
|
||||||
|
# EXPLICIT --labels that differs → operator asked and it was not applied.
|
||||||
|
# Plain converge whose recorded labels match a known *retired* default →
|
||||||
|
# rig's default map moved (e.g. added ubuntu-latest-full). Without this
|
||||||
|
# the operator re-runs install, sees "already registered", and believes
|
||||||
|
# they have the new default while Forgejo still holds the old set.
|
||||||
|
#
|
||||||
|
# Do NOT warn on every RECORDED != current default: that fires forever for
|
||||||
|
# any runner the operator deliberately gave custom --labels (drill's Leg 3
|
||||||
|
# registers drill:docker://node:22-bookworm). The old LABELS_EXPLICIT-only
|
||||||
|
# gate existed to avoid that noise; retired-default matching keeps the
|
||||||
|
# silence for intentional maps and still catches silent drift off a past
|
||||||
|
# rig default. Re-register only to pick up labels the old set never had —
|
||||||
|
# nothing matching the recorded set is broken by the map change alone.
|
||||||
|
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
|
||||||
|
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
|
||||||
|
if [ "$LABELS_EXPLICIT" -eq 1 ] && [ "$RECORDED" != "$LABELS" ]; then
|
||||||
|
warn "--labels was not applied: this runner is already registered, and Forgejo owns its labels from registration time. It still has: ${RECORDED}. Labels are what 'runs-on' matches, so changing them means re-registering: 'rig forgejo-runner remove' then install again with the labels you want."
|
||||||
|
elif [ "$LABELS_EXPLICIT" -eq 0 ] && labels_are_a_retired_default "$RECORDED"; then
|
||||||
|
warn "this runner was registered with an older rig default label set. The current default adds ubuntu-latest-full (the GitHub-parity image). Labels are fixed at registration, so picking it up means re-registering: 'rig forgejo-runner remove' then install again. Nothing you run today is affected — re-register only if you want the new label."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
|
||||||
|
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
|
||||||
|
"$BIN" register --no-interactive \
|
||||||
|
--instance "$INSTANCE" --token "$FORGEJO_RUNNER_TOKEN" \
|
||||||
|
--name "$RUNNER_NAME" --labels "$LABELS") \
|
||||||
|
|| die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already"
|
||||||
|
[ -e "$RUNNER_DIR/.runner" ] \
|
||||||
|
|| die "register reported success but wrote no ${RUNNER_DIR}/.runner"
|
||||||
|
# INSIDE the registration branch, where runner-install.sh keeps its copy and
|
||||||
|
# for the same reason: this file records what rig ACTUALLY registered with,
|
||||||
|
# so `status` has something to read back. Writing it unconditionally — as an
|
||||||
|
# earlier draft did — makes a plain re-run stamp this invocation's labels
|
||||||
|
# over a registration that used different ones, and `status` then reports
|
||||||
|
# confidently wrong labels while Forgejo still holds the originals. A
|
||||||
|
# metadata file that can disagree with the thing it describes is worse than
|
||||||
|
# no metadata file.
|
||||||
|
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
|
||||||
|
chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels"
|
||||||
|
fi
|
||||||
|
# EVERY run, registration or not: .runner holds the runner's own long-lived
|
||||||
|
# token, and a mode that drifted leaks it silently. See the lib.
|
||||||
|
forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP"
|
||||||
|
|
||||||
|
# --- service -------------------------------------------------------------
|
||||||
|
# Written by rig rather than shipped by upstream: forgejo-runner has no
|
||||||
|
# svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to
|
||||||
|
# actions/runner's). Converged like every file rig writes — cmp-guarded, so a
|
||||||
|
# re-run that changes nothing reloads nothing.
|
||||||
|
UNIT=/etc/systemd/system/forgejo-runner.service
|
||||||
|
UNIT_TMP="$(mktemp)"
|
||||||
|
cat > "$UNIT_TMP" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Forgejo Actions runner
|
||||||
|
Documentation=https://forgejo.org/docs/latest/admin/actions/
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=${RUNNER_USER}
|
||||||
|
WorkingDirectory=${RUNNER_DIR}
|
||||||
|
ExecStart=${BIN} daemon
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
# The runner supervises job containers on this box's docker socket; it is not
|
||||||
|
# a sandbox for them. These keep the DAEMON from being a soft target.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=${RUNNER_DIR} ${USER_HOME}/.cache
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
if ! cmp -s "$UNIT_TMP" "$UNIT" 2>/dev/null; then
|
||||||
|
install -m 0644 "$UNIT_TMP" "$UNIT"
|
||||||
|
systemctl daemon-reload
|
||||||
|
log "systemd unit written: ${UNIT}"
|
||||||
|
else
|
||||||
|
log "systemd unit already current"
|
||||||
|
fi
|
||||||
|
rm -f "$UNIT_TMP"
|
||||||
|
|
||||||
|
systemctl enable forgejo-runner >/dev/null 2>&1 || die "could not enable forgejo-runner.service"
|
||||||
|
systemctl restart forgejo-runner || die "could not start forgejo-runner.service — see 'journalctl -u forgejo-runner'"
|
||||||
|
|
||||||
|
# Assert the EFFECTIVE state, not systemctl's exit code: a unit that starts and
|
||||||
|
# immediately dies (bad token, unreachable instance) leaves restart succeeding
|
||||||
|
# and the runner absent. Settle briefly, then ask.
|
||||||
|
active=""
|
||||||
|
for _ in 1 2 3 4 5 6; do
|
||||||
|
if systemctl is-active forgejo-runner >/dev/null 2>&1; then active=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$active" ] || die "forgejo-runner.service is not active after 12s — see 'journalctl -u forgejo-runner' (a bad token or an unreachable instance both land here)"
|
||||||
|
|
||||||
|
log "runner ${RUNNER_NAME} (${INSTALLED_VER}) installed and running"
|
||||||
|
log "labels: ${LABELS}"
|
||||||
|
log "verify it shows Idle under ${INSTANCE} > Site Administration > Actions > Runners"
|
||||||
|
log "this box needs no inbound ports — the runner polls the instance outbound"
|
||||||
139
commands/forgejo-runner-remove.sh
Executable file
139
commands/forgejo-runner-remove.sh
Executable file
|
|
@ -0,0 +1,139 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig forgejo-runner remove — take the service down and wipe this box's
|
||||||
|
# registration. Convergent: a box with nothing installed exits 0.
|
||||||
|
#
|
||||||
|
# There is no --local flag here, and its absence is the design. The GitHub
|
||||||
|
# sibling offers --local as an ESCAPE HATCH from a real deregistration
|
||||||
|
# handshake (config.sh remove --token, against an endpoint that mints removal
|
||||||
|
# tokens). Forgejo has no such handshake and no such endpoint: local is the
|
||||||
|
# only thing removal can ever be. Shipping the flag would advertise a
|
||||||
|
# server-side alternative that does not exist, and an operator would spend the
|
||||||
|
# afternoon hunting for the token that turns it off.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
|
||||||
|
. "$HERE/lib/forgejo-runner-config.sh"
|
||||||
|
|
||||||
|
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
|
||||||
|
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: rig forgejo-runner remove [--user <name>]
|
||||||
|
|
||||||
|
--user <name> unprivileged service user (default: the tenant user `ci`
|
||||||
|
when it exists, else forgejo-runner)
|
||||||
|
|
||||||
|
Stops and disables the systemd service, then wipes this box's registration.
|
||||||
|
The binary and the user stay put, so a later `rig forgejo-runner install`
|
||||||
|
re-registers without downloading anything.
|
||||||
|
|
||||||
|
Forgejo has no runner deregistration endpoint, so this is always local-only:
|
||||||
|
the box is cleaned, and the runner stays listed as offline in the instance
|
||||||
|
until you delete it under Actions > Runners. No token is needed or asked for.
|
||||||
|
|
||||||
|
Convergent: safe to re-run; a box with no runner installed exits 0.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
|
RUNNER_USER=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--user)
|
||||||
|
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||||
|
RUNNER_USER="$2"; shift 2 ;;
|
||||||
|
--local)
|
||||||
|
# Named rather than "unknown flag": it is the GitHub sibling's spelling,
|
||||||
|
# and the answer is that removal here is ALWAYS what --local means.
|
||||||
|
die "--local is not a flag here: Forgejo has no deregistration endpoint, so 'rig forgejo-runner remove' is always local-only. Re-run it without the flag, then delete the offline runner in the instance's Actions > Runners." 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown flag: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RUNNER_USER" ]; then
|
||||||
|
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- validation ------------------------------------------------------------
|
||||||
|
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
|
||||||
|
|
||||||
|
# --- guards ----------------------------------------------------------------
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
|
||||||
|
UNIT=/etc/systemd/system/forgejo-runner.service
|
||||||
|
|
||||||
|
# --- nothing to remove? -----------------------------------------------------
|
||||||
|
# The unit is checked INDEPENDENTLY of the user, and that ordering is the whole
|
||||||
|
# point. A missing user used to exit 0 here before the unit was ever looked at,
|
||||||
|
# so a deleted account with a leftover forgejo-runner.service reported "nothing
|
||||||
|
# to remove" and left the unit behind — while the absence-assert at the end,
|
||||||
|
# which never ran, implied removal had been complete. `bootstrap --undo`'s own
|
||||||
|
# unit check would still have caught it, but a verb that claims to have removed
|
||||||
|
# everything must not be the thing that lies about it.
|
||||||
|
RUNNER_DIR=""
|
||||||
|
if id -u "$RUNNER_USER" >/dev/null 2>&1; then
|
||||||
|
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
||||||
|
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
||||||
|
else
|
||||||
|
log "no ${RUNNER_USER} user on this box"
|
||||||
|
fi
|
||||||
|
if [ -z "$RUNNER_DIR" ] && [ ! -e "$UNIT" ]; then
|
||||||
|
log "no runner user and no unit on this box; nothing to remove"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -n "$RUNNER_DIR" ] && [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$UNIT" ]; then
|
||||||
|
log "no runner registered in ${RUNNER_DIR}; nothing to remove"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[ -n "$RUNNER_DIR" ] || warn "the ${RUNNER_USER} user is gone but ${UNIT} is still here — removing the orphaned unit"
|
||||||
|
|
||||||
|
INSTANCE=""
|
||||||
|
RUNNER_NAME=""
|
||||||
|
if [ -n "$RUNNER_DIR" ]; then
|
||||||
|
INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")"
|
||||||
|
RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- service ---------------------------------------------------------------
|
||||||
|
# First, in both paths: stopping after the registration is wiped would strand a
|
||||||
|
# running daemon polling with credentials that no longer exist on disk.
|
||||||
|
if [ -e "$UNIT" ]; then
|
||||||
|
log "stopping and disabling forgejo-runner.service"
|
||||||
|
systemctl stop forgejo-runner >/dev/null 2>&1 || true
|
||||||
|
systemctl disable forgejo-runner >/dev/null 2>&1 || true
|
||||||
|
rm -f "$UNIT"
|
||||||
|
systemctl daemon-reload
|
||||||
|
else
|
||||||
|
log "no service installed; skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- registration -----------------------------------------------------------
|
||||||
|
# Every path below is gated on RUNNER_DIR being non-empty. With the user gone
|
||||||
|
# it is "", and an unguarded "$RUNNER_DIR/.rig-labels" would expand to
|
||||||
|
# "/.rig-labels" — an rm at the filesystem root, as root. The repo already
|
||||||
|
# treats this class of expansion as a hazard worth spelling out (`rm -rf
|
||||||
|
# "${ir:?}/versions/$ver"` in bin/rig); same discipline here.
|
||||||
|
if [ -n "$RUNNER_DIR" ]; then
|
||||||
|
if [ -e "$RUNNER_DIR/.runner" ]; then
|
||||||
|
rm -f "$RUNNER_DIR/.runner"
|
||||||
|
log "wiped the local registration"
|
||||||
|
fi
|
||||||
|
rm -f "$RUNNER_DIR/.rig-labels"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# END WITH THE ABSENCE ASSERT: "removed" is a claim, and claims get verified
|
||||||
|
# (the `rig uninstall` precedent).
|
||||||
|
leftover=""
|
||||||
|
[ -n "$RUNNER_DIR" ] && [ -e "$RUNNER_DIR/.runner" ] && leftover="$leftover $RUNNER_DIR/.runner"
|
||||||
|
[ -e "$UNIT" ] && leftover="$leftover $UNIT"
|
||||||
|
if [ -n "$leftover" ]; then
|
||||||
|
printf 'rig-forgejo-runner: remove INCOMPLETE — still present:%s\n' "$leftover" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "runner removed; the binary stays for a future rig forgejo-runner install"
|
||||||
|
warn "the runner${RUNNER_NAME:+ ${RUNNER_NAME}} is still listed as offline in ${INSTANCE:-the instance} — delete it under Actions > Runners. Forgejo has no deregistration endpoint, so rig cannot do this for you."
|
||||||
118
commands/forgejo-runner-status.sh
Executable file
118
commands/forgejo-runner-status.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig forgejo-runner status — what is this box's Forgejo runner registered to?
|
||||||
|
# Read-only: reports what is already on the box. No credential, no network call.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh
|
||||||
|
. "$HERE/lib/forgejo-runner-config.sh"
|
||||||
|
|
||||||
|
log() { printf 'rig-forgejo-runner: %s\n' "$*"; }
|
||||||
|
warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
# forgejo_runner_liveness_note <systemctl-state> — what `active` does not cover.
|
||||||
|
#
|
||||||
|
# `active` is the strongest health signal this command has, and it proves only
|
||||||
|
# that a process exists — not that the runner is still asking Forgejo for work.
|
||||||
|
# A poller can go quiet while the daemon stays up: measured 2026-07-30 (#129,
|
||||||
|
# #133), a daemon logged "[poller] launched" and never fetched a job dispatched
|
||||||
|
# four minutes later, while a daemon started fresh claimed that same queued task
|
||||||
|
# in one second. Both times it read as a label-mapping bug on the forge, which
|
||||||
|
# is the wrong place to look.
|
||||||
|
#
|
||||||
|
# A FUNCTION rather than an inline `if`, because the state boundary is the part
|
||||||
|
# worth pinning: an absent or inactive unit must say nothing, and a grep over
|
||||||
|
# the source cannot tell the difference (codex/kimi, !134).
|
||||||
|
#
|
||||||
|
# log, not warn: nothing has been DETECTED here. An idle runner with no queued
|
||||||
|
# jobs is silent in exactly the same way a stalled one is, so there is no signal
|
||||||
|
# separating them — a warning on every status run would be crying wolf, and warn
|
||||||
|
# in this file means a drift actually measured (the .runner mode below).
|
||||||
|
forgejo_runner_liveness_note() {
|
||||||
|
[ "${1:-}" = active ] || return 0
|
||||||
|
log " note: 'active' is not proof the runner is fetching jobs — only that the process is up."
|
||||||
|
log " If a job stays queued and its run page says it never started, run"
|
||||||
|
log " 'systemctl restart forgejo-runner' and re-read before suspecting the labels."
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: rig forgejo-runner status [--user <name>]
|
||||||
|
|
||||||
|
--user <name> unprivileged service user (default: the tenant user `ci`
|
||||||
|
when it exists, else forgejo-runner)
|
||||||
|
|
||||||
|
Prints the Forgejo instance this box's runner is registered to, its runner
|
||||||
|
name, the labels rig recorded when it registered, the install directory, and
|
||||||
|
the systemd unit and its state.
|
||||||
|
|
||||||
|
Reads only the runner's own on-disk config — no token, no network call. The
|
||||||
|
registration secret that config holds is never printed. Exits 1 when no runner
|
||||||
|
is installed.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
|
RUNNER_USER=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--user)
|
||||||
|
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||||
|
RUNNER_USER="$2"; shift 2 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown flag: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$RUNNER_USER" ]; then
|
||||||
|
if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- validation ------------------------------------------------------------
|
||||||
|
[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2
|
||||||
|
|
||||||
|
# --- guards ----------------------------------------------------------------
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
|
||||||
|
id -u "$RUNNER_USER" >/dev/null 2>&1 \
|
||||||
|
|| die "no runner installed (no ${RUNNER_USER} user on this box)"
|
||||||
|
USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)"
|
||||||
|
RUNNER_DIR="$USER_HOME/forgejo-runner"
|
||||||
|
[ -e "$RUNNER_DIR/.runner" ] \
|
||||||
|
|| die "no runner registered in ${RUNNER_DIR}"
|
||||||
|
|
||||||
|
# --- read the runner's own config -------------------------------------------
|
||||||
|
INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")"
|
||||||
|
RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")"
|
||||||
|
|
||||||
|
if [ -r "$RUNNER_DIR/.rig-labels" ]; then
|
||||||
|
LABELS="$(cat "$RUNNER_DIR/.rig-labels")"
|
||||||
|
else
|
||||||
|
LABELS="(not recorded on this box — see the instance's Actions > Runners)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
UNIT=/etc/systemd/system/forgejo-runner.service
|
||||||
|
if [ -e "$UNIT" ]; then
|
||||||
|
STATE="$(systemctl is-active forgejo-runner 2>/dev/null || true)"
|
||||||
|
SERVICE="forgejo-runner.service (${STATE:-unknown})"
|
||||||
|
else
|
||||||
|
SERVICE="(not installed as a service)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "instance: ${INSTANCE:-unknown}"
|
||||||
|
log "name: ${RUNNER_NAME:-unknown}"
|
||||||
|
log "labels: ${LABELS}"
|
||||||
|
log "dir: ${RUNNER_DIR}"
|
||||||
|
log "service: ${SERVICE}"
|
||||||
|
forgejo_runner_liveness_note "${STATE:-}"
|
||||||
|
|
||||||
|
|
||||||
|
# status is the only command an operator runs when nothing is obviously wrong,
|
||||||
|
# which makes it the right place to notice a mode that drifted. It reports and
|
||||||
|
# does not fix: converging state is `install`'s job, and a read-only verb that
|
||||||
|
# quietly writes is a worse surprise than a loud warning.
|
||||||
|
MODE="$(stat -c '%a' "$RUNNER_DIR/.runner" 2>/dev/null || true)"
|
||||||
|
if [ -n "$MODE" ] && [ "$MODE" != "$FORGEJO_RUNNER_FILE_MODE" ]; then
|
||||||
|
warn ".runner is mode ${MODE}, not ${FORGEJO_RUNNER_FILE_MODE} — it holds this runner's registration secret, and every account on this box can read it. Re-run 'rig forgejo-runner install --instance ${INSTANCE:-<url>}' to converge the mode."
|
||||||
|
fi
|
||||||
47
commands/lib/admin-path.sh
Normal file
47
commands/lib/admin-path.sh
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# admin-path.sh — assert the admin binaries are REACHABLE, not merely that we
|
||||||
|
# are root.
|
||||||
|
#
|
||||||
|
# Being uid 0 and being able to find useradd are different facts, and rig
|
||||||
|
# asserted only the first. `su` without `-`, sudo with a sanitised secure_path,
|
||||||
|
# and several container images all hand you a root shell whose PATH carries no
|
||||||
|
# /usr/sbin — which is where useradd, usermod and groupadd live on Debian. The
|
||||||
|
# result was a bare `useradd: command not found` naming a line number inside a
|
||||||
|
# versioned install root, emitted AFTER a registration token had been read off
|
||||||
|
# the operator's terminal (#139).
|
||||||
|
#
|
||||||
|
# Which binaries this covers is measured, not assumed (Debian 13, 2026-08-01):
|
||||||
|
#
|
||||||
|
# useradd usermod groupadd userdel groupdel /usr/sbin package: passwd
|
||||||
|
# visudo /usr/sbin package: sudo
|
||||||
|
# gpasswd /usr/bin package: passwd
|
||||||
|
#
|
||||||
|
# Two consequences worth keeping written down. `gpasswd` is in the same PACKAGE
|
||||||
|
# as useradd but a different DIRECTORY, so it is reachable on a PATH-shorn root
|
||||||
|
# and does not belong in any of these preflights — do not add it for symmetry.
|
||||||
|
# And `visudo` shares the directory but not the package, so its absence has a
|
||||||
|
# second, innocent cause (sudo simply not installed) that the others do not, so
|
||||||
|
# `rig users apply` checks it separately, after the point where that cause is
|
||||||
|
# ruled out. See the comment there.
|
||||||
|
#
|
||||||
|
# (Spelled without the `.sh` on purpose: test/cli.sh pins that exactly one file
|
||||||
|
# under commands/ names that script, to catch a second caller appearing. A
|
||||||
|
# comment is not a caller, but the pin is deliberately blunt and cheap.)
|
||||||
|
#
|
||||||
|
# It REFUSES rather than repairing PATH itself. A command that quietly prepends
|
||||||
|
# /usr/sbin teaches the operator nothing and leaves a misconfigured host
|
||||||
|
# misconfigured; the same reason bootstrap refuses rather than guessing. The
|
||||||
|
# message carries the fix so the refusal costs one paste, not an investigation.
|
||||||
|
|
||||||
|
# require_admin_bins <bin>... — die unless every one resolves on PATH.
|
||||||
|
require_admin_bins() {
|
||||||
|
local missing=() b
|
||||||
|
for b in "$@"; do
|
||||||
|
command -v "$b" >/dev/null 2>&1 || missing+=("$b")
|
||||||
|
done
|
||||||
|
[ "${#missing[@]}" -eq 0 ] && return 0
|
||||||
|
# Names the REMEDY, not this script: the operator typed a `rig ...` command,
|
||||||
|
# and echoing the internal path back at them is the unhelpful half of the
|
||||||
|
# original `useradd: command not found`.
|
||||||
|
die "cannot find ${missing[*]} on PATH — it lives in /usr/sbin, which this root shell does not carry (a 'su' without '-' does this, and so do some container images). Re-run the same rig command with: PATH=/usr/sbin:/sbin:\$PATH"
|
||||||
|
}
|
||||||
163
commands/lib/forgejo-runner-config.sh
Normal file
163
commands/lib/forgejo-runner-config.sh
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared reader for the Forgejo runner's own on-disk config ($RUNNER_DIR/.runner).
|
||||||
|
# Sourced by the forgejo-runner-* commands; never executed on its own.
|
||||||
|
#
|
||||||
|
# WHY A SECOND LIB, not an arm inside lib/runner-config.sh: the two files are
|
||||||
|
# different documents making different claims, and the sibling's helpers answer
|
||||||
|
# questions this one cannot ask. GitHub's .runner names a REPOSITORY
|
||||||
|
# (gitHubUrl), so `runner install` converges toward --repo. Forgejo's names an
|
||||||
|
# INSTANCE (address) and nothing else about scope — whether a registration is
|
||||||
|
# instance-wide, org, or single-repo is a property of the TOKEN, decided in
|
||||||
|
# Forgejo's UI before rig ever sees it. There is no repo here to converge
|
||||||
|
# toward, and no way to read one back. Sharing a reader would mean a
|
||||||
|
# gitHubUrl accessor that returns empty forever on one of the two forges.
|
||||||
|
#
|
||||||
|
# json_field is deliberately re-used FROM the sibling rather than copied: a
|
||||||
|
# rig-bootstrapped box has no jq, both files are flat JSON, and one grep/sed
|
||||||
|
# reader for both is the same trade lib/runner-config.sh already argued.
|
||||||
|
|
||||||
|
HERE_FJ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/runner-config.sh
|
||||||
|
. "$HERE_FJ/runner-config.sh" # json_field
|
||||||
|
|
||||||
|
# THE CREDENTIAL FACT that shapes this whole family: Forgejo's .runner holds
|
||||||
|
# the runner's own long-lived token — the secret it authenticates every poll
|
||||||
|
# with — alongside address/name/labels. GitHub's holds no such thing.
|
||||||
|
#
|
||||||
|
# So the mode is part of the contract, not hygiene: a registration secret
|
||||||
|
# readable by every account on the box is a quiet, permanent credential leak,
|
||||||
|
# and it leaks silently — nothing fails, the runner keeps working. Converge is
|
||||||
|
# the only moment rig can notice a mode that drifted (an operator's editor, a
|
||||||
|
# restore from a tarball that lost modes, a hand-edit to add a label).
|
||||||
|
FORGEJO_RUNNER_FILE_MODE=600
|
||||||
|
|
||||||
|
# forgejo_runner_instance <runner_dir> — the Forgejo instance this box's runner
|
||||||
|
# is registered to, empty when nothing is registered there.
|
||||||
|
forgejo_runner_instance() {
|
||||||
|
[ -e "$1/.runner" ] || return 0
|
||||||
|
json_field "$1/.runner" address
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_runner_name <runner_dir> — the runner's name, empty when unregistered.
|
||||||
|
forgejo_runner_name() {
|
||||||
|
[ -e "$1/.runner" ] || return 0
|
||||||
|
json_field "$1/.runner" name
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_runner_secure <runner_dir> <user> <group> — converge .runner to 0600
|
||||||
|
# owned by the runner user. Called on every install, not only at registration.
|
||||||
|
# Silent on success: this is a mode that should always already be right, and a
|
||||||
|
# line saying so on every converge would train the reader to skip it.
|
||||||
|
forgejo_runner_secure() {
|
||||||
|
local dir="$1" user="$2" group="$3"
|
||||||
|
[ -e "$dir/.runner" ] || return 0
|
||||||
|
chmod "$FORGEJO_RUNNER_FILE_MODE" "$dir/.runner"
|
||||||
|
chown "$user:$group" "$dir/.runner"
|
||||||
|
}
|
||||||
|
|
||||||
|
# runner_version_of <bin> — the bare version number ("12.13.2") the binary
|
||||||
|
# reports, empty when it cannot answer. `forgejo-runner --version` prints
|
||||||
|
# "forgejo-runner version v12.13.2"; the leading v is stripped so this compares
|
||||||
|
# against a --version argument, which has its own v stripped at parse.
|
||||||
|
#
|
||||||
|
# `|| true` for json_field's reason, which bites harder here. Callers run under
|
||||||
|
# `set -euo pipefail`, where a pipeline whose FIRST stage exits non-zero fails
|
||||||
|
# the whole pipeline — and `PRESENT_VER="$(runner_version_of "$BIN")"` is an
|
||||||
|
# assignment, so the script dies AT THAT LINE, with no message. "Empty when it
|
||||||
|
# cannot answer" is only true if this says so out loud.
|
||||||
|
#
|
||||||
|
# That is not a hypothetical shape: the binary is `[ -x ]` but unrunnable
|
||||||
|
# exactly when a ci-box's template preinstall landed a truncated or wrong-arch
|
||||||
|
# download — the one path this command family exists for. Without this, install
|
||||||
|
# exits 1 in silence and the refusal written for that case ("the download
|
||||||
|
# landed but cannot run") is unreachable code.
|
||||||
|
runner_version_of() {
|
||||||
|
"$1" --version 2>/dev/null | head -n1 \
|
||||||
|
| sed -nE 's/.*[Vv]ersion[[:space:]]+v?([0-9][0-9A-Za-z.+-]*).*/\1/p' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# runner_download_decision <have-binary yes|no> <present-ver> <wanted-ver>
|
||||||
|
# -> "install" | "skip" | "converge"
|
||||||
|
#
|
||||||
|
# A PURE function, and pure on purpose: this is the decision review !110 caught
|
||||||
|
# being wrong, and it was wrong in a way no grep could see. Lifting it out of
|
||||||
|
# the root-only install path is what makes "a pre-existing binary plus
|
||||||
|
# --version" a real test rather than a string match.
|
||||||
|
#
|
||||||
|
# The rule, and why it is not the GitHub sibling's:
|
||||||
|
#
|
||||||
|
# no binary -> install. Nothing to reason about.
|
||||||
|
# binary, no --version -> skip. Chasing "latest" on every converge would make
|
||||||
|
# a plain re-run an unrequested upgrade, and a
|
||||||
|
# convergent verb must not be a moving target.
|
||||||
|
# binary, pin matches -> skip.
|
||||||
|
# binary, pin differs -> CONVERGE, including downward. A pin is an
|
||||||
|
# instruction, not a floor.
|
||||||
|
#
|
||||||
|
# runner-install.sh skips on mere presence because actions/runner SELF-UPDATES,
|
||||||
|
# so its version moves regardless and freezing it would only make GitHub refuse
|
||||||
|
# the runner's jobs. forgejo-runner does not self-update: nothing else ever
|
||||||
|
# moves this version, and a ci-box's template preinstalls the binary at mint —
|
||||||
|
# so mere-presence here would leave --version dead on the one path this whole
|
||||||
|
# command exists to serve.
|
||||||
|
#
|
||||||
|
# An unreadable present version (empty) with a pin asked for falls to
|
||||||
|
# "converge", which is the right direction: a binary that cannot say what it is
|
||||||
|
# should be replaced by one that can.
|
||||||
|
runner_download_decision() {
|
||||||
|
local have="$1" present="$2" want="$3"
|
||||||
|
[ "$have" = yes ] || { printf 'install\n'; return 0; }
|
||||||
|
[ -n "$want" ] || { printf 'skip\n'; return 0; }
|
||||||
|
[ "$present" = "$want" ] && { printf 'skip\n'; return 0; }
|
||||||
|
printf 'converge\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
# assert_runner_instance <runner_dir> <instance-url>
|
||||||
|
#
|
||||||
|
# Returns 0 when the box has no runner, or has one already registered to
|
||||||
|
# <instance-url>: re-running `install` against the instance the box is already
|
||||||
|
# on is real convergence — it re-uses the binary, skips registration, exits 0.
|
||||||
|
#
|
||||||
|
# Returns 1, explaining itself on stderr, when the runner is registered to a
|
||||||
|
# DIFFERENT instance. Skipping *that* is not convergence, it is ignoring the
|
||||||
|
# argument: `install` would skip its registration step, restart the service
|
||||||
|
# against the OLD instance, and report success — leaving the instance you asked
|
||||||
|
# for with no runner and its jobs queued against one that will never come.
|
||||||
|
#
|
||||||
|
# This is assert_runner_repo's reasoning, asked about the axis Forgejo actually
|
||||||
|
# has. There is deliberately no `repoint` sibling: Forgejo has no
|
||||||
|
# deregistration handshake to perform against the old instance, so moving a
|
||||||
|
# runner is `remove` then `install` — two acts that are already honest about
|
||||||
|
# leaving a stale entry behind, rather than one verb pretending to be atomic.
|
||||||
|
assert_runner_instance() {
|
||||||
|
local dir="$1" wanted="$2" current
|
||||||
|
[ -e "$dir/.runner" ] || return 0
|
||||||
|
|
||||||
|
current="$(forgejo_runner_instance "$dir")"
|
||||||
|
|
||||||
|
if [ -z "$current" ]; then
|
||||||
|
printf 'rig-forgejo-runner: ERROR: %s\n' \
|
||||||
|
"${dir}/.runner exists but names no instance — this box's registration cannot
|
||||||
|
be read, so rig cannot tell whether it is already on ${wanted}.
|
||||||
|
Wipe the local registration and install again:
|
||||||
|
rig forgejo-runner remove" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trailing slashes are a spelling difference, not a different instance:
|
||||||
|
# forgejo-runner records the URL as given, so `--instance https://f.example/`
|
||||||
|
# and `--instance https://f.example` would otherwise read as a move.
|
||||||
|
if [ "${current%/}" = "${wanted%/}" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'rig-forgejo-runner: ERROR: %s\n' \
|
||||||
|
"this box's runner is already registered to ${current}, not ${wanted}.
|
||||||
|
install will not move a runner between instances: it would leave the service
|
||||||
|
running against the OLD instance and report success. To move it, take it off
|
||||||
|
the old instance first:
|
||||||
|
rig forgejo-runner remove
|
||||||
|
then install against the new one. Forgejo has no deregistration handshake, so
|
||||||
|
the old entry stays listed until you delete it in that instance's admin UI." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
185
commands/lib/manifest.sh
Normal file
185
commands/lib/manifest.sh
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# /etc/rig/manifest — PROVENANCE: which rig converged this machine, and when
|
||||||
|
# (#61). Sourced by bootstrap.sh, bootstrap-tenant.sh and the test harness.
|
||||||
|
#
|
||||||
|
# The file is `key=value`, one per line — not JSON, not YAML. Same constraint
|
||||||
|
# stated three times in the tree already (lib/users-config.sh:6-12,
|
||||||
|
# lib/runner-config.sh:6 and :24): a rig-bootstrapped box has no YAML parser
|
||||||
|
# and no jq, which is why json_field() is grep-and-sed. This is the one file
|
||||||
|
# that must stay readable on the most broken machine in the fleet, so `read`
|
||||||
|
# parses it for free.
|
||||||
|
#
|
||||||
|
# WHAT GOES IN HERE: facts that are DECIDED. Which rig ran, and when it ran.
|
||||||
|
# Facts that are OBSERVED — cores, RAM, disk, kernel — belong to `rig platform`
|
||||||
|
# (#64), which computes them fresh and stores nothing. That split is not
|
||||||
|
# tidiness: a stored spec goes stale on its own (someone adds RAM; the
|
||||||
|
# unattended-upgrades bootstrap.sh itself enables patches the kernel), and
|
||||||
|
# refreshing it on every run collides head-on with bootstrap.sh:3 —
|
||||||
|
# "Convergent: safe to re-run; a second run changes nothing." Keeping only
|
||||||
|
# immutable content removes that problem instead of managing it.
|
||||||
|
#
|
||||||
|
# NEVER A CREDENTIAL. This file is 0644 and world-readable by design — it is
|
||||||
|
# an audit record, and an audit record nobody can read is not one. Later
|
||||||
|
# commands may append their own provenance (runner_installed_at,
|
||||||
|
# coolify_installed_at, box_version), subject to the same two rules: the EVENT
|
||||||
|
# of installing something, never its current state, and never a secret. Same
|
||||||
|
# law runner-install.sh:190 already states for `.rig-labels` — "box-local
|
||||||
|
# metadata, never a credential."
|
||||||
|
|
||||||
|
# The schema version, an INTEGER, independent of the rig versions recorded in
|
||||||
|
# the file. Bumped only when a key is REMOVED or REPURPOSED — adding a key is
|
||||||
|
# not a bump, and readers must ignore keys they do not know, so a newer rig's
|
||||||
|
# manifest stays readable to an older one. No `schema=` line means pre-manifest.
|
||||||
|
MANIFEST_SCHEMA=1
|
||||||
|
|
||||||
|
# The five keys rig's provenance block owns. Everything else in the file is a
|
||||||
|
# later command's line and is preserved verbatim (see manifest_foreign).
|
||||||
|
MANIFEST_KEYS='schema bootstrapped_by bootstrapped_at converged_by converged_at'
|
||||||
|
|
||||||
|
# manifest_path — where the manifest lives. RIG_MANIFEST overrides it so tests
|
||||||
|
# point at fixtures (repo precedent: RIG_ROLE_MARKER, bin/rig:148).
|
||||||
|
manifest_path() {
|
||||||
|
printf '%s' "${RIG_MANIFEST:-/etc/rig/manifest}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_value <path> <key> — the value of one key, or nothing when the file
|
||||||
|
# or the key is absent. First occurrence wins. NO policy here: what a missing
|
||||||
|
# key MEANS is each caller's call (this reader only reads — repo precedent:
|
||||||
|
# read_role_marker).
|
||||||
|
manifest_value() {
|
||||||
|
[ -r "$1" ] || return 0
|
||||||
|
local k v
|
||||||
|
while IFS='=' read -r k v || [ -n "$k" ]; do
|
||||||
|
[ "$k" = "$2" ] || continue
|
||||||
|
printf '%s' "$v"
|
||||||
|
return 0
|
||||||
|
done < "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_has <path> <key> — is the key PRESENT, regardless of its value.
|
||||||
|
# Separate from manifest_value because "absent" and "present but empty" are
|
||||||
|
# different answers and command substitution collapses both to the empty
|
||||||
|
# string. Key comparison is a string equality, never a pattern: `rig manifest`
|
||||||
|
# passes operator input straight in, and a key of `.*` must find nothing rather
|
||||||
|
# than match the first line.
|
||||||
|
manifest_has() {
|
||||||
|
[ -r "$1" ] || return 1
|
||||||
|
local k
|
||||||
|
while IFS='=' read -r k _ || [ -n "$k" ]; do
|
||||||
|
[ "$k" = "$2" ] && return 0
|
||||||
|
done < "$1"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_foreign <path> — every line rig's provenance block does NOT own,
|
||||||
|
# in file order. A newer rig (or a later command) may have written keys this
|
||||||
|
# one has never heard of; rewriting the file must not eat them, or the schema's
|
||||||
|
# "readers ignore keys they do not know" promise would only hold for readers
|
||||||
|
# and not for the writer.
|
||||||
|
manifest_foreign() {
|
||||||
|
[ -r "$1" ] || return 0
|
||||||
|
local line key
|
||||||
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
key="${line%%=*}"
|
||||||
|
case " $MANIFEST_KEYS " in
|
||||||
|
*" $key "*) continue ;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "$line"
|
||||||
|
done < "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_render <path> <version> <now> — the WHOLE convergence contract, as
|
||||||
|
# a pure text→text function: existing file + running version + a clock reading
|
||||||
|
# in, the file's desired content out. No side effects, so the harness proves
|
||||||
|
# the rules non-root against fixtures (repo precedent: parse_users_file,
|
||||||
|
# assert_marker_human).
|
||||||
|
#
|
||||||
|
# Purity is what makes convergence testable rather than asserted. The rendered
|
||||||
|
# content is a function of (existing file, running version) ALONE — <now> is
|
||||||
|
# consulted only on the paths that were going to change anyway — so calling
|
||||||
|
# this twice with two DIFFERENT clock readings must produce byte-identical
|
||||||
|
# output. test/cli.sh pins exactly that, which is stronger than re-running the
|
||||||
|
# writer fast enough that the second matches by luck.
|
||||||
|
#
|
||||||
|
# Rule 1 — bootstrapped_* is FIRST-WRITE-WINS. Birth is pinned forever. If the
|
||||||
|
# file already carries a bootstrapped_at, both birth fields are preserved
|
||||||
|
# verbatim. Regenerating it as now() on every run would make every re-run a
|
||||||
|
# diff, which is the exact trap that keeping specs out of this file closed.
|
||||||
|
#
|
||||||
|
# Rule 2 — converged_* updates ONLY when the version actually differs.
|
||||||
|
# converged_at is "the time the converging version last changed", NOT the time
|
||||||
|
# of the last run. If it tracked every run it would be a clock, and a clock in
|
||||||
|
# a cmp-guarded file makes every re-run a fake change. So: compare the running
|
||||||
|
# version against the recorded converged_by; equal means the pair is already
|
||||||
|
# true and is copied through untouched.
|
||||||
|
#
|
||||||
|
# Under those two rules a re-run by the SAME rig renders byte-identical content
|
||||||
|
# and the cmp-guard stays silent, while a re-converge by a DIFFERENT rig
|
||||||
|
# renders a real diff — and the guard firing there is correct, not spurious. It
|
||||||
|
# was only ever the clock that was the fake change, never the version.
|
||||||
|
manifest_render() {
|
||||||
|
local path="$1" ver="$2" now="$3"
|
||||||
|
local b_by b_at c_by c_at
|
||||||
|
b_by="$(manifest_value "$path" bootstrapped_by)"
|
||||||
|
b_at="$(manifest_value "$path" bootstrapped_at)"
|
||||||
|
c_by="$(manifest_value "$path" converged_by)"
|
||||||
|
c_at="$(manifest_value "$path" converged_at)"
|
||||||
|
|
||||||
|
# Rule 1. bootstrapped_at is the field that decides, because it is the one
|
||||||
|
# that can never be reconstructed: a machine's birth version can at least be
|
||||||
|
# guessed at, its birth INSTANT cannot. So an existing at-stamp pins the
|
||||||
|
# pair, and a birth-stamp with no birth version records `unknown` rather
|
||||||
|
# than backfilling today's version as if it had always been there — a
|
||||||
|
# manifest that lies about which rig built the box is worse than one that
|
||||||
|
# admits it does not know.
|
||||||
|
if [ -z "$b_at" ]; then
|
||||||
|
b_by="$ver"; b_at="$now"
|
||||||
|
elif [ -z "$b_by" ]; then
|
||||||
|
b_by=unknown
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rule 2. The empty-at case is a one-time repair of a truncated file, not a
|
||||||
|
# clock: once written it satisfies the equality on every later run.
|
||||||
|
if [ "$c_by" != "$ver" ] || [ -z "$c_at" ]; then
|
||||||
|
c_by="$ver"; c_at="$now"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'schema=%s\n' "$MANIFEST_SCHEMA"
|
||||||
|
printf 'bootstrapped_by=%s\n' "$b_by"
|
||||||
|
printf 'bootstrapped_at=%s\n' "$b_at"
|
||||||
|
printf 'converged_by=%s\n' "$c_by"
|
||||||
|
printf 'converged_at=%s\n' "$c_at"
|
||||||
|
manifest_foreign "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_running_version <rig root> — the version that IS RUNNING, captured
|
||||||
|
# at run time from the tree's own VERSION file. NOT `rig --version` read back
|
||||||
|
# later: a machine outlives the rig that built it, so what is installed today
|
||||||
|
# answers a different question than what converged it. First line only — a
|
||||||
|
# stray second line would inject a bogus key into a key=value file.
|
||||||
|
manifest_running_version() {
|
||||||
|
local v=""
|
||||||
|
[ -r "$1/VERSION" ] && v="$(head -n1 "$1/VERSION")"
|
||||||
|
printf '%s' "${v:-unknown}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# manifest_stamp <version> — the writer. Renders, cmp-guards like every file
|
||||||
|
# rig converges, installs 0644 beside the role marker and the users ledger.
|
||||||
|
# Returns 0 when it CHANGED the file and 1 when the file was already current,
|
||||||
|
# so the caller owns the log line (and, under set -e, must call it in an `if`).
|
||||||
|
manifest_stamp() {
|
||||||
|
local path tmp rc
|
||||||
|
path="$(manifest_path)"
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
manifest_render "$path" "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp"
|
||||||
|
if cmp -s "$tmp" "$path" 2>/dev/null; then
|
||||||
|
rc=1
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "$path")"
|
||||||
|
install -m 0644 "$tmp" "$path"
|
||||||
|
rc=0
|
||||||
|
fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,8 @@ json_field() {
|
||||||
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true
|
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# json_string_array <file> <key> — the elements of the FIRST array named <key>,
|
# json_string_array <file> <key> — the elements of the array named <key> inside
|
||||||
# one per line, empty when the key is absent or the array is empty.
|
# the netmap's `Self` object, one per line; empty when Self or the key is absent.
|
||||||
#
|
#
|
||||||
# json_field's sibling for the one shape it cannot read: `.Self.Tags` from
|
# 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
|
# `tailscale status --json` is a JSON array, and bootstrap must assert on it to
|
||||||
|
|
@ -23,21 +23,44 @@ json_field() {
|
||||||
# not the tag rig requested. Same grep/sed spirit, same jq-free reason: a
|
# 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.
|
# rig-bootstrapped box has no jq and we will not install one to read one field.
|
||||||
#
|
#
|
||||||
# `tr -d '\n'` first, because tailscale pretty-prints its JSON and an array
|
# Scoped to Self, NOT document-global. The previous body took the first "Tags"
|
||||||
# spans lines — grep is line-oriented and would never see `[ ... ]` whole
|
# array anywhere in the file and justified it with Self-before-Peer field order.
|
||||||
# otherwise. `\[[^]]*\]` then captures the first flat array body for <key>
|
# That holds only when Self HAS tags: an untagged Self omits the key entirely
|
||||||
# (tag strings never contain `]`, so this is safe); the inner `grep -o` pulls
|
# (Go omitempty), so the match fell through into Peer and returned a PEER's tag
|
||||||
# every quoted token out of it, and `sed 1d` drops the key's own name — which
|
# — silently inverting both callers on any tailnet with a tagged node (#160).
|
||||||
# `"key":[...]` leads with — leaving just the elements.
|
|
||||||
#
|
#
|
||||||
# FIRST array wins by design, and the caller leans on it: `tailscale status
|
# Self is brace-counted rather than sliced to the next key: PeerStatus carries a
|
||||||
# --json` emits Self before Peer (Go struct field order, stable), so the first
|
# nested object (Location, a pointer with omitempty), which would end a naive
|
||||||
# "Tags" is the node's OWN, never a peer's. An absent key omits itself entirely
|
# slice early whenever it is present. Known limit of staying jq-free: a `{` or
|
||||||
# (Go's omitempty) rather than emitting `[]` — which is exactly the untagged,
|
# `}` inside a STRING value within Self would miscount — no PeerStatus string
|
||||||
# user-owned node bootstrap must catch. Never fails under `set -e`+pipefail: a
|
# field (hostnames, DNS names, OS, key strings) can contain one, so this is
|
||||||
# non-match is a fact to test for, like json_field, not a reason to die.
|
# 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.
|
||||||
json_string_array() {
|
json_string_array() {
|
||||||
tr -d '\n' < "$1" 2>/dev/null \
|
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" \
|
||||||
| grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \
|
| grep -o "\"$2\"[[:space:]]*:[[:space:]]*\[[^]]*\]" \
|
||||||
| head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true
|
| head -n1 | grep -o '"[^"]*"' | sed '1d; s/^"//; s/"$//' || true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,57 @@
|
||||||
# a hardening block is drift by construction, the same law that keeps rig's
|
# a hardening block is drift by construction, the same law that keeps rig's
|
||||||
# hands off Incus. Callers provide log/warn/die.
|
# hands off Incus. Callers provide log/warn/die.
|
||||||
|
|
||||||
# harden_sshd <human|server> — install the 00-rig.conf hardening drop-in,
|
# sshd_privsep_gap <status> <stderr> — true when a FAILED `sshd -t` failed for
|
||||||
|
# the missing privilege-separation directory rather than for anything in the
|
||||||
|
# config. Pure and sourceable (repo precedent: parse_users_file, deny_verdict)
|
||||||
|
# so the distinction is provable without root or a live sshd.
|
||||||
|
#
|
||||||
|
# `sshd -t` folds two questions into one exit code: is the merged config
|
||||||
|
# parseable, and is /run/sshd there. The second is not a fact about the config
|
||||||
|
# at all — /run is a tmpfs and /run/sshd is ssh.service's RuntimeDirectory,
|
||||||
|
# which systemd creates with that unit and removes when it stops, so the
|
||||||
|
# directory is legitimately absent under socket activation (ssh.socket, the
|
||||||
|
# default on current Debian/Ubuntu) on a box whose SSH door is serving
|
||||||
|
# connections perfectly well. Reading that as a config refusal aborted
|
||||||
|
# bootstrap with a verdict sshd never reached (#92).
|
||||||
|
#
|
||||||
|
# The STATUS is the verdict; this text match only classifies a failure. A
|
||||||
|
# passing `sshd -t` is never diverted here, whatever its output happens to say.
|
||||||
|
sshd_privsep_gap() {
|
||||||
|
[ "$1" -ne 0 ] || return 1
|
||||||
|
case "$2" in
|
||||||
|
*"Missing privilege separation directory"*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# sshd_config_ok — validate the merged config, repairing a privsep gap once and
|
||||||
|
# retesting. Returns sshd's verdict and leaves sshd's own stderr in $sshd_err
|
||||||
|
# for the caller's refusal message: a message that asserts a cause must carry
|
||||||
|
# the evidence for it, or the operator greps /etc/ssh blind (#92).
|
||||||
|
#
|
||||||
|
# The repair is `install -d`, which is idempotent and creates exactly what
|
||||||
|
# systemd would. It does not survive a reboot and is not meant to — by then the
|
||||||
|
# ssh unit has recreated it.
|
||||||
|
sshd_config_ok() {
|
||||||
|
local rc
|
||||||
|
sshd_err="$(sshd -t 2>&1)"; rc=$?
|
||||||
|
if sshd_privsep_gap "$rc" "$sshd_err"; then
|
||||||
|
install -d -m 0755 /run/sshd
|
||||||
|
sshd_err="$(sshd -t 2>&1)"; rc=$?
|
||||||
|
fi
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
# harden_sshd <closed|open> — install the 00-rig.conf hardening drop-in,
|
||||||
# validate the merged config before touching the daemon, restart only when the
|
# validate the merged config before touching the daemon, restart only when the
|
||||||
# drop-in actually changed, and assert the EFFECTIVE config (sshd -T), with the
|
# drop-in actually changed, and assert the EFFECTIVE config (sshd -T), with the
|
||||||
# permitrootlogin acceptance gated on the class passed in.
|
# permitrootlogin acceptance gated on the ROOT-DOOR policy passed in (#77; this
|
||||||
|
# argument was <human|server> before the trait was renamed for what it decides).
|
||||||
|
# Callers pass their own trait value, never a marker read: bootstrap knows its
|
||||||
|
# root-door from its flags, and the staging tenant is open by construction.
|
||||||
harden_sshd() {
|
harden_sshd() {
|
||||||
local class="$1"
|
local root_door="$1"
|
||||||
local dropin=/etc/ssh/sshd_config.d/00-rig.conf
|
local dropin=/etc/ssh/sshd_config.d/00-rig.conf
|
||||||
local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf
|
local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf
|
||||||
local tmp backup eff
|
local tmp backup eff
|
||||||
|
|
@ -39,10 +84,10 @@ EOF
|
||||||
# leaves no listener and no way back in. `sshd -t` parses everything sshd
|
# leaves no listener and no way back in. `sshd -t` parses everything sshd
|
||||||
# would parse — our drop-in, cloud-init's, and any third-party file — so a
|
# would parse — our drop-in, cloud-init's, and any third-party file — so a
|
||||||
# broken neighbour is caught here rather than after the door has shut.
|
# broken neighbour is caught here rather than after the door has shut.
|
||||||
if ! sshd -t 2>/dev/null; then
|
if ! sshd_config_ok; then
|
||||||
if [ -n "$backup" ]; then cp -a "$backup" "$dropin"; else rm -f "$dropin"; fi
|
if [ -n "$backup" ]; then cp -a "$backup" "$dropin"; else rm -f "$dropin"; fi
|
||||||
rm -f "$tmp" "$backup"
|
rm -f "$tmp" "$backup"
|
||||||
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
|
die "sshd rejects the merged config; drop-in rolled back, daemon untouched: $sshd_err"
|
||||||
fi
|
fi
|
||||||
rm -f "$backup"
|
rm -f "$backup"
|
||||||
|
|
||||||
|
|
@ -59,22 +104,22 @@ EOF
|
||||||
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
|
||||||
echo "$eff" | grep -qx 'passwordauthentication no' \
|
echo "$eff" | grep -qx 'passwordauthentication no' \
|
||||||
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${dropin}; check ls /etc/ssh/sshd_config.d/"
|
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${dropin}; check ls /etc/ssh/sshd_config.d/"
|
||||||
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
|
# The permitrootlogin acceptance is ROOT-DOOR-gated, because `no` means
|
||||||
# things on the two classes. class=human: `no` is the post-`rig users
|
# opposite things on the two policies. root-door=closed: `no` is the post-`rig
|
||||||
# close-root` state — strictly harder than the prohibit-password this function
|
# users close-root` state — strictly harder than the prohibit-password this
|
||||||
# installs. Hardening must never read a closed door as a broken one, and it
|
# function installs. Hardening must never read a closed door as a broken one,
|
||||||
# cannot reopen one either: by first-wins its own drop-in loses to
|
# and it cannot reopen one either: by first-wins its own drop-in loses to
|
||||||
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
|
# 00-rig-users.conf. root-door=open: root SSH is the control plane's automation
|
||||||
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
|
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
|
||||||
# management silently dead, and the likely culprit is a drop-in left over from
|
# management silently dead, and the likely culprit is a drop-in left over from
|
||||||
# a former class=human life on a repurposed box. rig can DETECT that but must
|
# a former root-door=closed life on a repurposed box. rig can DETECT that but
|
||||||
# not FIX it: silently reopening a root door is worse than a loud stop, so —
|
# must not FIX it: silently reopening a root door is worse than a loud stop, so
|
||||||
# same doctrine as the tag checks — detect, refuse, and name the repair.
|
# — same doctrine as the tag checks — detect, refuse, and name the repair.
|
||||||
if [ "$class" = "human" ]; then
|
if [ "$root_door" = "closed" ]; then
|
||||||
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|
||||||
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||||
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
|
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
|
||||||
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
|
die "sshd resolves permitrootlogin=no, but this is a root-door=open box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former root-door=closed life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
|
||||||
else
|
else
|
||||||
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|
||||||
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
|
||||||
|
|
|
||||||
437
commands/lib/templates.sh
Normal file
437
commands/lib/templates.sh
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
#!/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 FOUR 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)
|
||||||
|
# RIG_TEMPLATES_HOST which FORGE that repo lives on (default
|
||||||
|
# https://github.com) — see templates_archive_urls
|
||||||
|
# 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 forge the registry lives on. GitHub by default, so every existing caller
|
||||||
|
# is byte-unchanged; overridable because a self-hosted Forgejo is a different
|
||||||
|
# origin AND a different URL grammar (#109).
|
||||||
|
RIG_TEMPLATES_HOST_DEFAULT="https://github.com"
|
||||||
|
|
||||||
|
# templates_archive_urls <host> <repo> <ref> — the source-tarball candidates
|
||||||
|
# for <ref>, in order, one per line. A PURE function (no network, no globals):
|
||||||
|
# test/cli.sh lifts it and drives it against both forges, the resolve_latest_tag
|
||||||
|
# precedent in install.sh.
|
||||||
|
#
|
||||||
|
# The two forges are not URL-compatible, and the difference is not cosmetic:
|
||||||
|
#
|
||||||
|
# GitHub three forms, refs/tags FIRST so a tag always outranks a branch
|
||||||
|
# sharing its name (a pin must win), refs/heads as the fallback
|
||||||
|
# that keeps a branch ref working, then the bare form a commit SHA
|
||||||
|
# downloads through.
|
||||||
|
# Forgejo ONE form. /archive/<ref>.tar.gz resolves tags, branches and SHAs
|
||||||
|
# alike, and the refs/{tags,heads}/ paths are not served at all —
|
||||||
|
# emitting them would mean two guaranteed 404s ahead of every fetch
|
||||||
|
# and a failure message listing URLs that never could have worked.
|
||||||
|
#
|
||||||
|
# Measured against forgejo.heavyduty.builders, not inferred from the docs.
|
||||||
|
templates_archive_urls() {
|
||||||
|
local host="${1%/}" repo="$2" ref="$3"
|
||||||
|
case "$host" in
|
||||||
|
https://github.com|http://github.com|*//github.com)
|
||||||
|
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref"
|
||||||
|
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref"
|
||||||
|
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
||||||
|
*)
|
||||||
|
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# The host rides every non-snapshot description: a registry served from the
|
||||||
|
# wrong FORGE fails exactly like a misspelled repo, and naming only the repo
|
||||||
|
# would send the reader hunting for a typo that is not there (#109).
|
||||||
|
templates_source_desc() {
|
||||||
|
local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
|
||||||
|
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%s' \
|
||||||
|
"${host%/}" \
|
||||||
|
"${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 come from
|
||||||
|
# templates_archive_urls, which is forge-aware — see there for why the two
|
||||||
|
# forges cannot share one list.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# On a SELF-HOSTED forge there is a third way to fail that reads exactly like
|
||||||
|
# the other two, so the refusal names it: an instance with
|
||||||
|
# REQUIRE_SIGNIN_VIEW=true serves 404 for public repos to anonymous callers —
|
||||||
|
# the same status a wrong ref gets. A mint holds no credentials and never
|
||||||
|
# will, so such an instance cannot host a registry until it serves public
|
||||||
|
# repos anonymously (#109).
|
||||||
|
TEMPLATES_TMP=""
|
||||||
|
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
||||||
|
REGISTRY_DIR=""
|
||||||
|
templates_resolve() {
|
||||||
|
local repo ref host 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}"
|
||||||
|
host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
|
||||||
|
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)"
|
||||||
|
while IFS= read -r url; do
|
||||||
|
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
||||||
|
done < <(templates_archive_urls "$host" "$repo" "$ref")
|
||||||
|
if [ -z "$got" ]; then
|
||||||
|
printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2
|
||||||
|
templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&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
|
||||||
|
case "${host%/}" in
|
||||||
|
https://github.com|http://github.com) ;;
|
||||||
|
*) printf 'on a self-hosted forge, check the instance serves PUBLIC repos to anonymous callers too: Forgejo with REQUIRE_SIGNIN_VIEW=true answers 404 for a public repo, which is indistinguishable from a wrong ref above (set FORGEJO__service__REQUIRE_SIGNIN_VIEW=false).\n' >&2 ;;
|
||||||
|
esac
|
||||||
|
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 source archive holds exactly one top-level directory — assert that
|
||||||
|
# SHAPE, never the name, because the name is the forge's choice and the two
|
||||||
|
# disagree: GitHub writes <repo>-<ref> (slashes flattened), Forgejo writes
|
||||||
|
# bare <repo>. Globbing for the shape is what makes this line survive a
|
||||||
|
# forge swap untouched.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Shared parameters for the box TENANT roles (claude, codex, grok, staging) —
|
|
||||||
# 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; staging keeps box#69's `ops`.
|
|
||||||
tenant_user() {
|
|
||||||
case "$1" in
|
|
||||||
claude) printf 'claude' ;;
|
|
||||||
codex) printf 'codex' ;;
|
|
||||||
grok) printf 'grok' ;;
|
|
||||||
staging) 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; staging has no
|
|
||||||
# agent and no context file (return 1).
|
|
||||||
tenant_context_path() {
|
|
||||||
case "$1" in
|
|
||||||
claude) printf '%s/.claude/CLAUDE.md' "$2" ;;
|
|
||||||
codex) printf '%s/.codex/AGENTS.md' "$2" ;;
|
|
||||||
grok) 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 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)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -32,8 +32,8 @@
|
||||||
# fix cycle, not one round-trip per line.
|
# fix cycle, not one round-trip per line.
|
||||||
#
|
#
|
||||||
# Refusals: unknown role (the valid set is named), differing roles across one
|
# Refusals: unknown role (the valid set is named), differing roles across one
|
||||||
# user's lines, root as username (root's keys are class policy's business, not
|
# user's lines, root as username (root's keys are root-door policy's business,
|
||||||
# this file's), malformed line (fewer than 3 fields, or a key field that does
|
# not this file's), malformed line (fewer than 3 fields, or a key field that does
|
||||||
# not start with an SSH key type and is not exactly '@root'), '@root' with
|
# not start with an SSH key type and is not exactly '@root'), '@root' with
|
||||||
# trailing material (the token IS the whole field), invalid username (the
|
# trailing material (the token IS the whole field), invalid username (the
|
||||||
# charset below — '|' would corrupt this parser's own delimited stream, a
|
# charset below — '|' would corrupt this parser's own delimited stream, a
|
||||||
|
|
@ -71,7 +71,7 @@ parse_users_file() {
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if [ "$u" = "root" ]; then
|
if [ "$u" = "root" ]; then
|
||||||
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is class policy")
|
errs+=("line $n: 'root' is not a rig-managed user — this file names operators; root SSH's fate is root-door policy")
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
ok=1
|
ok=1
|
||||||
|
|
@ -103,21 +103,99 @@ parse_users_file() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# read_role_marker <path> — the marker line bootstrap wrote
|
# read_role_marker <path> — the marker line bootstrap wrote
|
||||||
# (`role=... class=... host=... join=...`), or nothing when absent. NO policy
|
# (`role=... root-door=... host=... join=...`), or nothing when absent. NO
|
||||||
# here: what an absent marker or a given class MEANS is each caller's call
|
# policy here: what an absent marker or a given trait MEANS is each caller's
|
||||||
# (apply notes it, close-root refuses on it) — this reader only reads.
|
# call (apply notes it, close-root refuses on it) — this reader only reads.
|
||||||
read_role_marker() {
|
read_role_marker() {
|
||||||
[ -r "$1" ] || return 0
|
[ -r "$1" ] || return 0
|
||||||
head -n1 "$1"
|
head -n1 "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# assert_marker_human <marker_path> — close-root's marker gate: return 0,
|
# root_door_of <marker line> — resolve the root-door trait from a marker LINE,
|
||||||
# silently, only when the marker says class=human; otherwise print the refusal
|
# reading both the current `root-door=` vocabulary and the `class=` one it
|
||||||
# reason on stdout and return 1 (the caller wraps it in its own die). The
|
# replaced (#77). Prints exactly one of:
|
||||||
# policy is a pure lib function on purpose: the CLI path sits behind the root
|
#
|
||||||
# check, so the harness proves every refusal HERE, against fixture markers,
|
# closed the root SSH door is meant to shut once named operators exist
|
||||||
# non-root (repo precedent: parse_users_file, assert_runner_repo).
|
# (`rig users close-root`) — was class=human
|
||||||
assert_marker_human() {
|
# open root SSH stays as the control plane's automation door
|
||||||
|
# — was class=server
|
||||||
|
# conflict the marker names BOTH vocabularies and they DISAGREE
|
||||||
|
# (empty) the marker names neither, or names one with a value that is
|
||||||
|
# not in its value set
|
||||||
|
#
|
||||||
|
# Text->text and total, so the harness proves every arm off a literal string
|
||||||
|
# (repo precedent: deny_verdict, group_allow_verdict). Callers turn a verdict
|
||||||
|
# into policy; this function has none.
|
||||||
|
#
|
||||||
|
# WHY THE COMPAT READ IS NOT OPTIONAL, and why it is here rather than at each
|
||||||
|
# call site. #77 renamed the trait because `class=human|server` was named for
|
||||||
|
# who lives on the box while what it decides is whether root SSH stays open as
|
||||||
|
# the control plane's automation door — the axis that made `dev-server` a
|
||||||
|
# `class=human` box, a suffix and a trait that read as a contradiction. But
|
||||||
|
# unlike #76's role rename, this field is not informational: it is written into
|
||||||
|
# /etc/rig/role and read back on live machines, where it gates `rig users
|
||||||
|
# close-root`. Every box bootstrapped before this change carries `class=` and
|
||||||
|
# carries it FOREVER, until someone re-bootstraps it — there is no migration
|
||||||
|
# step that reaches a fleet. So dropping the old read breaks in both directions
|
||||||
|
# at once, and both are incidents: a machine whose door should close stops
|
||||||
|
# being able to close it (close-root refuses on a marker it no longer
|
||||||
|
# understands), and — through bootstrap-tenant's machine-marker guard, which
|
||||||
|
# used `class=` as its "this is a machine" detector — a real fleet box stops
|
||||||
|
# looking like a machine at all and a tenant converge will happily clobber it.
|
||||||
|
# One resolver, consulted everywhere the trait is read, is what keeps those
|
||||||
|
# two readings from drifting apart.
|
||||||
|
#
|
||||||
|
# BOTH FIELDS PRESENT is a state bootstrap never writes — it writes one line,
|
||||||
|
# fresh, in the new vocabulary only — so a marker carrying both was
|
||||||
|
# hand-edited, and the two answers are a question about intent that rig cannot
|
||||||
|
# settle. Agreement is taken (it says one thing twice); disagreement resolves
|
||||||
|
# to `conflict` and every caller fails CLOSED on it, because the alternative is
|
||||||
|
# picking a winner between two equally-authored claims about a root door. The
|
||||||
|
# repair is the same one the rest of the marker family names: re-run bootstrap,
|
||||||
|
# which rewrites the line whole.
|
||||||
|
#
|
||||||
|
# NEITHER FIELD PRESENT resolves empty and is likewise fail-closed everywhere,
|
||||||
|
# unchanged from before: a marker that names no door policy cannot authorize
|
||||||
|
# shutting a door.
|
||||||
|
root_door_of() {
|
||||||
|
local marker="$1" new="" old="" padded
|
||||||
|
# FIELD-ANCHORED, not substring. The marker is one line of space-separated
|
||||||
|
# `key=value` fields (bootstrap writes it with a single printf), so padding
|
||||||
|
# both ends and matching whole fields is exact. Unanchored patterns matched
|
||||||
|
# any value that EXTENDS a real one: `root-door=closedish` resolved as
|
||||||
|
# `closed` and passed close-root's gate — the one arm that authorizes an
|
||||||
|
# irreversible act — and `class=humanoid` did the same through the compat
|
||||||
|
# arm. That contradicted this function's own promise above, that a value
|
||||||
|
# outside the set resolves empty and fails closed. Only reachable by hand
|
||||||
|
# editing, but this is the function every consumer trusts, so it owes them
|
||||||
|
# exactness rather than "close enough" (found in review on #77).
|
||||||
|
# Whitespace is normalised first so a hand-edit using tabs or double spaces
|
||||||
|
# is read the same way rather than silently failing to match.
|
||||||
|
padded=" ${marker//[[:space:]]/ } "
|
||||||
|
case "$padded" in
|
||||||
|
*" root-door=closed "*) new=closed ;;
|
||||||
|
*" root-door=open "*) new=open ;;
|
||||||
|
esac
|
||||||
|
case "$padded" in
|
||||||
|
*" class=human "*) old=closed ;;
|
||||||
|
*" class=server "*) old=open ;;
|
||||||
|
esac
|
||||||
|
if [ -n "$new" ] && [ -n "$old" ] && [ "$new" != "$old" ]; then
|
||||||
|
printf 'conflict'; return 0
|
||||||
|
fi
|
||||||
|
# New wins where both are readable and agree; the old field answers alone on
|
||||||
|
# every marker written before #77, which is the whole point.
|
||||||
|
printf '%s' "${new:-$old}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# assert_marker_closes_root <marker_path> — close-root's marker gate: return 0,
|
||||||
|
# silently, only when the marker's root-door trait resolves to `closed`;
|
||||||
|
# otherwise print the refusal reason on stdout and return 1 (the caller wraps
|
||||||
|
# it in its own die). The policy is a pure lib function on purpose: the CLI
|
||||||
|
# path sits behind the root check, so the harness proves every refusal HERE,
|
||||||
|
# against fixture markers, non-root (repo precedent: parse_users_file,
|
||||||
|
# assert_runner_repo).
|
||||||
|
assert_marker_closes_root() {
|
||||||
local marker
|
local marker
|
||||||
marker="$(read_role_marker "$1")"
|
marker="$(read_role_marker "$1")"
|
||||||
if [ -z "$marker" ]; then
|
if [ -z "$marker" ]; then
|
||||||
|
|
@ -126,22 +204,76 @@ assert_marker_human() {
|
||||||
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
|
printf '%s\n' "no /etc/rig/role marker: re-run rig bootstrap so this box knows what it is; refusing to shut the root door blind"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
case "$marker" in
|
case "$(root_door_of "$marker")" in
|
||||||
*class=human*) return 0 ;;
|
closed) return 0 ;;
|
||||||
*class=server*)
|
open)
|
||||||
# Root SSH on a server IS the control plane's (Coolify's) automation
|
# Root SSH on such a box IS the control plane's (Coolify's) automation
|
||||||
# identity — closing it severs fleet management. No --force exists.
|
# identity — closing it severs fleet management. No --force exists.
|
||||||
# Deliberately per-CLASS, not per-role: #17's original table let the
|
# Deliberately keyed on the DOOR, not on the role: #17's original table
|
||||||
# runner role close root ("no Coolify involved"), but the class model
|
# let the runner role close root ("no Coolify involved"), but the trait
|
||||||
# (#26) supersedes that — every server-class box, runner included, is
|
# model (#26) supersedes that — every root-door=open box, runner
|
||||||
# an automation identity whose management plane is root SSH, and rig
|
# included, is an automation identity whose management plane is root
|
||||||
# itself converges through that door. A CI box someone administers
|
# SSH, and rig itself converges through that door. A CI box someone
|
||||||
# like a human machine is class=human at bootstrap, not an exception
|
# administers like a human machine is --root-door closed at bootstrap,
|
||||||
# carved out here.
|
# not an exception carved out here.
|
||||||
printf '%s\n' "class=server: root here is the control plane's automation identity — closing it severs fleet management. Every server-class box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --class human at bootstrap, not an exception here"
|
printf '%s\n' "root-door=open: root here is the control plane's automation identity — closing it severs fleet management. Every root-door=open box (runner included) keeps root deliberately: it is an automation identity, and root SSH is its management plane; a box meant to be administered like a human machine is --root-door closed at bootstrap, not an exception here"
|
||||||
|
return 1 ;;
|
||||||
|
conflict)
|
||||||
|
# Hand-edited into naming both vocabularies, disagreeing. Fail closed:
|
||||||
|
# see root_door_of's header for why rig refuses to pick a winner.
|
||||||
|
printf '%s\n' "marker names both root-door= and the pre-#77 class= and they disagree (${marker}): rig will not pick a winner between two claims about a root door — re-run rig bootstrap to rewrite the marker, and refusing to shut the root door meanwhile"
|
||||||
return 1 ;;
|
return 1 ;;
|
||||||
*)
|
*)
|
||||||
printf '%s\n' "marker names no class (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
printf '%s\n' "marker names no root-door policy (${marker}): re-run rig bootstrap; refusing to shut the root door blind"
|
||||||
|
return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# assert_marker_hosts_vms <marker_path> — the box role's gate: return 0,
|
||||||
|
# silently, only when the marker says host=yes; otherwise print the reason on
|
||||||
|
# stdout and return 1 (the caller decides whether that is a warn or a die).
|
||||||
|
# Same shape and same reason as assert_marker_closes_root above: the policy is a
|
||||||
|
# pure marker->verdict function so the harness can prove every arm against
|
||||||
|
# fixture markers, non-root, while the CLI path sits behind the root check.
|
||||||
|
#
|
||||||
|
# The MARKER decides, not the machine (#58). Apply used to consult host= only
|
||||||
|
# when group incus was ABSENT — so a box whose marker said host=no but which
|
||||||
|
# happened to carry the group (box's setup-host ran, then the box was
|
||||||
|
# re-bootstrapped with different traits, or given --host no) handed box-role
|
||||||
|
# users a bare `usermod -aG incus`: the socket with no tier behind it. That is
|
||||||
|
# the worst of the three states, because incus-user answers a socket it is
|
||||||
|
# given by lazily creating an UNHARDENED project for whoever opens it —
|
||||||
|
# incusbr-<uid>, NAT on v4 and v6, no ACL, no dns.mode=none, no port
|
||||||
|
# isolation. The alternative considered was to let the group's presence win
|
||||||
|
# and converge anyway with a warning, on the theory that a real incus install
|
||||||
|
# is evidence the machine really does host VMs. It was rejected: the marker is
|
||||||
|
# what this box CLAIMS to be, and every other host= decision in the family
|
||||||
|
# already treats it as authoritative rather than as a hint to be second-
|
||||||
|
# guessed by probing the machine. A box that lies about itself gets its lie
|
||||||
|
# taken seriously and gets told, loudly, to re-run bootstrap — which is a
|
||||||
|
# cheap repair — instead of rig quietly provisioning a VM-host tier on a box
|
||||||
|
# that does not claim to be one. Deciding from the marker alone also means the
|
||||||
|
# verdict is the SAME whether or not the group exists, which is the property
|
||||||
|
# that was missing.
|
||||||
|
#
|
||||||
|
# No marker, and a marker with no host= trait, both land here as "not a VM
|
||||||
|
# host" for the same fail-closed reason: rig cannot tell an unbootstrapped box
|
||||||
|
# from a repurposed one, and the safe error is withholding VM access that can
|
||||||
|
# be granted by a re-run, not granting VM access that cannot be un-granted
|
||||||
|
# once a project exists under it.
|
||||||
|
assert_marker_hosts_vms() {
|
||||||
|
local marker
|
||||||
|
marker="$(read_role_marker "$1")"
|
||||||
|
case "$marker" in
|
||||||
|
*host=yes*) return 0 ;;
|
||||||
|
*host=no*)
|
||||||
|
printf '%s\n' "this box does not host VMs (host=no)"
|
||||||
|
return 1 ;;
|
||||||
|
"")
|
||||||
|
printf '%s\n' "no /etc/rig/role marker, so this box names no host= trait — re-run rig bootstrap so it knows whether it hosts VMs"
|
||||||
|
return 1 ;;
|
||||||
|
*)
|
||||||
|
printf '%s\n' "the role marker names no host= trait (${marker}) — re-run rig bootstrap so this box knows whether it hosts VMs"
|
||||||
return 1 ;;
|
return 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
commands/manifest.sh
Executable file
88
commands/manifest.sh
Executable file
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig manifest — print this machine's provenance record, /etc/rig/manifest
|
||||||
|
# (#61). Reads only: this command NEVER writes the file. `rig bootstrap` is its
|
||||||
|
# single writer, and it stamps it as its own last durable act.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/manifest.sh
|
||||||
|
. "$HERE/lib/manifest.sh" # manifest_path / manifest_value
|
||||||
|
|
||||||
|
die() { printf 'rig-manifest: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
usage: rig manifest [<key>]
|
||||||
|
|
||||||
|
Print /etc/rig/manifest — which rig converged this machine, and when. With a
|
||||||
|
key, print that key's value alone, unquoted and newline-terminated, so a shell
|
||||||
|
caller does not re-parse the file:
|
||||||
|
|
||||||
|
rig manifest schema=1
|
||||||
|
bootstrapped_by=0.2.0
|
||||||
|
bootstrapped_at=2026-07-19T14:24:51Z
|
||||||
|
converged_by=0.4.0
|
||||||
|
converged_at=2026-08-02T09:11:03Z
|
||||||
|
|
||||||
|
rig manifest converged_by 0.4.0
|
||||||
|
|
||||||
|
Both pairs are provenance, both immutable in the sense that matters: BIRTH —
|
||||||
|
the rig that first converged this machine, pinned forever — and LATEST — the
|
||||||
|
newest rig to have converged it. On a fresh machine the two are equal. Ask the
|
||||||
|
second pair "is this machine converged by something ancient?"; ask the first
|
||||||
|
"what built it".
|
||||||
|
|
||||||
|
The version recorded is the one that RAN. `rig --version` reports the tree
|
||||||
|
installed NOW, which after an upgrade is a different question — a machine
|
||||||
|
outlives the rig that built it.
|
||||||
|
|
||||||
|
Reads only. Needs no root (the file is 0644), no network, and works on a
|
||||||
|
machine whose rig has since been upgraded or removed. Specs — cores, RAM,
|
||||||
|
disk, kernel — are NOT here: they are observed rather than decided, so they go
|
||||||
|
stale on their own and belong to `rig platform`, which computes them fresh and
|
||||||
|
stores nothing.
|
||||||
|
|
||||||
|
Exit 1 when there is no manifest — a machine converged before rig wrote one,
|
||||||
|
or never converged at all.
|
||||||
|
|
||||||
|
RIG_MANIFEST override the path (default /etc/rig/manifest)
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
KEY=""
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
-*)
|
||||||
|
printf 'rig-manifest: unknown option: %s\n' "$a" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ -n "$KEY" ]; then
|
||||||
|
printf 'rig-manifest: manifest takes at most one key\n' >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
KEY="$a"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
MPATH="$(manifest_path)"
|
||||||
|
[ -r "$MPATH" ] || die "no manifest at $MPATH — this machine has not been converged by a rig that writes one (rig bootstrap writes it)"
|
||||||
|
|
||||||
|
if [ -z "$KEY" ]; then
|
||||||
|
cat "$MPATH"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A key that is absent and a key whose value is empty are different answers to
|
||||||
|
# a shell caller, and $(...) collapses both to "". So the ABSENCE is the exit
|
||||||
|
# code, and only a present key ever prints — a caller reading `rig manifest
|
||||||
|
# converged_by` into a variable can trust that an empty result it accepted was
|
||||||
|
# a real empty value, not a missing key.
|
||||||
|
manifest_has "$MPATH" "$KEY" \
|
||||||
|
|| die "no such key: $KEY (keys present: $(cut -d= -f1 "$MPATH" | tr '\n' ' '))"
|
||||||
|
manifest_value "$MPATH" "$KEY"
|
||||||
|
echo
|
||||||
268
commands/platform.sh
Executable file
268
commands/platform.sh
Executable file
|
|
@ -0,0 +1,268 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# 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
|
||||||
|
# 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
|
||||||
|
# refreshing one on every run would collide with bootstrap's convergence
|
||||||
|
# contract ("safe to re-run; a second run changes nothing").
|
||||||
|
#
|
||||||
|
# The corollary is worth having deliberately: this runs on a machine rig has
|
||||||
|
# never converged, and needs no root. It answers "what should I converge this
|
||||||
|
# into?", not only "what did I converge this into?".
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
|
. "$HERE/lib/users-config.sh" # read_role_marker — one reader of /etc/rig/role
|
||||||
|
|
||||||
|
die() { printf 'rig-platform: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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
|
||||||
|
the provenance block reads 'not bootstrapped'.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- args ------------------------------------------------------------------
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown flag: $1" 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# One aligned column for every line, so the output diffs cleanly across a
|
||||||
|
# fleet and reads as one table rather than a log.
|
||||||
|
field() { printf '%-10s %s\n' "$1" "$2"; }
|
||||||
|
|
||||||
|
# --- hostname ---------------------------------------------------------------
|
||||||
|
# uname -n is the coreutils fallback: `hostname` lives in its own package and a
|
||||||
|
# minimal image may not carry it, and this command's whole point is running
|
||||||
|
# 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.
|
||||||
|
# Every site in this tree sources it in a SUBSHELL instead (bootstrap.sh:305,
|
||||||
|
# bootstrap-tenant.sh:126, runner-install.sh:88, db.sh:52,
|
||||||
|
# coolify-backup-install.sh:88), and test/cli.sh greps commands/ to keep it
|
||||||
|
# that way. Follow the form verbatim.
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
OS="$(. /etc/os-release && printf '%s %s' "${NAME:-}" "${VERSION:-${VERSION_ID:-}}")"
|
||||||
|
else
|
||||||
|
OS=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- kernel -----------------------------------------------------------------
|
||||||
|
KERNEL="$(uname -r) ($(uname -m))"
|
||||||
|
|
||||||
|
# --- CPU --------------------------------------------------------------------
|
||||||
|
# 'model name' is x86's spelling; arm64 /proc/cpuinfo has no such field, so an
|
||||||
|
# unnamed CPU still reports its core count rather than nothing at all.
|
||||||
|
CPU_MODEL="$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo 2>/dev/null || true)"
|
||||||
|
CORES="$(nproc 2>/dev/null || true)"
|
||||||
|
|
||||||
|
# --- memory -----------------------------------------------------------------
|
||||||
|
# /proc/meminfo is in kB. MemAvailable is the kernel's own estimate of what a
|
||||||
|
# new workload could claim (MemFree undercounts badly, reclaimable cache being
|
||||||
|
# most of a busy box's RAM); it predates every kernel rig targets, but degrade
|
||||||
|
# rather than print a wrong number if it is missing.
|
||||||
|
mem_kb() { awk -v k="$1" '$1 == k":" {print $2; exit}' /proc/meminfo 2>/dev/null || true; }
|
||||||
|
MEM_TOTAL_KB="$(mem_kb MemTotal)"
|
||||||
|
MEM_AVAIL_KB="$(mem_kb MemAvailable)"
|
||||||
|
human_kb() { # kB -> IEC, matching df's units below
|
||||||
|
[ -n "${1:-}" ] || { printf 'unknown'; return 0; }
|
||||||
|
numfmt --to=iec-i "$(( $1 * 1024 ))" 2>/dev/null || printf '%s kB' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- disk -------------------------------------------------------------------
|
||||||
|
# -P is the one-line-per-filesystem guarantee (a long device name otherwise
|
||||||
|
# wraps and breaks field positions); -B1 gives bytes, so numfmt renders the
|
||||||
|
# same IEC units as memory above instead of df's own bare 'G'.
|
||||||
|
DISK_TOTAL="" DISK_FREE=""
|
||||||
|
if DF="$(df -PB1 / 2>/dev/null)"; then
|
||||||
|
DISK_TOTAL="$(printf '%s\n' "$DF" | awk 'NR==2 {print $2}')"
|
||||||
|
DISK_FREE="$(printf '%s\n' "$DF" | awk 'NR==2 {print $4}')"
|
||||||
|
fi
|
||||||
|
human_b() { # bytes -> IEC; falls back like human_kb rather than to 'unknown'
|
||||||
|
[ -n "${1:-}" ] || { printf 'unknown'; return 0; }
|
||||||
|
numfmt --to=iec-i "$1" 2>/dev/null || printf '%s B' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- virtualization ---------------------------------------------------------
|
||||||
|
# THE set -e TRAP: systemd-detect-virt exits NON-ZERO on bare metal while
|
||||||
|
# printing 'none'. That is a normal, correct answer — without the `|| true` a
|
||||||
|
# bare-metal machine would turn this whole command into a failed run. The
|
||||||
|
# substitution also swallows the binary being absent entirely (a non-systemd
|
||||||
|
# box), which lands as 'unknown'.
|
||||||
|
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)}"
|
||||||
|
field MEMORY "$(human_kb "$MEM_TOTAL_KB") total, $(human_kb "$MEM_AVAIL_KB") available"
|
||||||
|
field DISK "$(human_b "$DISK_TOTAL") total, $(human_b "$DISK_FREE") free on /"
|
||||||
|
field VIRT "${VIRT:-unknown}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# --- provenance: READ, never written ----------------------------------------
|
||||||
|
# The complementary half of the answer — which rig, and when — is decided
|
||||||
|
# rather than observed, so unlike everything above it IS stored. rig writes it
|
||||||
|
# during bootstrap; this command only ever reads it.
|
||||||
|
#
|
||||||
|
# /etc/rig/manifest is #61 and is NOT implemented yet, so on every machine in
|
||||||
|
# existence today this block reads 'not bootstrapped'. That degradation is the
|
||||||
|
# point: the two features are independent and neither blocks the other. The
|
||||||
|
# parse is the flat key=value shape the manifest is specified to use — the
|
||||||
|
# same jq-free shape /etc/rig/users and /etc/rig/role already use, parseable
|
||||||
|
# with `read` on a box that has no YAML parser.
|
||||||
|
#
|
||||||
|
# RIG_MANIFEST / RIG_ROLE_MARKER override the paths so the harness can drive
|
||||||
|
# both the present and the absent case against fixtures, non-root, without a
|
||||||
|
# real marker on the machine running the tests (repo precedent: the
|
||||||
|
# RIG_ROLE_MARKER gate in bin/rig, install.sh and users-close-root.sh).
|
||||||
|
MANIFEST="${RIG_MANIFEST:-/etc/rig/manifest}"
|
||||||
|
MARKER="${RIG_ROLE_MARKER:-/etc/rig/role}"
|
||||||
|
|
||||||
|
manifest_field() { # $1 = key — empty when absent, unreadable or unset
|
||||||
|
local k v
|
||||||
|
[ -r "$MANIFEST" ] || return 0
|
||||||
|
# `|| [ -n "$k" ]` so a manifest whose last line lacks a trailing newline
|
||||||
|
# still yields that line: read returns 1 at EOF even having filled k/v.
|
||||||
|
# Same guard parse_users_file uses (lib/users-config.sh:47) — #61's writer
|
||||||
|
# should not have to know whether this reader tolerates a missing \n.
|
||||||
|
while IFS='=' read -r k v || [ -n "$k" ]; do
|
||||||
|
[ "$k" = "$1" ] || continue
|
||||||
|
printf '%s\n' "$v"
|
||||||
|
return 0
|
||||||
|
done < "$MANIFEST"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '%s\n' "PROVENANCE"
|
||||||
|
# Keys are #61's documented schema verbatim — schema/bootstrapped_by/
|
||||||
|
# bootstrapped_at/converged_by/converged_at — NOT invented ones. #61 keeps
|
||||||
|
# birth and latest deliberately separate ("is this machine converged by a rig
|
||||||
|
# that predates the fix?"), so both are reported and neither is inferred from
|
||||||
|
# the other: CONVERGED answers currency, BOOTSTRAPPED answers provenance.
|
||||||
|
# Every field degrades independently, so a partial manifest from a future
|
||||||
|
# schema still renders what it does carry.
|
||||||
|
if [ -r "$MANIFEST" ]; then
|
||||||
|
M_SCHEMA="$(manifest_field schema)"
|
||||||
|
B_BY="$(manifest_field bootstrapped_by)"; B_AT="$(manifest_field bootstrapped_at)"
|
||||||
|
C_BY="$(manifest_field converged_by)"; C_AT="$(manifest_field converged_at)"
|
||||||
|
# A manifest with no schema= line is pre-#61; say so rather than render blanks.
|
||||||
|
if [ -z "$M_SCHEMA$B_BY$B_AT$C_BY$C_AT" ]; then
|
||||||
|
field RIG "manifest present but carries no recognised fields ($MANIFEST)"
|
||||||
|
else
|
||||||
|
# 'not recorded' rather than 'unknown', and it does NOT describe a fresh
|
||||||
|
# box: #61's writer records both pairs equally at bootstrap, so no writer
|
||||||
|
# produces a manifest missing converged_* — its absence means the file is
|
||||||
|
# partial or hand-edited. Deliberately not backfilled from bootstrapped_*,
|
||||||
|
# because inferring a convergence that never happened is worse than saying
|
||||||
|
# the record is not there. README and the fixtures pin exactly this.
|
||||||
|
field CONVERGED "${C_BY:-not recorded}${C_AT:+, $C_AT}"
|
||||||
|
field BOOTSTRAP "${B_BY:-not recorded}${B_AT:+, $B_AT}"
|
||||||
|
[ -n "$M_SCHEMA" ] && [ "$M_SCHEMA" != "1" ] && \
|
||||||
|
field NOTE "manifest schema=$M_SCHEMA is newer than this rig reads (expects 1)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
field RIG "not bootstrapped (no $MANIFEST)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The role marker is bootstrap's own line — 'role=dev-server
|
||||||
|
# root-door=closed host=yes join=authkey' — printed as the role plus its
|
||||||
|
# traits. The example tracks the CURRENT vocabulary (#76's -server/-box role
|
||||||
|
# suffixes, #77's root-door trait); this command renders whatever fields the
|
||||||
|
# marker carries, so a pre-rename box still prints its own class= line as-is.
|
||||||
|
MARKER_LINE="$(read_role_marker "$MARKER")"
|
||||||
|
if [ -n "$MARKER_LINE" ]; then
|
||||||
|
ROLE_NAME="" ROLE_TRAITS=""
|
||||||
|
for kv in $MARKER_LINE; do
|
||||||
|
case "$kv" in
|
||||||
|
role=*) ROLE_NAME="${kv#role=}" ;;
|
||||||
|
*) ROLE_TRAITS="${ROLE_TRAITS:+$ROLE_TRAITS }$kv" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
field ROLE "${ROLE_NAME:-unknown}${ROLE_TRAITS:+ ($ROLE_TRAITS)}"
|
||||||
|
else
|
||||||
|
field ROLE "not bootstrapped (no $MARKER)"
|
||||||
|
fi
|
||||||
|
|
@ -9,6 +9,8 @@ set -euo pipefail
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
|
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
|
||||||
. "$HERE/lib/runner-config.sh"
|
. "$HERE/lib/runner-config.sh"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
||||||
|
. "$HERE/lib/admin-path.sh"
|
||||||
|
|
||||||
log() { printf 'rig-runner: %s\n' "$*"; }
|
log() { printf 'rig-runner: %s\n' "$*"; }
|
||||||
warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; }
|
||||||
|
|
@ -85,6 +87,10 @@ VERSION="${VERSION#v}"
|
||||||
|
|
||||||
# --- guards ----------------------------------------------------------------
|
# --- guards ----------------------------------------------------------------
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
# Root is not enough: the admin binaries must also be reachable (#139). This
|
||||||
|
# sits beside the root check so identity and capability are asserted together,
|
||||||
|
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
|
||||||
|
require_admin_bins useradd
|
||||||
if [ -r /etc/os-release ]; then
|
if [ -r /etc/os-release ]; then
|
||||||
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
|
# Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"),
|
||||||
# which would clobber this script's $VERSION.
|
# which would clobber this script's $VERSION.
|
||||||
|
|
|
||||||
55
commands/template-lint.sh
Executable file
55
commands/template-lint.sh
Executable file
|
|
@ -0,0 +1,55 @@
|
||||||
|
#!/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,16 +1,18 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# rig users apply — converge named operator accounts from a declarative users
|
# rig users apply — converge named operator accounts from a declarative users
|
||||||
# file, on every class. Humans always enter as themselves and elevate via
|
# file, on every box. Humans always enter as themselves and elevate via
|
||||||
# sudo: a shared root login is unattributable, so operators belong on servers
|
# sudo: a shared root login is unattributable, so operators belong on servers
|
||||||
# too — class never gates this command, it only decides root SSH's fate AFTER
|
# too — the root-door trait never gates this command, it only decides root
|
||||||
# users exist (close-root on human, kept as the control plane's automation
|
# SSH's fate AFTER users exist (close-root on root-door=closed, kept as the
|
||||||
# door on server). Convergent: a second identical run changes nothing and
|
# control plane's automation door on root-door=open). Convergent: a second
|
||||||
# says so.
|
# identical run changes nothing and says so.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
. "$HERE/lib/users-config.sh"
|
. "$HERE/lib/users-config.sh"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/admin-path.sh
|
||||||
|
. "$HERE/lib/admin-path.sh"
|
||||||
|
|
||||||
log() { printf 'rig-users: %s\n' "$*"; }
|
log() { printf 'rig-users: %s\n' "$*"; }
|
||||||
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
||||||
|
|
@ -18,9 +20,15 @@ die() { printf 'rig-users: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: rig users apply --file <path>
|
usage: rig users apply --file <path> [--yes]
|
||||||
|
|
||||||
--file <path> users file (required; '-' reads it from stdin)
|
--file <path> users file (required; '-' reads it from stdin)
|
||||||
|
--yes consent, up front, to the one prompt this command can ask:
|
||||||
|
a file naming ZERO users against a box that still has
|
||||||
|
managed operators revokes all of them. RIG_YES=1 says the
|
||||||
|
same thing (the installer-family convention). Without
|
||||||
|
either, that case asks on a TTY and REFUSES (exit 2)
|
||||||
|
without one — it never assumes consent it cannot get.
|
||||||
|
|
||||||
The file is line-based and bash-parseable on purpose — a rig box has no YAML
|
The file is line-based and bash-parseable on purpose — a rig box has no YAML
|
||||||
parser and no jq, and gets neither for this. Whitespace-separated: user,
|
parser and no jq, and gets neither for this. Whitespace-separated: user,
|
||||||
|
|
@ -46,8 +54,16 @@ follows it to the user. Apply dies if root has no authorized_keys to seed.
|
||||||
roles:
|
roles:
|
||||||
admin group rig-admin — full NOPASSWD sudo
|
admin group rig-admin — full NOPASSWD sudo
|
||||||
rig group rig — NOPASSWD sudo for /usr/local/bin/rig only
|
rig group rig — NOPASSWD sudo for /usr/local/bin/rig only
|
||||||
box group incus — Incus restricted tier, no sudo (box's setup-host
|
box group incus — Incus restricted tier, no sudo. On host=yes apply
|
||||||
owns the Incus install; rig only asserts it)
|
calls 'box grant <user>', which is the tier: the
|
||||||
|
group is only its socket, the user-<uid> project,
|
||||||
|
the boxnet-only narrowing, snapshots, backups and
|
||||||
|
the box-net profile are the rest of it. Dropping
|
||||||
|
the role hands the group back through
|
||||||
|
'box revoke' — never --purge: convergence removes
|
||||||
|
access, never someone's running boxes. box's
|
||||||
|
setup-host owns the Incus install; rig only
|
||||||
|
asserts it, and defers BOTH directions to box.
|
||||||
|
|
||||||
All passwords stay locked, always — the SSH key at the door is the
|
All passwords stay locked, always — the SSH key at the door is the
|
||||||
authentication, and NOPASSWD sudo does not weaken it. Convergent: membership
|
authentication, and NOPASSWD sudo does not weaken it. Convergent: membership
|
||||||
|
|
@ -64,11 +80,17 @@ EOF
|
||||||
|
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
FILE=""
|
FILE=""
|
||||||
|
# RIG_YES is the installer-family consent contract (bin/rig's uninstall_confirm
|
||||||
|
# reads the same variable): how automation says yes where there is no terminal
|
||||||
|
# to say it on. Set here so --yes and the env var are one flag with two doors.
|
||||||
|
ASSUME_YES=0
|
||||||
|
[ -n "${RIG_YES:-}" ] && ASSUME_YES=1
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--file)
|
--file)
|
||||||
[ $# -ge 2 ] || die "--file needs a value" 2
|
[ $# -ge 2 ] || die "--file needs a value" 2
|
||||||
FILE="$2"; shift 2 ;;
|
FILE="$2"; shift 2 ;;
|
||||||
|
--yes) ASSUME_YES=1; shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) die "unknown flag: $1" 2 ;;
|
*) die "unknown flag: $1" 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -116,6 +138,10 @@ done <<< "$PARSED"
|
||||||
|
|
||||||
# --- guards ------------------------------------------------------------------
|
# --- guards ------------------------------------------------------------------
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
|
# Root is not enough: the admin binaries must also be reachable (#139). This
|
||||||
|
# sits beside the root check so identity and capability are asserted together,
|
||||||
|
# and BEFORE any prompt or download — a token typed for a doomed run is waste.
|
||||||
|
require_admin_bins useradd usermod groupadd
|
||||||
|
|
||||||
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
|
# Identity management gates its INVOKER, not just its uid: %rig's sudoers rule
|
||||||
# is binary-scoped but not argument-scoped, so without this gate a rig-role
|
# is binary-scoped but not argument-scoped, so without this gate a rig-role
|
||||||
|
|
@ -145,13 +171,27 @@ if [ "$NEED_SEED" -eq 1 ]; then
|
||||||
|| die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead"
|
|| die "a user's keys seed from @root but root has no authorized_keys (/root/.ssh/authorized_keys missing or without key lines) — @root's whole point is copying a key you provably hold; list a literal key instead"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Class is a note, never a refusal: #26's call is that operators belong on
|
# The root-door trait is a note here, never a refusal: #26's call is that
|
||||||
# EVERY class — what differs is root SSH's fate once they exist.
|
# operators belong on EVERY box — what differs is root SSH's fate once they
|
||||||
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
# exist. Resolved through root_door_of so this note and close-root's gate read
|
||||||
*class=server*) log "class=server: root SSH stays — it is the control plane's automation door" ;;
|
# one marker the same way, pre-#77 class= spellings included (#77): a box whose
|
||||||
*class=human*) log "class=human: once your admin key works, 'rig users close-root' shuts the root door" ;;
|
# note says the door will shut must be a box where close-root agrees it shuts.
|
||||||
"") warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is" ;;
|
APPLY_MARKER="$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")"
|
||||||
esac
|
if [ -z "$APPLY_MARKER" ]; then
|
||||||
|
warn "no /etc/rig/role marker — re-run rig bootstrap so this box knows what it is"
|
||||||
|
else
|
||||||
|
case "$(root_door_of "$APPLY_MARKER")" in
|
||||||
|
open) log "root-door=open: root SSH stays — it is the control plane's automation door" ;;
|
||||||
|
closed) log "root-door=closed: once your admin key works, 'rig users close-root' shuts the root door" ;;
|
||||||
|
# A marker naming both vocabularies in disagreement, or naming no door
|
||||||
|
# policy at all, is exactly where close-root will refuse. Apply still
|
||||||
|
# converges operators — that is the point of #26 — but it must not stay
|
||||||
|
# quiet about the refusal waiting at the end of the sequence it just
|
||||||
|
# pointed the operator at.
|
||||||
|
conflict) warn "marker names both root-door= and the pre-#77 class= and they disagree ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;;
|
||||||
|
*) warn "marker names no root-door policy ($APPLY_MARKER) — 'rig users close-root' will refuse until you re-run rig bootstrap" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
CHANGED=0
|
CHANGED=0
|
||||||
|
|
||||||
|
|
@ -161,31 +201,175 @@ if [ "$NEED_SUDO" -eq 1 ] && ! command -v sudo >/dev/null 2>&1; then
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo
|
||||||
CHANGED=1
|
CHANGED=1
|
||||||
fi
|
fi
|
||||||
|
# visudo is checked HERE and not beside the root check, because until the block
|
||||||
|
# above has run there is a legitimate reason for it to be absent: sudo is not
|
||||||
|
# installed yet, and apply is what installs it. Above, a missing visudo would
|
||||||
|
# be indistinguishable from that, so the refusal would fire on a healthy box.
|
||||||
|
#
|
||||||
|
# Below, it is unambiguous. sudo is present, so `visudo` missing means only one
|
||||||
|
# thing: /usr/sbin is off PATH. And it MUST refuse here rather than be left to
|
||||||
|
# the sudoers block further down, because that block asks `command -v visudo`
|
||||||
|
# and treats false as "no sudo on the box means no role needed it" — which on a
|
||||||
|
# PATH-shorn root is FALSE TWICE. A role does need it, sudo is installed, and
|
||||||
|
# apply would finish reporting success having silently never written the
|
||||||
|
# sudoers drop-in: the users get their roles and not the escalation the roles
|
||||||
|
# are FOR. That is the failure this whole issue is about (a wrong effective
|
||||||
|
# state reported as success, #12), in its quietest form — the other three sites
|
||||||
|
# at least crash. Refusing before the first mutation is what keeps it loud.
|
||||||
|
#
|
||||||
|
# It sits before `groupadd` below, so nothing has been converged when it fires.
|
||||||
|
if [ "$NEED_SUDO" -eq 1 ]; then
|
||||||
|
require_admin_bins visudo
|
||||||
|
fi
|
||||||
|
|
||||||
# --- groups ------------------------------------------------------------------
|
# --- groups ------------------------------------------------------------------
|
||||||
groupadd -f rig-admin
|
groupadd -f rig-admin
|
||||||
groupadd -f rig
|
groupadd -f rig
|
||||||
# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An
|
# rig NEVER installs Incus: box's setup-host owns the daemon and its group.
|
||||||
# absent incus group means that never ran — but what that MEANS is the host=
|
#
|
||||||
# trait's call. The box role binds where VMs live; a users file is fleet-wide,
|
# Whether the box role applies AT ALL is the host= trait's call, decided here,
|
||||||
# its box grants are not. So on host=yes an absent group is a broken VM host
|
# once, from the marker alone — never from what groups happen to exist on the
|
||||||
# (refuse, point at setup-host), while on host=no it is simply not this box's
|
# machine (#58). The box role binds where VMs live; a users file is fleet-wide,
|
||||||
# role to converge — skip it, never abort the admins the file also carries.
|
# its box grants are not. Apply used to ask the trait only when group incus was
|
||||||
|
# ABSENT, which meant a host=no or marker-less box that nonetheless carried the
|
||||||
|
# group (setup-host ran, then the box was re-bootstrapped with other traits)
|
||||||
|
# handed box-role users a bare `usermod -aG incus` — the socket with no tier
|
||||||
|
# behind it, and incus-user answers a socket it is given by lazily creating an
|
||||||
|
# UNHARDENED project under whoever opens it. The trait now decides the same way
|
||||||
|
# in both directions: BOX_ROLE_OK is the single gate, and the group's presence
|
||||||
|
# only ever answers the narrower question of whether a box that DOES claim to
|
||||||
|
# host VMs is ready to.
|
||||||
|
#
|
||||||
|
# The rejected alternative was letting the machine overrule the marker — treat
|
||||||
|
# a real incus group as evidence the box hosts VMs and converge anyway, warning
|
||||||
|
# that the marker disagrees. It reads reasonable, but it inverts what the rest
|
||||||
|
# of this family does with host=: the marker is the box's declared identity, and
|
||||||
|
# bootstrap is the one thing that writes it. Provisioning a VM-host tier onto a
|
||||||
|
# box that does not claim to be a VM host is rig deciding it knows better than
|
||||||
|
# the declaration, on evidence (a leftover group) that survives exactly the
|
||||||
|
# repurposing that makes the marker right and the group stale. The cost of
|
||||||
|
# choosing the marker is a genuine VM host mislabelled host=no that stops
|
||||||
|
# provisioning — so this does not do it SILENTLY: the skip warning below names
|
||||||
|
# the contradiction and names `rig bootstrap` as the one-line repair.
|
||||||
|
#
|
||||||
|
# Consequence worth stating plainly: on such a box the exact-membership
|
||||||
|
# convergence below will now STRIP box-role users out of incus rather than
|
||||||
|
# leave them there. That is the same call, not a second one. A membership
|
||||||
|
# inherited from a previous life is the identical half-grant state as one
|
||||||
|
# freshly added — socket, no tier — and rig's promise for its three managed
|
||||||
|
# groups is exactness, not "exact except where drift got there first".
|
||||||
INCUS_OK=0
|
INCUS_OK=0
|
||||||
if getent group incus >/dev/null; then INCUS_OK=1; fi
|
if getent group incus >/dev/null; then INCUS_OK=1; fi
|
||||||
if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
|
BOX_ROLE_OK=0
|
||||||
|
BOX_ROLE_WHY=""
|
||||||
|
if BOX_ROLE_WHY="$(assert_marker_hosts_vms "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
||||||
|
BOX_ROLE_OK=1
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 0 ]; then
|
||||||
|
# The group being present while the trait says otherwise is the marker/reality
|
||||||
|
# mismatch — the case that used to slip through — so it gets its own sentence
|
||||||
|
# rather than the generic skip. Never a die: a fleet-wide users file naming a
|
||||||
|
# box-role user somewhere must not abort the admins it also carries here.
|
||||||
|
if [ "$INCUS_OK" -eq 1 ]; then
|
||||||
|
warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY — yet group incus EXISTS here, so this box's marker and this box's reality disagree. rig believes the marker and grants nothing (the group alone is only the socket; without the tier behind it incus-user would lazily build an unhardened project under whoever opens it). If this machine really does host VMs, re-run rig bootstrap with --host yes and apply again; everything else converges"
|
||||||
|
else
|
||||||
|
warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY; everything else converges"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then
|
||||||
|
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- the box TIER, not just its socket (#49) ---------------------------------
|
||||||
|
# Group incus is step 1 of the five 'box grant' performs: the group is the
|
||||||
|
# SOCKET, while the tier is the per-user convergence behind it — the
|
||||||
|
# user-<uid> project, its narrowing to boxnet and only boxnet, the snapshot
|
||||||
|
# and backup allowances clone and 'box export' ride, and the shipped box-net
|
||||||
|
# profile installed into that project. rig doing step 1 alone left every
|
||||||
|
# box-role user's first 'box new' refusing ("your project has no box-net
|
||||||
|
# profile"), so apply's promise — the users file is the fleet's source of
|
||||||
|
# truth — was not kept for this role. It is kept by CALLING box's own grant
|
||||||
|
# rather than reimplementing four fifths of it: 'box grant' is idempotent,
|
||||||
|
# root-or-sudo (which apply already is), stdin-pinned so no incus client can
|
||||||
|
# wedge waiting on an answer, and does its own run-as-the-user touch, so the
|
||||||
|
# granted user never has to log in first for the lazy project to exist.
|
||||||
|
#
|
||||||
|
# Only on host=yes. The box role binds where VMs live, and the two other
|
||||||
|
# arms of the host= decision above are deliberately untouched: host=no keeps
|
||||||
|
# its skip-with-warning, and a marker with no host= trait keeps its own.
|
||||||
|
# Granting a tier on a box that does not host VMs would be converging policy
|
||||||
|
# into a daemon that is not there to enforce it.
|
||||||
|
#
|
||||||
|
# The box CLI's ABSENCE is a host-level fact, so it dies here, in the same
|
||||||
|
# register and for the same reason as the missing-incus-group die above: a
|
||||||
|
# host=yes box carrying box-role users but no box CLI is a broken VM host,
|
||||||
|
# not a per-user accident, and no amount of continuing converges it.
|
||||||
|
BOX_GRANT=0
|
||||||
|
if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 1 ]; then
|
||||||
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in
|
||||||
*host=yes*)
|
*host=yes*)
|
||||||
die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" ;;
|
command -v box >/dev/null 2>&1 \
|
||||||
*host=no*)
|
|| die "a user carries role box and this box hosts VMs (host=yes) but the box CLI is not on PATH — the incus group is only the socket; the restricted tier is 'box grant', which rig calls rather than reimplements. Install the box CLI (rig bootstrap does it on host=yes) and re-run"
|
||||||
warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;;
|
BOX_GRANT=1 ;;
|
||||||
*)
|
|
||||||
warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;;
|
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
|
in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; }
|
||||||
|
|
||||||
|
# --- dropping role box: give the group back to the tool that owns it (#50) ---
|
||||||
|
# rig-admin and rig are rig's own groups, and `gpasswd -d` is the whole story
|
||||||
|
# for them. incus is not rig's: box's setup-host creates it, `box grant` hands
|
||||||
|
# it out, and `box revoke` takes it back — doing strictly MORE than removing
|
||||||
|
# the membership. It says out loud what removing the membership does not do:
|
||||||
|
# supplementary groups are read at LOGIN, so a session the dropped operator
|
||||||
|
# already holds keeps the Incus socket until that session dies, and the remedy
|
||||||
|
# is `loginctl terminate-user <user>`. rig's bare `gpasswd -d` logged "removed
|
||||||
|
# <user> from incus" and left it there, so an operator who dropped someone from
|
||||||
|
# the users file and watched apply succeed believed the VM access was gone —
|
||||||
|
# and was wrong for as long as that user held a session.
|
||||||
|
#
|
||||||
|
# So box takes its own group back. The fallback below exists for the host where
|
||||||
|
# box is not installed (someone else built the Incus stack, or box was removed
|
||||||
|
# from under it), and it carries the session warning ITSELF: the silence is the
|
||||||
|
# bug being fixed, not the gpasswd call.
|
||||||
|
#
|
||||||
|
# NEVER --purge from here. A bare revoke ends access and leaves the user's
|
||||||
|
# project and boxes intact — still RUNNING, because revoking a person does not
|
||||||
|
# kill their workloads. Deleting them is irreversible and is not a convergence
|
||||||
|
# step: an edit to the users file must never destroy someone's machines behind
|
||||||
|
# an admin's back. `box revoke <user> --purge`, run deliberately, is where that
|
||||||
|
# lives.
|
||||||
|
#
|
||||||
|
# The absent-group case needs no guard of its own: `id -nG` cannot report a
|
||||||
|
# group that does not exist, so every caller's `in_group` test is already false
|
||||||
|
# on a host=no box or one where `box setup-host` never ran — there is nothing
|
||||||
|
# to revoke, and apply says nothing and moves on.
|
||||||
|
drop_incus() { # drop_incus <user> — hand group 'incus' back to box
|
||||||
|
local u="$1"
|
||||||
|
if command -v box >/dev/null 2>&1; then
|
||||||
|
# Don't trust the exit code — prove the effective state (the #12 lesson,
|
||||||
|
# the same one bootstrap applies to box's installer). A revoke that exits
|
||||||
|
# 0 having left the membership in place would otherwise be reported as a
|
||||||
|
# removal; the membership is what closes the socket, so it gets checked.
|
||||||
|
if box revoke "$u" && ! in_group "$u" incus; then
|
||||||
|
log "removed $u from incus (via 'box revoke' — box owns that group)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
warn "'box revoke $u' did not remove the incus group — removing it directly; check box on this host"
|
||||||
|
fi
|
||||||
|
if in_group "$u" incus; then
|
||||||
|
gpasswd -d "$u" incus >/dev/null
|
||||||
|
log "removed $u from incus"
|
||||||
|
fi
|
||||||
|
# box's warning, in rig's voice, because rig is the one that took the group
|
||||||
|
# here. pgrep decides whether it applies; if pgrep is absent rig cannot tell,
|
||||||
|
# and an unnecessary warning costs an operator one command while a missing
|
||||||
|
# one costs them a wrong belief about who can reach the daemon.
|
||||||
|
if ! command -v pgrep >/dev/null 2>&1 || pgrep -u "$u" >/dev/null 2>&1; then
|
||||||
|
warn "$u may hold live sessions, and group membership is read at login — those sessions keep the Incus socket until they end. To end them now: loginctl terminate-user $u"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# --- converge each user ------------------------------------------------------
|
# --- converge each user ------------------------------------------------------
|
||||||
for u in "${USERS[@]}"; do
|
for u in "${USERS[@]}"; do
|
||||||
if ! id -u "$u" >/dev/null 2>&1; then
|
if ! id -u "$u" >/dev/null 2>&1; then
|
||||||
|
|
@ -206,13 +390,44 @@ for u in "${USERS[@]}"; do
|
||||||
want=""
|
want=""
|
||||||
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
|
case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac
|
||||||
case ",$roles," in *,rig,*) want="$want rig" ;; esac
|
case ",$roles," in *,rig,*) want="$want rig" ;; esac
|
||||||
# incus joins the wanted set only when the group exists (host=no boxes
|
# incus joins the wanted set only when the box role APPLIES to this box —
|
||||||
# skipped it above): converging membership in a conjured group would hand
|
# the host= trait's call, made once above — and only when the group is
|
||||||
# the daemon's arrival an audience it never granted.
|
# actually there. Deliberately a gate on whether the role applies, not on
|
||||||
case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac
|
# the add: it is the same question no matter who performs the add, so it
|
||||||
|
# keeps answering correctly if the add itself later moves elsewhere (#53
|
||||||
|
# defers it to `box grant`). BOX_ROLE_OK=1 already implies the group exists
|
||||||
|
# (the arm above dies otherwise), but INCUS_OK is kept in the condition
|
||||||
|
# because converging membership in a conjured group would hand the daemon's
|
||||||
|
# arrival an audience it never granted, and that must not depend on a
|
||||||
|
# neighbouring branch staying fatal.
|
||||||
|
case ",$roles," in *,box,*) if [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac
|
||||||
for g in rig-admin rig incus; do
|
for g in rig-admin rig incus; do
|
||||||
case " $want " in
|
case " $want " in
|
||||||
*" $g "*)
|
*" $g "*)
|
||||||
|
# incus stays in the WANTED set — that is what keeps the exact
|
||||||
|
# convergence below from stripping a box-role user's socket — but on
|
||||||
|
# a host where we are about to run 'box grant', the ADD is deferred to
|
||||||
|
# grant itself. Two reasons, both load-bearing:
|
||||||
|
#
|
||||||
|
# Grant's rollback only reaches a membership THAT RUN added: a user it
|
||||||
|
# finds already in incus keeps it when a later step trips
|
||||||
|
# (box/host/grant-user.sh's was_member branch), by design — stripping
|
||||||
|
# a membership it did not add could break a working user over a failed
|
||||||
|
# re-run. So if rig opens the socket first, grant can no longer close
|
||||||
|
# it, and a grant that fails midway leaves the user holding live
|
||||||
|
# access to an UN-narrowed project: the stock unhardened incusbr-<uid>
|
||||||
|
# one --network flag away. That state is strictly worse than no grant
|
||||||
|
# at all, which is the sharp end of #49. Deferring makes the socket and
|
||||||
|
# the tier land together or fail together.
|
||||||
|
#
|
||||||
|
# And grant is the authority on whether the group belongs at all: for
|
||||||
|
# an incus-admin member it deliberately does NOT add incus (admin
|
||||||
|
# membership already wins at the socket, so the row would only mislead
|
||||||
|
# whoever reads the group list later). rig adding it here would put a
|
||||||
|
# membership on that user which box has decided against — not a loop
|
||||||
|
# that oscillates, but rig overruling box on box's own tier, which is
|
||||||
|
# exactly the boundary this whole change is respecting.
|
||||||
|
if [ "$g" = incus ] && [ "$BOX_GRANT" -eq 1 ]; then continue; fi
|
||||||
if ! in_group "$u" "$g"; then
|
if ! in_group "$u" "$g"; then
|
||||||
usermod -aG "$g" "$u"
|
usermod -aG "$g" "$u"
|
||||||
log "added $u to $g"
|
log "added $u to $g"
|
||||||
|
|
@ -220,13 +435,66 @@ for u in "${USERS[@]}"; do
|
||||||
fi ;;
|
fi ;;
|
||||||
*)
|
*)
|
||||||
if in_group "$u" "$g"; then
|
if in_group "$u" "$g"; then
|
||||||
|
if [ "$g" = incus ]; then
|
||||||
|
drop_incus "$u"
|
||||||
|
else
|
||||||
gpasswd -d "$u" "$g" >/dev/null
|
gpasswd -d "$u" "$g" >/dev/null
|
||||||
log "removed $u from $g"
|
log "removed $u from $g"
|
||||||
|
fi
|
||||||
CHANGED=1
|
CHANGED=1
|
||||||
fi ;;
|
fi ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The tier itself. AFTER the account exists — 'box grant' opens with a
|
||||||
|
# getent passwd and refuses an unknown user — and after the other groups,
|
||||||
|
# so a user whose grant fails still lands with everything rig owns outright.
|
||||||
|
#
|
||||||
|
# Per-user failures WARN and continue, matching the governing call the
|
||||||
|
# host= split above already makes: one box-role user somewhere in the fleet
|
||||||
|
# must not stop apply everywhere VMs don't live, and a project that would
|
||||||
|
# not converge is exactly that shape of local fact. Host-level facts still
|
||||||
|
# die, but they die before this loop — the missing group and the missing
|
||||||
|
# CLI both. What is left here (project creation refused, an instance still
|
||||||
|
# parked on the private bridge blocking the narrowing, an incus-user socket
|
||||||
|
# that will not answer) is per-user by construction.
|
||||||
|
if [ "$BOX_GRANT" -eq 1 ]; then
|
||||||
|
case ",$roles," in
|
||||||
|
*,box,*)
|
||||||
|
had_socket=0
|
||||||
|
if in_group "$u" incus; then had_socket=1; fi
|
||||||
|
log "granting $u the box restricted tier (box grant $u)"
|
||||||
|
grant_rc=0
|
||||||
|
box grant "$u" || grant_rc=$?
|
||||||
|
if [ "$grant_rc" -eq 0 ]; then
|
||||||
|
# CHANGED is a claim about what apply DID, and the only part of the
|
||||||
|
# grant rig can observe from out here is the group transition —
|
||||||
|
# every other step converges inside incus's own state, with no cheap
|
||||||
|
# "already converged?" probe short of redoing the work. So report
|
||||||
|
# the transition when it happens and stay silent otherwise: a
|
||||||
|
# convergent call that changed nothing must not turn "already
|
||||||
|
# converged; no changes" into a permanent lie on every host=yes box.
|
||||||
|
if [ "$had_socket" -eq 0 ] && in_group "$u" incus; then
|
||||||
|
log "granted $u the restricted tier (incus group, user-$(id -u "$u") project, boxnet-only, box-net profile)"
|
||||||
|
CHANGED=1
|
||||||
|
fi
|
||||||
|
elif in_group "$u" incus-admin; then
|
||||||
|
# Today 'box grant' refuses incus-admin members outright — it reads
|
||||||
|
# their admin membership as "there is nothing tighter to grant",
|
||||||
|
# which conflates permission with provisioning: they hold the full
|
||||||
|
# socket but have no project of their own. heavy-duty/box#99 makes
|
||||||
|
# grant provision them instead of refusing, at which point this
|
||||||
|
# branch simply stops being reached — no rig change needed, because
|
||||||
|
# rig asks for the tier and lets box decide what that means. Until
|
||||||
|
# then the honest report is that nothing was lost: they still hold
|
||||||
|
# strictly more access than the tier would give them.
|
||||||
|
warn "box grant $u exited $grant_rc and $u is in incus-admin, which box refuses to grant today — they keep the full socket (incus-admin is strictly stronger than the tier), but they get no user-<uid> project of their own and land in the shared default project alongside every other admin. Blocked on heavy-duty/box#99; everything else converged, and a later apply picks the tier up once box stops refusing"
|
||||||
|
else
|
||||||
|
warn "box grant $u exited $grant_rc: $u has their account, keys and rig groups, but NOT the box restricted tier — no user-<uid> project, no boxnet narrowing, no box-net profile, so their 'box new' will refuse. box's own output above names the cause; fix it and re-run apply (or 'box grant $u' by hand). Every other user still converged — one box-role user must not stop apply for the fleet"
|
||||||
|
fi ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
# authorized_keys becomes exactly the file's keys — only the content WRITE
|
# authorized_keys becomes exactly the file's keys — only the content WRITE
|
||||||
# is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode
|
# is cmp-guarded, so an unchanged file is a clean no-op. Ownership and mode
|
||||||
# converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing
|
# converge UNCONDITIONALLY: sshd's StrictModes treats them as load-bearing
|
||||||
|
|
@ -271,6 +539,62 @@ done
|
||||||
# revoked, data kept, convergence never destroys.
|
# revoked, data kept, convergence never destroys.
|
||||||
LEDGER=/etc/rig/users
|
LEDGER=/etc/rig/users
|
||||||
REVOKED=()
|
REVOKED=()
|
||||||
|
|
||||||
|
# --- the empty-file gate (#65) -----------------------------------------------
|
||||||
|
# "Revoke everyone" and "I truncated the file" are the same instruction in this
|
||||||
|
# file format, and apply cannot read intent. What it CAN read is the ledger, and
|
||||||
|
# that draws the only line worth drawing: a file naming zero users against an
|
||||||
|
# empty ledger is an unambiguous no-op, while the same file against a populated
|
||||||
|
# one closes every named door on the box. Only the second is dangerous.
|
||||||
|
#
|
||||||
|
# So this is a CONFIRMATION, not a refusal — deliberately unlike bootstrap's
|
||||||
|
# flat die on the same file (#57/#59). bootstrap ASSERTS who lives on a box, so
|
||||||
|
# an empty answer there is a self-contradiction; apply CONVERGES, and converging
|
||||||
|
# to zero is a complete, legitimate de-provisioning that must keep working. The
|
||||||
|
# difference between the two commands is the whole point, and it survives here.
|
||||||
|
#
|
||||||
|
# The per-user warnings below are not this gate and cannot replace it: they
|
||||||
|
# arrive after the decision, one line per operator, so the signal is loudest
|
||||||
|
# exactly where it reads as scrollback rather than as a question.
|
||||||
|
#
|
||||||
|
# Count FIRST, then speak: the message states a real number, and counting is
|
||||||
|
# what makes the already-converged case silent. An entry the ledger already
|
||||||
|
# marks `revoked`, or one whose account no longer exists, is not at risk — this
|
||||||
|
# run would not change it — so a second identical run of an emptied file stays
|
||||||
|
# the clean no-op convergence promises, with no prompt to answer twice.
|
||||||
|
#
|
||||||
|
# SCOPE: the bright line only. Whether a file that drops 19 of 20 operators
|
||||||
|
# deserves the same gate is the issue's open question — it needs a threshold
|
||||||
|
# someone has to justify, where "the file is empty" needs nothing. Left open.
|
||||||
|
if [ "${#USERS[@]}" -eq 0 ] && [ -r "$LEDGER" ] && [ "$ASSUME_YES" -eq 0 ]; then
|
||||||
|
AT_RISK=0
|
||||||
|
while read -r prev pstate _; do
|
||||||
|
[ -n "$prev" ] || continue
|
||||||
|
[ "${pstate:-active}" != "revoked" ] || continue
|
||||||
|
id -u "$prev" >/dev/null 2>&1 || continue
|
||||||
|
AT_RISK=$((AT_RISK + 1))
|
||||||
|
done < "$LEDGER"
|
||||||
|
if [ "$AT_RISK" -gt 0 ]; then
|
||||||
|
warn "this users file names ZERO users, and this box still manages $AT_RISK operator(s): applying it revokes every one of them — accounts expired, authorized_keys renamed, rig groups stripped. If the file was meant to be empty this is de-provisioning; if it was truncated by accident, stop here."
|
||||||
|
# No terminal means no consent, and a question nobody can answer must not
|
||||||
|
# be assumed into a yes or left to hang. Same shape and same words as
|
||||||
|
# bin/rig's uninstall_confirm, on purpose — one refusal in this codebase.
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
printf 'rig-users: refusing to revoke every managed operator without --yes (no terminal to confirm on; RIG_YES=1 also means yes)\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
# `|| reply=""` is load-bearing: under `set -e` a read that hits EOF is a
|
||||||
|
# non-zero command, and an unguarded read would abort the script rather
|
||||||
|
# than take the safe default (#68, the same bug class).
|
||||||
|
printf 'rig-users: revoke all %s managed operator(s) on this box? [y/N] ' "$AT_RISK"
|
||||||
|
read -r reply || reply=""
|
||||||
|
case "$reply" in
|
||||||
|
y|Y|yes|YES|Yes) ;;
|
||||||
|
*) die "aborted — no operator was revoked" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -r "$LEDGER" ]; then
|
if [ -r "$LEDGER" ]; then
|
||||||
while read -r prev pstate _; do
|
while read -r prev pstate _; do
|
||||||
[ -n "$prev" ] || continue
|
[ -n "$prev" ] || continue
|
||||||
|
|
@ -281,8 +605,19 @@ if [ -r "$LEDGER" ]; then
|
||||||
if [ -f "$prevhome/.ssh/authorized_keys" ]; then
|
if [ -f "$prevhome/.ssh/authorized_keys" ]; then
|
||||||
mv "$prevhome/.ssh/authorized_keys" "$prevhome/.ssh/authorized_keys.revoked-by-rig"
|
mv "$prevhome/.ssh/authorized_keys" "$prevhome/.ssh/authorized_keys.revoked-by-rig"
|
||||||
fi
|
fi
|
||||||
|
# incus goes back through box here too — a user dropped from the file
|
||||||
|
# entirely is exactly the offboarding this warning was written for, and it
|
||||||
|
# would be perverse for the full revocation to be the quiet one. It fires
|
||||||
|
# once, on the transition: the membership is gone on the next run, so
|
||||||
|
# in_group is false and an already-revoked user stays a clean no-op.
|
||||||
for g in rig-admin rig incus; do
|
for g in rig-admin rig incus; do
|
||||||
if in_group "$prev" "$g"; then gpasswd -d "$prev" "$g" >/dev/null; fi
|
if in_group "$prev" "$g"; then
|
||||||
|
if [ "$g" = incus ]; then
|
||||||
|
drop_incus "$prev"
|
||||||
|
else
|
||||||
|
gpasswd -d "$prev" "$g" >/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
REVOKED+=("$prev")
|
REVOKED+=("$prev")
|
||||||
# Warn on the TRANSITION only: an already-revoked user is converged above
|
# Warn on the TRANSITION only: an already-revoked user is converged above
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# rig users close-root — shut the human-class root SSH door, once and only
|
# rig users close-root — shut the root SSH door on the boxes whose door is
|
||||||
# once a named admin can already get in. class decides root SSH's fate (#26):
|
# meant to shut, once and only once a named admin can already get in. The
|
||||||
# on class=human a root login is unattributable noise, so it goes; on
|
# root-door trait decides root SSH's fate (#26, renamed by #77): on
|
||||||
# class=server root IS the control plane's automation identity, so closing it
|
# root-door=closed a root login is unattributable noise, so it goes; on
|
||||||
|
# root-door=open root IS the control plane's automation identity, so closing it
|
||||||
# would sever fleet management — this command refuses there, and no --force
|
# would sever fleet management — this command refuses there, and no --force
|
||||||
# exists. Convergent: a second run is a no-op and says so.
|
# exists. Convergent: a second run is a no-op and says so.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -10,6 +11,12 @@ set -euo pipefail
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
. "$HERE/lib/users-config.sh"
|
. "$HERE/lib/users-config.sh"
|
||||||
|
# The sshd validator is shared with bootstrap's hardening on purpose: both
|
||||||
|
# commands ask sshd the same question before bouncing the daemon, and two
|
||||||
|
# copies of that judgement is drift by construction (#31's law, #92's bug —
|
||||||
|
# the flaw this fixes was present in both copies).
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||||
|
. "$HERE/lib/sshd.sh"
|
||||||
|
|
||||||
log() { printf 'rig-users: %s\n' "$*"; }
|
log() { printf 'rig-users: %s\n' "$*"; }
|
||||||
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'rig-users: WARNING: %s\n' "$*" >&2; }
|
||||||
|
|
@ -23,9 +30,12 @@ Shuts the root SSH door: installs /etc/ssh/sshd_config.d/00-rig-users.conf
|
||||||
carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by
|
carrying exactly `PermitRootLogin no`, which beats bootstrap's drop-in by
|
||||||
first-wins include order.
|
first-wins include order.
|
||||||
|
|
||||||
Human class ONLY. On class=server, root SSH is the control plane's (Coolify's)
|
root-door=closed boxes ONLY. On root-door=open, root SSH is the control plane's
|
||||||
automation identity — closing it severs fleet management — so close-root
|
(Coolify's) automation identity — closing it severs fleet management — so
|
||||||
refuses there, with no --force. It also refuses without a role marker (re-run
|
close-root refuses there, with no --force. Markers written before #77 name this
|
||||||
|
trait as class=human|server and are read as closed|open respectively, so a box
|
||||||
|
bootstrapped before the rename gates exactly as it always did.
|
||||||
|
It also refuses without a role marker (re-run
|
||||||
rig bootstrap; never shut the root door blind) and refuses while no rig-admin
|
rig bootstrap; never shut the root door blind) and refuses while no rig-admin
|
||||||
member holds a login this box would actually honor. Per candidate, in order:
|
member holds a login this box would actually honor. Per candidate, in order:
|
||||||
the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys
|
the StrictModes shape (authorized_keys present and non-empty, home/.ssh/keys
|
||||||
|
|
@ -72,11 +82,13 @@ if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ] \
|
||||||
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
|
die "the users family changes who holds root — only rig-admin members (or root itself) may run it; role rig grants operational rig use, not identity management (invoker: $SUDO_USER)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Marker gate — the policy lives in assert_marker_human (lib) so the harness
|
# Marker gate — the policy lives in assert_marker_closes_root (lib) so the
|
||||||
# can prove its refusals against fixture markers as non-root; RIG_ROLE_MARKER
|
# harness can prove its refusals against fixture markers as non-root;
|
||||||
# exists for the same reason: it keeps the command's own gate pointable at
|
# RIG_ROLE_MARKER exists for the same reason: it keeps the command's own gate
|
||||||
# fixtures instead of only at the real /etc/rig/role.
|
# pointable at fixtures instead of only at the real /etc/rig/role. The gate
|
||||||
if ! WHY="$(assert_marker_human "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
# reads the pre-#77 class= spelling too — see root_door_of for why that compat
|
||||||
|
# read is load-bearing rather than courteous.
|
||||||
|
if ! WHY="$(assert_marker_closes_root "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then
|
||||||
die "$WHY"
|
die "$WHY"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -264,13 +276,13 @@ if [ "$RESTART" -eq 1 ]; then
|
||||||
# to become — restarting into a config the daemon refuses to parse leaves
|
# to become — restarting into a config the daemon refuses to parse leaves
|
||||||
# no listener and no way back in. Roll back (when we installed anything)
|
# no listener and no way back in. Roll back (when we installed anything)
|
||||||
# and stop rather than shut the door on a maybe.
|
# and stop rather than shut the door on a maybe.
|
||||||
if ! sshd -t 2>/dev/null; then
|
if ! sshd_config_ok; then
|
||||||
if [ "$INSTALLED" -eq 1 ]; then
|
if [ "$INSTALLED" -eq 1 ]; then
|
||||||
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
|
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
|
||||||
rm -f "$BACKUP"
|
rm -f "$BACKUP"
|
||||||
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
|
die "sshd rejects the merged config; drop-in rolled back, daemon untouched: $sshd_err"
|
||||||
fi
|
fi
|
||||||
die "sshd rejects the current config; daemon untouched. Run 'sshd -t' to see which file is bad."
|
die "sshd rejects the current config; daemon untouched: $sshd_err"
|
||||||
fi
|
fi
|
||||||
rm -f "$BACKUP"
|
rm -f "$BACKUP"
|
||||||
systemctl restart ssh
|
systemctl restart ssh
|
||||||
|
|
|
||||||
319
docs/plans/2026-07-27-forgejo-ci-box.md
Normal file
319
docs/plans/2026-07-27-forgejo-ci-box.md
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
# Forgejo-native CI: a `ci-box` tenant and a `forgejo-runner` command family
|
||||||
|
|
||||||
|
Status: proposed (#109)
|
||||||
|
Date: 2026-07-27
|
||||||
|
|
||||||
|
## The shape of the thing
|
||||||
|
|
||||||
|
A fleet machine hosts boxes. One of those boxes is the CI box. The Forgejo
|
||||||
|
runner lives inside it, and CI jobs run in containers on that box's own
|
||||||
|
dockerd.
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------------------------+ outbound HTTPS (long-poll)
|
||||||
|
| Forgejo (Coolify) | <---------------------------------+
|
||||||
|
| forgejo.heavyduty.builders | |
|
||||||
|
+-----------------------------+ |
|
||||||
|
+------------------------------+---+
|
||||||
|
No inbound path to the box. | staging-server (host=yes) |
|
||||||
|
The runner polls out. | +---------------------------+ |
|
||||||
|
| | ci-box (tenant) | |
|
||||||
|
| | forgejo-runner (systemd)| |
|
||||||
|
| | dockerd -> job containers| |
|
||||||
|
| +---------------------------+ |
|
||||||
|
+----------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Three pieces, in two repos.
|
||||||
|
|
||||||
|
## 1. A forge-agnostic registry fetch (`commands/lib/templates.sh`)
|
||||||
|
|
||||||
|
`templates_resolve` builds three candidate URLs, all on `github.com`. A
|
||||||
|
registry hosted anywhere else cannot be fetched.
|
||||||
|
|
||||||
|
The fix is a fourth knob beside `_DIR` / `_REF` / `_REPO`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RIG_TEMPLATES_HOST the forge origin (default https://github.com)
|
||||||
|
```
|
||||||
|
|
||||||
|
The candidate list becomes the host's fact rather than a constant, because the
|
||||||
|
two forges genuinely differ:
|
||||||
|
|
||||||
|
| | GitHub | Forgejo |
|
||||||
|
|---|---|---|
|
||||||
|
| Candidates | `archive/refs/tags/<ref>.tar.gz`, `archive/refs/heads/<ref>.tar.gz`, `archive/<ref>.tar.gz` | `archive/<ref>.tar.gz` — one form, which resolves tags, branches and SHAs alike |
|
||||||
|
| Archive top-level dir | `<repo>-<ref>` | `<repo>` |
|
||||||
|
|
||||||
|
Both were measured against `forgejo.heavyduty.builders`, not assumed.
|
||||||
|
|
||||||
|
The existing "exactly one top-level directory" assert survives untouched — it
|
||||||
|
globs `*/` rather than reconstructing the name, so the differing directory name
|
||||||
|
costs nothing. Its **comment** was wrong for Forgejo and is corrected.
|
||||||
|
|
||||||
|
`templates_source_desc` grows the host, so a misconfigured origin shows up in
|
||||||
|
the unknown-role refusal instead of reading like a typo. The GitHub default
|
||||||
|
means every existing caller behaves exactly as before.
|
||||||
|
|
||||||
|
`install.sh`'s `snapshot_templates` duplicates the same candidate list for its
|
||||||
|
install-time cache. It gets the same knob, from the same environment variable,
|
||||||
|
so the snapshot and the live fetch cannot disagree about where the registry is.
|
||||||
|
|
||||||
|
### The blocker this exposed — since cleared
|
||||||
|
|
||||||
|
**Resolved 2026-07-27, after this was written.** The operator set
|
||||||
|
`REQUIRE_SIGNIN_VIEW=false`, and the mint-time path was then verified live: a
|
||||||
|
credential-less `templates_resolve` with
|
||||||
|
`RIG_TEMPLATES_HOST=https://forgejo.heavyduty.builders` fetches and extracts a
|
||||||
|
real archive. Piece 1 is proven end to end on the target instance, not merely
|
||||||
|
argued.
|
||||||
|
|
||||||
|
The requirement below stands for *any* instance hosting a registry, and the
|
||||||
|
refusal text still names it — it is a property of the mint's creds-free
|
||||||
|
contract, not of one server's configuration. What follows is the original
|
||||||
|
analysis, kept because it is why the knob exists.
|
||||||
|
|
||||||
|
### The blocker, as originally measured
|
||||||
|
|
||||||
|
`templates_resolve` documents a hard contract:
|
||||||
|
|
||||||
|
> the fetch is unauthenticated by contract (box auto-runs bootstrap at mint,
|
||||||
|
> holding nothing)
|
||||||
|
|
||||||
|
Measured: on `forgejo.heavyduty.builders`, anonymous requests for
|
||||||
|
`heavy-duty/rig` — reported by the API as `private: false` — return **404** for
|
||||||
|
the API, the web page, the git remote and the archive endpoint. Only an
|
||||||
|
authenticated request succeeds. The instance requires sign-in to view.
|
||||||
|
|
||||||
|
A mint holds no credentials, so **a Forgejo-hosted registry is unreachable at
|
||||||
|
mint time** until the instance serves public repos anonymously:
|
||||||
|
|
||||||
|
```
|
||||||
|
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a Coolify env-var change on the Forgejo service, next to the
|
||||||
|
`FORGEJO__actions__ENABLED=true` that Actions already needs. It is recorded
|
||||||
|
here and in the README as a prerequisite. Nothing in this change silently
|
||||||
|
assumes it: with the gate up, the fetch fails the way any unreachable ref
|
||||||
|
fails, listing every URL tried.
|
||||||
|
|
||||||
|
## 2. The `ci-box` tenant definition
|
||||||
|
|
||||||
|
Data, not mechanism — it belongs in the registry repo. It is staged in this PR
|
||||||
|
under `docs/templates/ci-box/` so it can be reviewed and linted here, and moves
|
||||||
|
to the registry verbatim once that repo exists on Forgejo.
|
||||||
|
|
||||||
|
```
|
||||||
|
USER="ci"
|
||||||
|
CONTEXT_PATH=".ci/CONTEXT.md"
|
||||||
|
CLI_NAME="forgejo-runner"
|
||||||
|
CLI_SRC="/usr/local/bin/forgejo-runner"
|
||||||
|
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
|
||||||
|
NEEDS_NODE="no"
|
||||||
|
```
|
||||||
|
|
||||||
|
`CLI_SRC` is absolute rather than `~/`-relative, which is the one place this
|
||||||
|
definition departs from the agent tenants. Their CLI lives in the tenant's
|
||||||
|
home; this binary is executed by a systemd unit, and a tenant-writable binary
|
||||||
|
that a root-installed unit runs is a trivial path to root inside the box. So it
|
||||||
|
lands root-owned under `/usr/local/bin`. `test/cli.sh` pins `CLI_SRC` against
|
||||||
|
the path `install.sh` actually writes — a drifted pair converges to a CLI that
|
||||||
|
exists and cannot run, which is the scar `grok-box` left.
|
||||||
|
|
||||||
|
`NEEDS_NODE="no"` because the runner is a static Go binary. Jobs get their Node
|
||||||
|
from the container image, which is the whole point of the label mapping below —
|
||||||
|
installing a second Node on the host would be a toolchain nobody reads.
|
||||||
|
|
||||||
|
`install.sh` fetches the release binary for the architecture and **verifies the
|
||||||
|
published `.sha256` before installing it**. Forgejo ships bare binaries with
|
||||||
|
`.sha256` and `.asc` beside them rather than a tarball, so a checksum is
|
||||||
|
available for free; taking it makes the install auditable in the way
|
||||||
|
`coolify install`'s version pin is.
|
||||||
|
|
||||||
|
This satisfies the tenant schema honestly rather than by paperwork:
|
||||||
|
`bootstrap-tenant.sh` asserts `<CLI> --version` answers **as the tenant user**,
|
||||||
|
and `forgejo-runner --version` does.
|
||||||
|
|
||||||
|
`creds.md` states the box holds no Forgejo credential and that registration is
|
||||||
|
a separate, operator-run act — which is true, and is what the rendered context
|
||||||
|
file needs to say.
|
||||||
|
|
||||||
|
### Why the box replaces docker-in-docker
|
||||||
|
|
||||||
|
The setup guide this design came from builds a `docker:dind` sidecar with
|
||||||
|
`privileged: true` and a plaintext `tcp://…:2375` daemon socket. Inside a
|
||||||
|
tenant that is redundant: `bootstrap-tenant.sh` already installs Docker on
|
||||||
|
every tenant and adds the tenant user to the `docker` group. The runner talks
|
||||||
|
to that daemon over its own socket.
|
||||||
|
|
||||||
|
The isolation argument that justifies dind on a shared CI server is already
|
||||||
|
paid for here by the box: it is network-isolated, disposable, and has no
|
||||||
|
inbound path. Stacking dind inside it would add a privileged container to buy
|
||||||
|
a boundary that already exists.
|
||||||
|
|
||||||
|
Note the trade this makes explicit: `docker` group membership is
|
||||||
|
root-equivalent *within the box*. `rig runner install` refuses Docker for
|
||||||
|
exactly that reason — but it converges a fleet **machine**, where the blast
|
||||||
|
radius is the machine. Here the blast radius is a guest that is thrown away.
|
||||||
|
That is the difference that makes the same trade correct in one place and wrong
|
||||||
|
in the other.
|
||||||
|
|
||||||
|
## 3. `rig forgejo-runner install|status|remove`
|
||||||
|
|
||||||
|
A new family beside `rig runner`, which is left untouched.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
box shell ci-box
|
||||||
|
sudo rig forgejo-runner install \
|
||||||
|
--instance https://forgejo.heavyduty.builders \
|
||||||
|
--name ci-runner-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Default labels:
|
||||||
|
|
||||||
|
```
|
||||||
|
ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm
|
||||||
|
```
|
||||||
|
|
||||||
|
so `runs-on: ubuntu-latest` works in a workflow written for GitHub.
|
||||||
|
|
||||||
|
### Why not `rig runner --forge forgejo`
|
||||||
|
|
||||||
|
GitHub's runner registers against a repository URL, and
|
||||||
|
`assert_runner_repo` converges toward `--repo`: re-running against the same
|
||||||
|
repo is a no-op, a different repo is refused because silently restarting on the
|
||||||
|
old one would leave the requested repo with jobs queued forever.
|
||||||
|
|
||||||
|
Forgejo has no such argument. The runner registers against an **instance**, and
|
||||||
|
whether the registration is instance-wide, org, or single-repo is a property of
|
||||||
|
the *token*, decided in Forgejo's UI before rig ever sees it. There is no repo
|
||||||
|
to converge toward and nothing to compare.
|
||||||
|
|
||||||
|
Folding that into one command would make every guard bimodal to share a flag
|
||||||
|
name, while the contract underneath differs. Two families is the smaller lie.
|
||||||
|
|
||||||
|
### `register` is deprecated upstream, and is still the right call
|
||||||
|
|
||||||
|
Measured on v12.13.2: both `forgejo-runner register` and `create-runner-file`
|
||||||
|
carry `(deprecated)` in their help. The successor is
|
||||||
|
`daemon --url <instance> --uuid <uuid> --token-url <url>`, which requires the
|
||||||
|
runner to **already exist on the instance** — the operator creates it via the
|
||||||
|
API or UI and carries a UUID back.
|
||||||
|
|
||||||
|
`register` is chosen anyway:
|
||||||
|
|
||||||
|
- It still works, and this was verified rather than assumed. A planted
|
||||||
|
`.runner` made `daemon` resolve the instance from the file, connect to a live
|
||||||
|
Forgejo, and fail with `Unauthenticated: unregistered runner` — the transport
|
||||||
|
and the file format are intact; only the credential was fake.
|
||||||
|
- The successor asks the operator for a second, differently-shaped credential
|
||||||
|
dance for no gain today.
|
||||||
|
- `register` writes a file `status` can read back. Under the successor, "what
|
||||||
|
is this box registered to" has no on-disk answer that is not merely rig's own
|
||||||
|
copy of what it was told.
|
||||||
|
|
||||||
|
When upstream removes it: the unit line gains `--url`/`--uuid`, and the readers
|
||||||
|
in `forgejo-runner-config.sh` move to whatever holds the UUID.
|
||||||
|
`assert_runner_instance`'s contract survives either spelling, because it asks
|
||||||
|
about the instance — which both record.
|
||||||
|
|
||||||
|
Not verified here, and left to the drill: that `register` writes `.runner` on a
|
||||||
|
live instance. That needs a real registration token, which this design cannot
|
||||||
|
mint.
|
||||||
|
|
||||||
|
### The convergence guard that does apply
|
||||||
|
|
||||||
|
`assert_runner_instance`: re-running against the instance this box is already
|
||||||
|
registered to re-uses the binary and skips registration; a **different**
|
||||||
|
instance refuses and names both. Same trust-boundary reasoning as
|
||||||
|
`assert_runner_repo`, asked about the axis Forgejo actually has.
|
||||||
|
|
||||||
|
### Three corrections from review !110
|
||||||
|
|
||||||
|
The first draft carried three defects that review caught, all of the same
|
||||||
|
family — a stated contract that the code did not actually keep:
|
||||||
|
|
||||||
|
1. **`--version` was swallowed on the primary path.** The download block
|
||||||
|
skipped on mere presence, copying `rig runner install`'s shape without its
|
||||||
|
justification: `actions/runner` self-updates, `forgejo-runner` does not. And
|
||||||
|
a ci-box's template *preinstalls* the binary at mint, so the flag documented
|
||||||
|
as the deterministic-pin lever could never fire on a ci-box. The decision is
|
||||||
|
now `runner_download_decision` in the lib — a pure function, so the rule is
|
||||||
|
driven by tests instead of asserted by grep — and the binary is renamed into
|
||||||
|
place rather than written over, since the converge path now runs while the
|
||||||
|
daemon is live (in-place would be `ETXTBSY`).
|
||||||
|
|
||||||
|
2. **`.rig-labels` outlived the registration it described.** The write had
|
||||||
|
escaped the registration branch, where `runner-install.sh` correctly keeps
|
||||||
|
its copy. A plain re-run stamped that invocation's labels over a
|
||||||
|
registration made with different ones, and `status` then reported
|
||||||
|
confidently wrong labels while Forgejo held the originals. It is scoped
|
||||||
|
again, and an *explicit* `--labels` on a re-run now warns that Forgejo owns
|
||||||
|
labels from registration time.
|
||||||
|
|
||||||
|
3. **The checksum gate failed open.** A missing `.sha256` warned and installed
|
||||||
|
anyway — contradicting both the README and this file's own comment about
|
||||||
|
unverified root downloads. The original reasoning (do not let an upstream
|
||||||
|
layout change break installs) reasons about the wrong failure: a layout
|
||||||
|
change breaks the *binary* URL too, so "binary yes, checksum no" is the
|
||||||
|
shape of an interfered fetch, which is what the checksum is for. Both paths
|
||||||
|
refuse now, with no bypass flag.
|
||||||
|
|
||||||
|
### `.runner` holds a credential here
|
||||||
|
|
||||||
|
GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels`
|
||||||
|
**and the runner's own long-lived token** — the credential it authenticates
|
||||||
|
with on every poll.
|
||||||
|
|
||||||
|
So this family does something its GitHub sibling never had to: it installs
|
||||||
|
`.runner` as `0600` owned by the runner user, and **re-asserts that mode on
|
||||||
|
every converge**. A registration secret readable by every account on the box
|
||||||
|
would be a quiet, permanent credential leak, and convergence is the only moment
|
||||||
|
rig can notice a mode that drifted.
|
||||||
|
|
||||||
|
`status` therefore reads `address`, `name` and `labels` and never prints the
|
||||||
|
token.
|
||||||
|
|
||||||
|
### Removal
|
||||||
|
|
||||||
|
Forgejo's runner has no deregistration handshake — no removal-token endpoint,
|
||||||
|
no `config.sh remove`. `remove` stops and disables the unit and wipes the local
|
||||||
|
registration, then says plainly that the entry must be deleted in Forgejo's
|
||||||
|
admin UI. `rig runner remove`'s `--local` escape hatch is the *only* mode here,
|
||||||
|
so it is not offered as a flag that suggests a server-side alternative exists.
|
||||||
|
|
||||||
|
## Guard: `bootstrap --undo`
|
||||||
|
|
||||||
|
`bootstrap-undo.sh` refuses to leave the tailnet while a GitHub runner is
|
||||||
|
installed, so undo cannot strand a ghost runner in a repository. The same
|
||||||
|
hazard exists for a Forgejo runner on a machine, so the guard learns the
|
||||||
|
`forgejo-runner.service` unit and the `.runner` under the runner user's home.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`test/cli.sh` is dependency-free, non-root, offline. What it can prove:
|
||||||
|
|
||||||
|
- Every new command's arg validation, `--help`, and unknown-flag refusals.
|
||||||
|
- `bin/rig` dispatch, including bare `rig forgejo-runner` showing usage.
|
||||||
|
- The forge-aware URL builder as a **pure function**, lifted and driven
|
||||||
|
directly: GitHub host yields the three-candidate list in tag-first order,
|
||||||
|
a Forgejo host yields the single `/archive/<ref>.tar.gz`.
|
||||||
|
- `templates_source_desc` names a non-default host.
|
||||||
|
- The staged `ci-box` definition passes `rig template-lint` — the same parser
|
||||||
|
a mint runs, so the definition cannot ship malformed.
|
||||||
|
- Grep-pins that the `.runner` 0600 assert and the `bootstrap --undo`
|
||||||
|
forgejo-runner guard are present, so a deleted guard cannot ship green
|
||||||
|
(the repo's existing precedent for guards that need a real machine).
|
||||||
|
|
||||||
|
What it cannot prove, and is left to the drill: a real registration against a
|
||||||
|
live Forgejo, and a job actually executing in a container.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
`install.sh` hardcodes `github.com` in `resolve_latest_tag` and
|
||||||
|
`ref_candidate_urls` (rig's own source), and `commands/bootstrap.sh` fetches
|
||||||
|
box from `raw.githubusercontent.com`. Hosting rig itself on Forgejo needs those
|
||||||
|
too. They are follow-ups, not this change — `snapshot_templates` is included
|
||||||
|
here only because it fetches *the registry*, and leaving it behind would let
|
||||||
|
the snapshot and the live fetch disagree about where the registry lives.
|
||||||
25
docs/templates/README.md
vendored
Normal file
25
docs/templates/README.md
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Staged template definitions
|
||||||
|
|
||||||
|
Definitions here are **not** the registry. They are role definitions destined
|
||||||
|
for `heavy-duty/rig-templates`, staged in rig's tree only while the registry
|
||||||
|
repo does not yet exist on the forge that will serve them (#109).
|
||||||
|
|
||||||
|
This is deliberately a waiting room, not a second registry:
|
||||||
|
|
||||||
|
- `bootstrap-tenant.sh` does not look here. Nothing in this directory is
|
||||||
|
reachable by a mint, and adding a lookup would recreate exactly the coupling
|
||||||
|
the registry split was written to remove — where adding a tenant meant
|
||||||
|
editing rig.
|
||||||
|
- `test/cli.sh` lints each one with `rig template-lint`, the same parser a mint
|
||||||
|
runs. A definition that cannot pass the schema never reaches the registry.
|
||||||
|
- When the registry repo exists, a definition moves there **verbatim** and is
|
||||||
|
deleted from here in the same PR.
|
||||||
|
|
||||||
|
To try one before it is anywhere, point a mint at it directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
RIG_TEMPLATES_DIR=docs/templates rig bootstrap ci-box
|
||||||
|
```
|
||||||
|
|
||||||
|
That is `RIG_TEMPLATES_DIR`'s stated purpose — "try a template before it exists
|
||||||
|
anywhere" — and it is the supported path, not a workaround.
|
||||||
13
docs/templates/ci-box/creds.md
vendored
Normal file
13
docs/templates/ci-box/creds.md
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
- **Creds-free by default.** The box starts with no Forgejo credentials and no
|
||||||
|
git credentials. The runner binary is installed but **not registered**:
|
||||||
|
registration needs a token the operator mints in Forgejo (Site
|
||||||
|
Administration, org, or repo → Actions → Runners) and hands to
|
||||||
|
`sudo rig forgejo-runner install --instance <url>`. rig never writes that
|
||||||
|
token to disk — but it does **not expire, and registering does not spend
|
||||||
|
it**. A Forgejo registration token stays valid until somebody mints a
|
||||||
|
replacement at that same scope, and it will register as many runners as it is
|
||||||
|
shown to. Treat a leaked one as live until it has been replaced. (GitHub's
|
||||||
|
equivalent expires in about an hour; do not carry that habit across.) After
|
||||||
|
registration the runner's own credential lives in `~/forgejo-runner/.runner`,
|
||||||
|
mode 0600 — never copy it, print it, or commit it. Secrets that CI jobs need
|
||||||
|
belong in Forgejo's repo or org secrets, injected per job, not on this box.
|
||||||
129
docs/templates/ci-box/install.sh
vendored
Executable file
129
docs/templates/ci-box/install.sh
vendored
Executable file
|
|
@ -0,0 +1,129 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci-box — the forgejo-runner binary. Run BY THE MECHANISM as root, with
|
||||||
|
# TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE exported.
|
||||||
|
#
|
||||||
|
# This lands the BINARY ONLY. Registration is deliberately not here: it needs a
|
||||||
|
# registration token from the Forgejo instance, and a tenant install is
|
||||||
|
# creds-free by contract — box auto-runs it at mint, holding nothing. The
|
||||||
|
# operator registers afterwards, out loud:
|
||||||
|
#
|
||||||
|
# Deliberately NOT described the way the GitHub sibling describes its own
|
||||||
|
# registration token, which really does expire in about an hour. That adjective
|
||||||
|
# must not cross this forge boundary — a test pins its absence from this file
|
||||||
|
# and from creds.md, so do not reintroduce it by copying from `rig runner`.
|
||||||
|
# Forgejo's ActionRunnerToken carries no expiry field at all;
|
||||||
|
# NewRunnerToken invalidates prior tokens only when a replacement is minted at
|
||||||
|
# the same scope, and Register leaves the one it was handed active. It is
|
||||||
|
# reusable until replaced, so a leak stays live. See creds.md, which is the
|
||||||
|
# copy an agent inside the box actually reads.
|
||||||
|
#
|
||||||
|
# box shell ci-box
|
||||||
|
# sudo rig forgejo-runner install --instance https://forgejo.example.com
|
||||||
|
#
|
||||||
|
# Same split as staging-box's tailnet join, for the same reason.
|
||||||
|
#
|
||||||
|
# Root-owned under /usr/local/bin rather than the tenant's home: unlike an
|
||||||
|
# agent CLI, this binary is run by a systemd unit as the tenant user, and a
|
||||||
|
# tenant-writable binary that root's unit executes is a trivial path to root
|
||||||
|
# inside the box.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# fetch_and_verify_sha256 <asset-url> <file> <sumfile> <label>
|
||||||
|
#
|
||||||
|
# The whole checksum POLICY, in one place: fetch the published .sha256 beside
|
||||||
|
# an asset and prove the download matches it. Prints the reason on stderr and
|
||||||
|
# returns 1 on any failure; the caller supplies the refusal in its own voice.
|
||||||
|
#
|
||||||
|
# BYTE-IDENTICAL to the copy in commands/forgejo-runner-install.sh, diffed by
|
||||||
|
# test/cli.sh — the valid_version / templates_archive_urls precedent. The two
|
||||||
|
# downloaders cannot share a lib: that one sources commands/lib/, and this one
|
||||||
|
# is a REGISTRY DEFINITION that runs standalone inside a mint from a fetched
|
||||||
|
# tarball, with rig's tree nowhere in reach. So the pin is the only mechanism
|
||||||
|
# that keeps one policy from becoming two.
|
||||||
|
#
|
||||||
|
# Review !110 is the evidence for why that matters: a fail-open branch lived in
|
||||||
|
# BOTH copies while a grep for "checksum mismatch" passed against both, because
|
||||||
|
# the string it looked for sat right beside the branch it could not see. The
|
||||||
|
# next checksum-policy change must not be able to land in one file only.
|
||||||
|
#
|
||||||
|
# AN UNFETCHABLE CHECKSUM REFUSES — it is a gate, not a courtesy. The earlier
|
||||||
|
# reasoning ("do not let an upstream layout change break installs") reasons
|
||||||
|
# about the wrong failure: a layout change moves the BINARY url too, so the
|
||||||
|
# download would already have died. "Binary yes, checksum no" is not what a
|
||||||
|
# layout change looks like — it is what an interfered fetch looks like, which
|
||||||
|
# is precisely what a checksum exists to catch. Failing open would hand an
|
||||||
|
# unverified root install to anyone able to block a single URL. There is
|
||||||
|
# deliberately no bypass flag: if upstream really does move its assets, that is
|
||||||
|
# a rig PR editing the URL, not an operator improvising past a security gate.
|
||||||
|
fetch_and_verify_sha256() {
|
||||||
|
local url="$1" file="$2" sumfile="$3" label="$4" want got
|
||||||
|
if ! curl -fsSL "${url}.sha256" -o "$sumfile" 2>/dev/null; then
|
||||||
|
printf 'no published .sha256 for %s at %s.sha256 — the binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch\n' "$label" "$url" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# The published .sha256 names the asset, not our temp path. Compare the
|
||||||
|
# digest itself rather than rewriting the file into sha256sum -c's format:
|
||||||
|
# one comparison, no parsing of a file we did not write.
|
||||||
|
want="$(tr -d '\r' < "$sumfile" 2>/dev/null | awk '{print $1}' | head -n1)"
|
||||||
|
got="$(sha256sum "$file" | awk '{print $1}')"
|
||||||
|
if [ -z "$want" ]; then
|
||||||
|
printf 'the published checksum for %s is unreadable — a fetch that succeeds but returns nothing usable is not a verified download\n' "$label" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [ "$want" != "$got" ]; then
|
||||||
|
printf 'checksum mismatch for %s: published %s, downloaded %s\n' "$label" "$want" "$got" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf 'checksum verified (%s)\n' "$got"
|
||||||
|
}
|
||||||
|
|
||||||
|
# CIBOX_BIN is a TEST-ONLY override, in the same spirit as bootstrap-undo.sh's
|
||||||
|
# RIG_FORGEJO_RUNNER_DIR: the production default is the only path the mechanism
|
||||||
|
# ever uses, but test/cli.sh must be able to drive this script on a box that
|
||||||
|
# already has a real runner installed. Without it the early-exit below fires
|
||||||
|
# against the host and the checksum checks silently test nothing (#136).
|
||||||
|
BIN="${CIBOX_BIN:-/usr/local/bin/forgejo-runner}"
|
||||||
|
|
||||||
|
if [ -x "$BIN" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64) ARCH="arm64" ;;
|
||||||
|
*) echo "ci-box install: unsupported arch: $(uname -m)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The latest release, resolved by following the releases/latest redirect — no
|
||||||
|
# API call, no token, no JSON to parse on a dependency-free guest. A pinned
|
||||||
|
# version belongs to `rig forgejo-runner install --version`, which is where an
|
||||||
|
# operator who needs a deterministic install already is; a pin baked into the
|
||||||
|
# registry would go stale in a repo nobody watches.
|
||||||
|
LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
|
||||||
|
https://code.forgejo.org/forgejo/runner/releases/latest)" \
|
||||||
|
|| { echo "ci-box install: could not resolve the latest forgejo-runner release" >&2; exit 1; }
|
||||||
|
VERSION="${LATEST_URL##*/}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
case "$VERSION" in
|
||||||
|
""|*[!0-9.]*) echo "ci-box install: could not parse a version from ${LATEST_URL}" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
ASSET="forgejo-runner-${VERSION}-linux-${ARCH}"
|
||||||
|
URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}"
|
||||||
|
|
||||||
|
WORKDIR="$(mktemp -d)"
|
||||||
|
cleanup() { rm -rf "$WORKDIR"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "ci-box install: downloading forgejo-runner ${VERSION} (${ARCH})"
|
||||||
|
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
||||||
|
|| { echo "ci-box install: could not download ${URL}" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Forgejo publishes a .sha256 beside each binary. Verifying it costs one
|
||||||
|
# request and makes the install auditable; this file executes as root inside
|
||||||
|
# every future mint, so an unverified download is the last thing it should do.
|
||||||
|
fetch_and_verify_sha256 "$URL" "$WORKDIR/forgejo-runner" "$WORKDIR/forgejo-runner.sha256" "$ASSET" \
|
||||||
|
|| { echo "ci-box install: refusing to install an unverified ${ASSET} — it lands as root inside every mint. See the checksum failure above." >&2; exit 1; }
|
||||||
|
|
||||||
|
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
||||||
|
echo "ci-box install: installed ${BIN}"
|
||||||
16
docs/templates/ci-box/template.env
vendored
Normal file
16
docs/templates/ci-box/template.env
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# ci-box — the Forgejo CI tenant (#109). The box guest a fleet machine mints to
|
||||||
|
# run CI jobs: forgejo-runner polls the instance outbound, jobs run in
|
||||||
|
# containers on the dockerd bootstrap-tenant.sh already installed.
|
||||||
|
#
|
||||||
|
# The schema is rig's commands/lib/templates.sh; this is data, parsed and never
|
||||||
|
# sourced.
|
||||||
|
USER="ci"
|
||||||
|
CONTEXT_PATH=".ci/CONTEXT.md"
|
||||||
|
CLI_NAME="forgejo-runner"
|
||||||
|
CLI_SRC="/usr/local/bin/forgejo-runner"
|
||||||
|
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
|
||||||
|
# NEEDS_NODE is a question about the CLI, not about the jobs. forgejo-runner is
|
||||||
|
# a static Go binary, and every job gets its own toolchain from its container
|
||||||
|
# image — a Node on the host would be a second, invisible toolchain that no
|
||||||
|
# workflow reads.
|
||||||
|
NEEDS_NODE="no"
|
||||||
114
drill/README.md
Normal file
114
drill/README.md
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# 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 Actions runners 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 3's Forgejo half** (#129): `--forgejo-instance <url>` and
|
||||||
|
`--forgejo-runner-repo <owner>/<repo>`, where that repo carries the same
|
||||||
|
`workflow_dispatch` workflow — but with `runs-on: drill`, because a
|
||||||
|
Forgejo runner matches the bare label it registered with. Tokens come from
|
||||||
|
`FORGEJO_RUNNER_TOKEN` (a registration token) or `FORGEJO_API_TOKEN`, which
|
||||||
|
mints one and is also what dispatches the job. `--forgejo-ref` names the
|
||||||
|
branch to dispatch (default `main`): Forgejo's dispatch endpoint requires a
|
||||||
|
ref in the body, where GitHub's defaults to the repo's default branch.
|
||||||
|
Without an instance and a repo this half **skips, loudly and separately**.
|
||||||
|
Note there is no removal token — Forgejo has no deregistration endpoint, so
|
||||||
|
the leg removes locally and the record tells you to delete the stale runner
|
||||||
|
row by hand.
|
||||||
|
- **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. Runs **once per forge**: `rig runner`
|
||||||
|
against GitHub, then `rig forgejo-runner` against a Forgejo instance
|
||||||
|
(#129). Both families ship, so a release that evidences only one
|
||||||
|
evidences half of what it ships; each half skips separately, so a record
|
||||||
|
can honestly show one forge drilled and the other not.
|
||||||
|
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.
|
||||||
983
drill/drill.sh
Normal file
983
drill/drill.sh
Normal file
|
|
@ -0,0 +1,983 @@
|
||||||
|
#!/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 Actions runners (GitHub, and
|
||||||
|
# Forgejo when --forgejo-instance is given).
|
||||||
|
# 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 \
|
||||||
|
# --forgejo-instance https://forgejo.example.com \
|
||||||
|
# --forgejo-runner-repo you/drill-probe --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.
|
||||||
|
# Runs once per forge: `rig runner` against GitHub (--runner-repo), and
|
||||||
|
# `rig forgejo-runner` against a Forgejo instance (--forgejo-instance +
|
||||||
|
# --forgejo-runner-repo). Both forges ship, so both need evidence; each
|
||||||
|
# skips loudly and separately when its inputs are absent (#129).
|
||||||
|
# 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}"
|
||||||
|
FJ_INSTANCE="${DRILL_FORGEJO_INSTANCE:-}"
|
||||||
|
FJ_RUNNER_REPO="${DRILL_FORGEJO_RUNNER_REPO:-}"
|
||||||
|
# The branch the dispatch names. Forgejo's dispatch endpoint requires a ref in
|
||||||
|
# the body — unlike GitHub's, which defaults to the repo's default branch.
|
||||||
|
FJ_REF="${DRILL_FORGEJO_REF:-main}"
|
||||||
|
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 ;;
|
||||||
|
--forgejo-instance) FJ_INSTANCE="$2"; shift 2 ;;
|
||||||
|
--forgejo-runner-repo) FJ_RUNNER_REPO="$2"; shift 2 ;;
|
||||||
|
--forgejo-ref) FJ_REF="$2"; shift 2 ;;
|
||||||
|
-h|--help) sed -n '2,40p' "$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"); }
|
||||||
|
|
||||||
|
# forgejo_run_verdict <pre_id> <tasks-json> — the verdict for OUR dispatch:
|
||||||
|
# success | failed | pending. Reads GET /repos/{o}/{r}/actions/tasks, whose
|
||||||
|
# shape is NOT GitHub's and was measured against forgejo.heavyduty.builders
|
||||||
|
# (8.0.3+gitea-1.22.0) on 2026-07-30 rather than read from the docs:
|
||||||
|
#
|
||||||
|
# * There is no `conclusion` field. `status` carries the terminal outcome
|
||||||
|
# directly ("success"), where GitHub splits status:completed +
|
||||||
|
# conclusion:success. Reading `conclusion` here gets an empty string on
|
||||||
|
# every run, which would grade a green job as failed.
|
||||||
|
# * `id` is a GLOBAL task id; the run's own URL ends in `run_number`. The
|
||||||
|
# pre-dispatch guard therefore compares `id`, exactly as the GitHub leg
|
||||||
|
# compares databaseId — an old run must never be read as this one.
|
||||||
|
# * The payload lists ASSIGNED tasks only. A run sitting queued is simply
|
||||||
|
# absent (measured: 200s of total_count 0 while the web UI showed the run
|
||||||
|
# as "job is not started"). So "no new id" is the ONLY signal that the
|
||||||
|
# runner never took the job — which is the verdict this leg exists for.
|
||||||
|
# * A task appears when ASSIGNED, so it can be seen mid-flight. A
|
||||||
|
# non-terminal status is pending, not failed; grading a running job as a
|
||||||
|
# failure would make the leg flaky inside its own watch window.
|
||||||
|
#
|
||||||
|
# EVERY entry is inspected, and the NEWEST id above pre_id decides — never
|
||||||
|
# entry[0]. actions/tasks accumulates, so the moment a repo is drilled twice
|
||||||
|
# our run shares the payload with older ones, and nothing documents the sort
|
||||||
|
# order. Reading the first entry made a green job report as a timeout, a false
|
||||||
|
# FAILURE on the gate this leg exists to provide (grok/kimi on !130).
|
||||||
|
#
|
||||||
|
# grep-and-sed, not jq: a throwaway drill machine has neither jq nor an
|
||||||
|
# authenticated forge CLI, the same constraint json_field() carries in
|
||||||
|
# commands/lib/runner-config.sh. `id` is a bare number, which json_field's
|
||||||
|
# quoted-value shape cannot read, so this reads both forms itself. Newlines are
|
||||||
|
# stripped first so a pretty-printed payload parses identically to a compact
|
||||||
|
# one — the instance documents neither.
|
||||||
|
forgejo_run_verdict() {
|
||||||
|
local pre="$1" file="$2" obj id best_id="" best_st=""
|
||||||
|
[ -r "$file" ] || { echo pending; return 0; }
|
||||||
|
while IFS= read -r obj; do
|
||||||
|
[ -n "$obj" ] || continue
|
||||||
|
id="$(printf '%s' "$obj" | grep -o '"id"[[:space:]]*:[[:space:]]*[0-9][0-9]*' \
|
||||||
|
| head -n1 | sed 's/.*:[[:space:]]*//')"
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
# Strictly newer than the pre-dispatch id. Equal is the run that was
|
||||||
|
# already there; lower is older still.
|
||||||
|
if [ -n "$pre" ]; then
|
||||||
|
[ "$id" -gt "$pre" ] 2>/dev/null || continue
|
||||||
|
fi
|
||||||
|
if [ -z "$best_id" ] || [ "$id" -gt "$best_id" ] 2>/dev/null; then
|
||||||
|
best_id="$id"
|
||||||
|
best_st="$(printf '%s' "$obj" | grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||||
|
| head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//')"
|
||||||
|
fi
|
||||||
|
done <<EOF
|
||||||
|
$(tr -d '\n' < "$file" 2>/dev/null | grep -o '{[^{}]*}')
|
||||||
|
EOF
|
||||||
|
[ -n "$best_id" ] || { echo pending; return 0; }
|
||||||
|
case "$best_st" in
|
||||||
|
success) echo success ;;
|
||||||
|
failure | cancelled | skipped | timedout) echo failed ;;
|
||||||
|
*) echo pending ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_leg_row <install_ok> <status_ok> <took> <remove_ok> <absent_ok>
|
||||||
|
# The record row for the Forgejo runner leg. PASS requires the WHOLE lifecycle,
|
||||||
|
# not just the take-a-job outcome.
|
||||||
|
#
|
||||||
|
# Keying the row on <took> alone let it read "PASS — registered, took a job,
|
||||||
|
# removed" when install had failed, because the dispatched job only needs
|
||||||
|
# SOMETHING answering runs-on: drill — and this leg removes locally, telling the
|
||||||
|
# operator to delete the stale runner by hand, so a leftover drill-labeled
|
||||||
|
# runner from the previous drill is the designed-for aftermath rather than a
|
||||||
|
# contrived case (codex/grok/kimi on !130). drills/<v>.md is the release's
|
||||||
|
# durable evidence; a row claiming a lifecycle that did not happen is exactly
|
||||||
|
# what the gate exists to refuse.
|
||||||
|
#
|
||||||
|
# The drill's exit code was never wrong here — every one of those failures also
|
||||||
|
# called `no`. What was wrong is the row, and the row is what outlives the run.
|
||||||
|
forgejo_leg_row() {
|
||||||
|
local install_ok="$1" status_ok="$2" took="$3" remove_ok="$4" absent_ok="$5"
|
||||||
|
if [ "$install_ok" != 1 ] || [ "$status_ok" != 1 ] \
|
||||||
|
|| [ "$remove_ok" != 1 ] || [ "$absent_ok" != 1 ]; then
|
||||||
|
echo "FAIL — see Failed below"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "$took" in
|
||||||
|
success) echo "PASS — registered, took a job, removed (stale row needs deleting by hand)" ;;
|
||||||
|
none) echo "PARTIAL — registered and removed; took a job: not attempted (no FORGEJO_API_TOKEN)" ;;
|
||||||
|
*) echo "FAIL — see Failed below" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_max_task_id <tasks-json> — the highest numeric task id in the payload,
|
||||||
|
# empty when there is none. This is the PRE-DISPATCH baseline, and it must fold
|
||||||
|
# max exactly as forgejo_run_verdict does: taking the first id instead names an
|
||||||
|
# OLD run as the baseline whenever the payload is not newest-first (the order is
|
||||||
|
# undocumented). A later poll that finds the same body then reads the PREVIOUS
|
||||||
|
# drill's run as this dispatch's result — a false PASS on the take-a-job
|
||||||
|
# assertion, which is worse than the false FAIL the same mistake caused inside
|
||||||
|
# the verdict (grok/kimi, !130). The GitHub leg is safe from this only because
|
||||||
|
# `gh run list --limit 1` contracts newest-first; this API contracts nothing.
|
||||||
|
forgejo_max_task_id() {
|
||||||
|
local file="$1" id best=""
|
||||||
|
[ -r "$file" ] || return 0
|
||||||
|
while IFS= read -r id; do
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
if [ -z "$best" ] || [ "$id" -gt "$best" ] 2>/dev/null; then best="$id"; fi
|
||||||
|
done <<EOF
|
||||||
|
$(tr -d '\n' < "$file" 2>/dev/null \
|
||||||
|
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9][0-9]*' | sed 's/.*:[[:space:]]*//')
|
||||||
|
EOF
|
||||||
|
printf '%s\n' "$best"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_token_verdict <resolved_token> <api_token> — ok | mint-failed | no-source.
|
||||||
|
# #129's acceptance: "Token source present but the instance is unreachable ->
|
||||||
|
# the leg FAILS; it must not skip and must not pass". A mint that yields
|
||||||
|
# nothing — unreachable instance, under-scoped token, wrong repo — is a
|
||||||
|
# CONFIGURED leg failing, and reporting it as "no token source" both writes
|
||||||
|
# SKIPPED where the record owes a FAIL and sends the operator to check an env
|
||||||
|
# var they already set. Absent inputs are the only honest skip.
|
||||||
|
forgejo_token_verdict() {
|
||||||
|
if [ -n "$1" ]; then echo ok
|
||||||
|
elif [ -n "$2" ]; then echo mint-failed
|
||||||
|
else echo no-source
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 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}, a GitHub runner${RUNNER_REPO:+ against $RUNNER_REPO} and a Forgejo runner${FJ_INSTANCE:+ against $FJ_INSTANCE}${FJ_RUNNER_REPO:+ ($FJ_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 "Leg 3 — forgejo runner lifecycle against an instance"
|
||||||
|
# =============================================================================
|
||||||
|
# The same leg as above, for the other forge. Both forges ship a runner family
|
||||||
|
# (#109 added `rig forgejo-runner` beside `rig runner`), so a release that
|
||||||
|
# evidences only GitHub evidences half of what it ships (#129).
|
||||||
|
#
|
||||||
|
# Three things differ from the GitHub leg, all measured against
|
||||||
|
# forgejo.heavyduty.builders (8.0.3+gitea-1.22.0) on 2026-07-30, not read:
|
||||||
|
#
|
||||||
|
# * Scope is the TOKEN's, never a flag — `rig forgejo-runner install` refuses
|
||||||
|
# --repo on purpose (commands/forgejo-runner-install.sh:160). The repo here
|
||||||
|
# is only where the registration token is minted from, and where the
|
||||||
|
# dispatched workflow lives.
|
||||||
|
# * The mint path is /repos/<o>/<r>/actions/runners/registration-token. The
|
||||||
|
# instance's own swagger documents /repos/<o>/<r>/runners/registration-token
|
||||||
|
# — WITHOUT /actions/ — and that path 404s. Do not "fix" this to match the
|
||||||
|
# published API reference.
|
||||||
|
# * There is no deregistration endpoint, so there is no removal token and no
|
||||||
|
# remote deregistration: `rig forgejo-runner remove` is local-only by
|
||||||
|
# design (commands/forgejo-runner-remove.sh:7-11) and the runner row
|
||||||
|
# survives in the UI until a human deletes it. The record says so rather
|
||||||
|
# than implying a clean remote teardown the way the GitHub leg can.
|
||||||
|
#
|
||||||
|
# Tokens: FORGEJO_RUNNER_TOKEN (a registration token) is used directly; else
|
||||||
|
# FORGEJO_API_TOKEN mints one from FJ_RUNNER_REPO. Without an instance, a repo,
|
||||||
|
# or a token source the leg SKIPS loudly and the record says it did not run.
|
||||||
|
if [ -z "$FJ_INSTANCE" ] || [ -z "$FJ_RUNNER_REPO" ]; then
|
||||||
|
skip "forgejo runner lifecycle: no --forgejo-instance/--forgejo-runner-repo given — the leg did not run"
|
||||||
|
leg "forgejo runner lifecycle" "SKIPPED — no instance/repo provided"
|
||||||
|
else
|
||||||
|
fj_reg="${FORGEJO_RUNNER_TOKEN:-}"
|
||||||
|
if [ -z "$fj_reg" ] && [ -n "${FORGEJO_API_TOKEN:-}" ]; then
|
||||||
|
fj_reg="$(curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
||||||
|
"${FJ_INSTANCE%/}/api/v1/repos/${FJ_RUNNER_REPO}/actions/runners/registration-token" 2>/dev/null \
|
||||||
|
| grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 \
|
||||||
|
| sed 's/.*:[[:space:]]*"//; s/"$//')"
|
||||||
|
fi
|
||||||
|
fj_tok_verdict="$(forgejo_token_verdict "$fj_reg" "${FORGEJO_API_TOKEN:-}")"
|
||||||
|
if [ "$fj_tok_verdict" = mint-failed ]; then
|
||||||
|
# Configured, and it did not work. Never a skip: see forgejo_token_verdict.
|
||||||
|
no "registration-token mint FAILED against ${FJ_INSTANCE} — is it reachable, and does FORGEJO_API_TOKEN own ${FJ_RUNNER_REPO}? (the token is never printed)"
|
||||||
|
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" "FAIL — registration-token mint failed"
|
||||||
|
elif [ "$fj_tok_verdict" = no-source ]; then
|
||||||
|
skip "forgejo runner lifecycle: no FORGEJO_RUNNER_TOKEN and no FORGEJO_API_TOKEN to mint one — the leg did not run"
|
||||||
|
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" "SKIPPED — no registration token source"
|
||||||
|
else
|
||||||
|
FJ_NAME="drill-$(hostname)-$$"
|
||||||
|
fj_install_ok=0 fj_status_ok=0 fj_remove_ok=0 fj_absent_ok=0
|
||||||
|
# The label MUST carry a docker:// image: forgejo-runner runs jobs in
|
||||||
|
# containers, and a bare label leaves runs-on matched but unrunnable.
|
||||||
|
if FORGEJO_RUNNER_TOKEN="$fj_reg" run_logged /tmp/drill-forgejo-runner-install.log \
|
||||||
|
rig forgejo-runner install --instance "$FJ_INSTANCE" --name "$FJ_NAME" \
|
||||||
|
--labels 'drill:docker://node:22-bookworm'; then
|
||||||
|
ok "rig forgejo-runner install --instance $FJ_INSTANCE exited 0 (registered as $FJ_NAME)"
|
||||||
|
fj_install_ok=1
|
||||||
|
else
|
||||||
|
no "forgejo-runner install FAILED — tail: $(tail -3 /tmp/drill-forgejo-runner-install.log | tr '\n' ' ')"
|
||||||
|
fi
|
||||||
|
if rig forgejo-runner status 2>/dev/null | grep -qF "${FJ_INSTANCE%/}"; then
|
||||||
|
ok "forgejo-runner status names the instance: $FJ_INSTANCE"; fj_status_ok=1
|
||||||
|
else
|
||||||
|
no "forgejo-runner status does not name ${FJ_INSTANCE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
fj_took=none
|
||||||
|
# Do NOT dispatch once install or status has failed. The job would be taken
|
||||||
|
# by whatever else answers runs-on: drill — a stale runner this leg's own
|
||||||
|
# hand-delete caveat leaves behind — and its success would be evidence about
|
||||||
|
# someone else's runner (codex/grok/kimi, !130).
|
||||||
|
if [ "$fj_install_ok" != 1 ] || [ "$fj_status_ok" != 1 ]; then
|
||||||
|
skip "took a job: not attempted — install or status failed, and a foreign runner answering 'drill' could only manufacture a false pass"
|
||||||
|
elif [ -n "${FORGEJO_API_TOKEN:-}" ]; then
|
||||||
|
fj_api="${FJ_INSTANCE%/}/api/v1/repos/${FJ_RUNNER_REPO}"
|
||||||
|
# Read the newest ASSIGNED task id BEFORE dispatching, same guard as the
|
||||||
|
# GitHub leg: an already-completed run must never be read as ours.
|
||||||
|
fj_pre_body="$(curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
||||||
|
"$fj_api/actions/tasks" 2>/dev/null || echo '{}')"
|
||||||
|
printf '%s' "$fj_pre_body" > /tmp/drill-forgejo-pre.json
|
||||||
|
fj_pre="$(forgejo_max_task_id /tmp/drill-forgejo-pre.json)"
|
||||||
|
if curl -fsSL -o /dev/null -X POST -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" -d "{\"ref\":\"${FJ_REF}\"}" \
|
||||||
|
"$fj_api/actions/workflows/${RUNNER_WORKFLOW}/dispatches" 2>/dev/null; then
|
||||||
|
inf "dispatched $RUNNER_WORKFLOW on $FJ_RUNNER_REPO — waiting for the runner to take it (≤5 min)…"
|
||||||
|
fj_took=timeout
|
||||||
|
for _i in $(seq 1 30); do
|
||||||
|
sleep 10
|
||||||
|
curl -fsSL -H "Authorization: token ${FORGEJO_API_TOKEN}" \
|
||||||
|
"$fj_api/actions/tasks" -o /tmp/drill-forgejo-tasks.json 2>/dev/null || continue
|
||||||
|
case "$(forgejo_run_verdict "${fj_pre:-}" /tmp/drill-forgejo-tasks.json)" in
|
||||||
|
success) fj_took=success; break ;;
|
||||||
|
failed) fj_took=failed; break ;;
|
||||||
|
*) : ;; # pending — queued, or assigned and still running
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
else
|
||||||
|
fj_took=nodispatch
|
||||||
|
fi
|
||||||
|
case "$fj_took" in
|
||||||
|
success) ok "the forgejo 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 $FJ_RUNNER_REPO" ;;
|
||||||
|
# A queued task is INVISIBLE in this API until a runner claims it, so a
|
||||||
|
# timeout means "nothing ever took it". Two causes, and the second one
|
||||||
|
# is not rig's: the runs-on label may not match, or the daemon's poller
|
||||||
|
# can go quiet — a restarted daemon claims a minutes-old backlog in
|
||||||
|
# about a second. Check 'systemctl restart forgejo-runner' before
|
||||||
|
# reading this as a rig defect.
|
||||||
|
timeout) no "the dispatched job was never taken within 5 min — check the workflow's runs-on is 'drill', then restart forgejo-runner and re-read (a quiet poller looks exactly like this)" ;;
|
||||||
|
nodispatch) no "could not dispatch $RUNNER_WORKFLOW on $FJ_RUNNER_REPO — does it carry that workflow, with workflow_dispatch, on its default branch?" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
skip "took a job: not attempted — no FORGEJO_API_TOKEN to dispatch $RUNNER_WORKFLOW with"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No removal token exists on this forge — remove is local by design.
|
||||||
|
if rig forgejo-runner remove >/dev/null 2>&1; then
|
||||||
|
note "forgejo-runner removed locally — Forgejo has no deregistration endpoint, so DELETE the stale '$FJ_NAME' row under $FJ_RUNNER_REPO > Settings > Actions > Runners by hand"
|
||||||
|
fj_remove_ok=1
|
||||||
|
else
|
||||||
|
no "forgejo-runner remove FAILED"
|
||||||
|
fi
|
||||||
|
if rig forgejo-runner status >/dev/null 2>&1; then
|
||||||
|
no "forgejo-runner status still answers after remove — the removal did not take"
|
||||||
|
else
|
||||||
|
ok "forgejo-runner status confirms: nothing registered"; fj_absent_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
leg "forgejo runner lifecycle ($FJ_RUNNER_REPO)" \
|
||||||
|
"$(forgejo_leg_row "$fj_install_ok" "$fj_status_ok" "$fj_took" \
|
||||||
|
"$fj_remove_ok" "$fj_absent_ok")"
|
||||||
|
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 ]
|
||||||
59
drills/0.3.0.md
Normal file
59
drills/0.3.0.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# rig 0.3.0 — release drill
|
||||||
|
|
||||||
|
**WAIVED. No drill was run for this release.**
|
||||||
|
|
||||||
|
Waived by the maintainer (@danmt) on 2026-07-21. This file exists because the
|
||||||
|
gate requires a *record*, not a passing result — so a skip is a line in a diff
|
||||||
|
somebody reviewed, rather than a silence nobody notices.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The drill harness is not in a state to produce a trustworthy run, and rig is
|
||||||
|
the sharpest case: **rig has no drill harness of its own at all.** Its legs —
|
||||||
|
tenant guests minted via box and converged, the GitHub runner lifecycle against
|
||||||
|
a fork, `rig coolify install` and the backup install failing to its credential
|
||||||
|
boundary — have only ever been run by hand, reconstructed each time. box has a
|
||||||
|
harness; rig and cast do not.
|
||||||
|
|
||||||
|
A run improvised under those conditions produces numbers nobody should rely on,
|
||||||
|
and `drills/` is worth nothing if it fills with records like that.
|
||||||
|
|
||||||
|
Deferred deliberately, and tracked: #107.
|
||||||
|
|
||||||
|
## What this release therefore does NOT prove
|
||||||
|
|
||||||
|
Stated plainly so it is not rediscovered as a surprise:
|
||||||
|
|
||||||
|
- **Convergence on real hardware** — that a machine reaches its role, and
|
||||||
|
reaches it *idempotently*, on a host that is not a container.
|
||||||
|
- **The `--host yes` path end to end** — rig installing box and running its
|
||||||
|
`setup-host` on a bare machine, which is the bottom of the whole stack.
|
||||||
|
- **The three BREAKING renames in this release under real use** — `--class` →
|
||||||
|
`--root-door` (#77), and the `-box` / `-server` role suffixes (#76). These are
|
||||||
|
hard cuts with no aliases; CI proves the parsing, not the lived upgrade.
|
||||||
|
- **The combination users receive.** rig still resolves `BOX_REF` to `main`
|
||||||
|
(#103), so even a passing drill would have proven a pair that drifts the
|
||||||
|
moment box's main moves.
|
||||||
|
|
||||||
|
## What CI did prove on this tree
|
||||||
|
|
||||||
|
Not nothing, and worth recording so the gap is precise rather than total:
|
||||||
|
|
||||||
|
| Job | Result |
|
||||||
|
|---|---|
|
||||||
|
| `db-integration` — a real PostgreSQL dump/restore round-trip | pass |
|
||||||
|
| `scope` | pass |
|
||||||
|
| `reconcile` | pass |
|
||||||
|
| `test/cli.sh` | 566 passed, 0 failed |
|
||||||
|
| `test/release.sh` | 134 passed, 0 failed |
|
||||||
|
| `test/labels-reconcile.sh` | 72 passed, 0 failed |
|
||||||
|
| CI shellcheck sweep (globstar + dotglob) | clean |
|
||||||
|
|
||||||
|
The command surface and the DB round-trip are covered. Convergence on real
|
||||||
|
hardware is not.
|
||||||
|
|
||||||
|
## The commitment
|
||||||
|
|
||||||
|
The next release carries a real drill record at `drills/<version>.md` — what
|
||||||
|
ran, on what host, the pinned refs and SHAs, the numbers, and what failed. A
|
||||||
|
failed drill is a valid record; another waiver is not. Tracked in #107.
|
||||||
12
drills/0.3.1.md
Normal file
12
drills/0.3.1.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# 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.
|
||||||
124
drills/README.md
Normal file
124
drills/README.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Drill records
|
||||||
|
|
||||||
|
Per-release evidence for rig's real-hardware drill. **One file per version**,
|
||||||
|
named for the version exactly as `VERSION` carries it:
|
||||||
|
|
||||||
|
drills/<version>.md
|
||||||
|
|
||||||
|
So `0.3.0` is recorded in `drills/0.3.0.md`, and `0.3.0-rc1` in
|
||||||
|
`drills/0.3.0-rc1.md`. They are different files, which is the whole point: the
|
||||||
|
filesystem does the whole-version comparison that an earlier single-file
|
||||||
|
version of this had to do with an awk extractor and a heading grammar. A
|
||||||
|
record for the candidate cannot be mistaken for evidence for the final.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
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
|
||||||
|
of only spaces and tabs, is not a record (box#149 and cast#138 both shipped an
|
||||||
|
extractor where a single tab satisfied the gate).
|
||||||
|
|
||||||
|
The guard requires a **record**, not a passing result. A maintainer waiver is
|
||||||
|
a legitimate outcome of a release — but it is written in that version's file,
|
||||||
|
so that skipping the drill is a deliberate, reviewable commit rather than a
|
||||||
|
silence. **A failed drill is still a valid record**: the gate wants evidence,
|
||||||
|
not success.
|
||||||
|
|
||||||
|
## The drill
|
||||||
|
|
||||||
|
rig's legs (#105; `drill/drill.sh` runs them):
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
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
|
||||||
|
installer. 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.
|
||||||
|
|
||||||
|
**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. 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
any drill runs, so a cycle at runtime becomes two independent tests against
|
||||||
|
one fixed pair. It also means **candidate refs, not released artifacts**:
|
||||||
|
`RIG_REPO`/`RIG_REF` are mint-time environment variables, so no repo has to be
|
||||||
|
released before another can be drilled. Drilling the candidate *is* drilling
|
||||||
|
the release, because a release PR's diff is `VERSION` + `CHANGELOG.md` and
|
||||||
|
nothing executable differs.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Drills that share a substrate share **one run ID**; each repo records its own
|
||||||
|
legs in its own file, citing that run ID and the other repos' commit SHAs, so
|
||||||
|
the records can be joined after the fact. 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. Releases
|
||||||
|
do **not** have to be published in a fixed order.
|
||||||
|
|
||||||
|
## What a record should contain
|
||||||
|
|
||||||
|
What ran, on what host, the pinned candidate refs, the numbers, and what
|
||||||
|
failed. Below is the *shape*, in a file named `drills/9.9.9.md` — a version
|
||||||
|
that can never collide with a real release. **No drill has been recorded here
|
||||||
|
yet**; this log starts empty rather than reconstructing runs from memory, since
|
||||||
|
an invented number is worse than no number.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Release drill — 9.9.9 — 2026-01-01
|
||||||
|
|
||||||
|
Run ID: drill-2026-01-01-a. Host: bare Debian 13 cloud image, 4 vCPU / 8 GB.
|
||||||
|
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) |
|
||||||
|
| 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 |
|
||||||
|
| runner lifecycle against a fork | PASS — registered, took a job, deregistered clean |
|
||||||
|
| forgejo runner lifecycle (you/drill-probe) | PASS — registered, took a job, removed (stale row needs deleting by hand) |
|
||||||
|
| coolify install (4.1.2) | PASS (6 min) |
|
||||||
|
|
||||||
|
Failed: `rig users apply` left one revoked key in `authorized_keys`
|
||||||
|
(filed #NNN). Everything else clean.
|
||||||
|
```
|
||||||
|
|
||||||
|
State what failed. A record with no failures listed reads as "nothing broke",
|
||||||
|
so if a leg was not run, say that instead of omitting it.
|
||||||
164
install.sh
164
install.sh
|
|
@ -29,12 +29,19 @@ set -euo pipefail
|
||||||
# protect, and the operator flipping versions on purpose is the common case.
|
# 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.
|
# A pre-versioning flat tree is migrated in place, so upgrading is seamless.
|
||||||
#
|
#
|
||||||
# RIG_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
|
# RIG_INSTALL_SOURCE=<dir-or-tarball> is the LOCAL channel, a supported input
|
||||||
# downloading — for CI and the test suite, so what lands is the code under
|
# like RIG_REF (#106): installs from that tree instead of downloading — CI's
|
||||||
# review.
|
# 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.
|
||||||
|
|
||||||
REPO="${RIG_REPO:-heavy-duty/rig}"
|
REPO="${RIG_REPO:-heavy-duty/rig}"
|
||||||
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
||||||
|
# The forge this REPO lives on is RIG_HOST (#111), default https://github.com.
|
||||||
|
# Parallel to RIG_TEMPLATES_HOST — not the same variable, because the registry
|
||||||
|
# and rig itself may live on different forges. Default stays GitHub so every
|
||||||
|
# existing curl|bash one-liner is byte-unchanged; set
|
||||||
|
# RIG_HOST=https://forgejo.heavyduty.builders to install from this instance.
|
||||||
|
|
||||||
# cloud-init's runcmd runs with NO $HOME in the environment, and under set -u
|
# cloud-init's runcmd runs with NO $HOME in the environment, and under set -u
|
||||||
# the expansions just below turned that into a death instead of an install —
|
# the expansions just below turned that into a death instead of an install —
|
||||||
|
|
@ -99,11 +106,18 @@ warn_bootstrapped() { # $1 = what is about to happen
|
||||||
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
|
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
|
||||||
# rate-limit pain. A repo with no releases redirects to /releases — not to
|
# rate-limit pain. A repo with no releases redirects to /releases — not to
|
||||||
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
|
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
|
||||||
# and the CALLER owns the loud story. test/release.sh extracts this function
|
# and the CALLER owns the loud story. Host comes from RIG_HOST (default
|
||||||
# (awk, the valid_version idiom) and drives it against a stubbed curl.
|
# GitHub); both GitHub and Forgejo serve the same /releases/latest →
|
||||||
|
# /releases/tag/<tag> redirect grammar, measured 2026-07-29 (#111).
|
||||||
|
# test/release.sh extracts this function (awk, the valid_version idiom) and
|
||||||
|
# drives it against a stubbed curl.
|
||||||
resolve_latest_tag() {
|
resolve_latest_tag() {
|
||||||
local loc
|
# Default is inlined (not $RIG_HOST_DEFAULT) so test/release.sh's awk
|
||||||
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1
|
# extract of this function stays self-contained — same discipline as
|
||||||
|
# valid_version.
|
||||||
|
local host="${RIG_HOST:-https://github.com}" loc
|
||||||
|
host="${host%/}"
|
||||||
|
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "$host/$1/releases/latest")" || return 1
|
||||||
case "$loc" in
|
case "$loc" in
|
||||||
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
|
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
|
|
@ -111,12 +125,69 @@ resolve_latest_tag() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an
|
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an
|
||||||
# explicit RIG_REF, in order: refs/tags first, so a tag always outranks a
|
# explicit RIG_REF, in order. Host comes from RIG_HOST. Both GitHub and
|
||||||
# branch that happens to share its name (the pin must win), refs/heads as
|
# Forgejo (measured 2026-07-29 on forgejo.heavyduty.builders 8.0.3) serve
|
||||||
# the fallback that keeps RIG_REF=main the dev channel.
|
# the same two paths and the same disambiguation: refs/tags first so a pin
|
||||||
|
# always outranks a same-named branch, then refs/heads for RIG_REF=main.
|
||||||
|
# Host is the only forge-specific input — no second grammar (#111).
|
||||||
|
#
|
||||||
|
# The RELEASE channel (RIG_REF unset) must NOT use this list: a missing tag
|
||||||
|
# archive must fail loudly, never fall through to a same-named branch and
|
||||||
|
# still report the resolved tag in INSTALLED_FROM. Use release_tag_url.
|
||||||
ref_candidate_urls() {
|
ref_candidate_urls() {
|
||||||
printf 'https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$1" "$2"
|
local host="${RIG_HOST:-https://github.com}"
|
||||||
printf 'https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$1" "$2"
|
host="${host%/}"
|
||||||
|
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$1" "$2"
|
||||||
|
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
# release_tag_url <owner/repo> <tag> — the RELEASE channel is tag-only on
|
||||||
|
# every forge (#111 / #32). One URL, refs/tags only: if that archive is
|
||||||
|
# gone the install dies, it never quietly takes refs/heads/<tag>.
|
||||||
|
release_tag_url() {
|
||||||
|
local host="${RIG_HOST:-https://github.com}"
|
||||||
|
host="${host%/}"
|
||||||
|
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
# install_script_url — the curl|bash entrypoint URL for this REPO on RIG_HOST.
|
||||||
|
# GitHub serves raw files at raw.githubusercontent.com; Forgejo at
|
||||||
|
# /raw/branch/<ref>/<path>. The refusal hint and bin/rig usage() both print
|
||||||
|
# this, so a Forgejo install never tells the operator to hit a 404 (#111).
|
||||||
|
# REPO is the installer's global (RIG_REPO); tests that extract this function
|
||||||
|
# must set it.
|
||||||
|
install_script_url() {
|
||||||
|
local host="${RIG_HOST:-https://github.com}"
|
||||||
|
host="${host%/}"
|
||||||
|
case "$host" in
|
||||||
|
https://github.com|http://github.com|*//github.com)
|
||||||
|
printf 'https://raw.githubusercontent.com/%s/main/install.sh\n' "${REPO:-heavy-duty/rig}" ;;
|
||||||
|
*)
|
||||||
|
printf '%s/%s/raw/branch/main/install.sh\n' "$host" "${REPO:-heavy-duty/rig}" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# The registry's candidate URLs, forge-aware — a byte-identical copy of
|
||||||
|
# commands/lib/templates.sh's, diffed by test/cli.sh so the two cannot drift
|
||||||
|
# (#109; the valid_version / warn_bootstrapped precedent). One decision about
|
||||||
|
# where a registry lives, made in one grammar: a snapshot fetched from a forge
|
||||||
|
# converge would never fetch from is worse than no snapshot at all.
|
||||||
|
#
|
||||||
|
# Copied rather than sourced on purpose. snapshot_templates runs against an
|
||||||
|
# extracted tree, so sourcing WOULD work here — but it would make the
|
||||||
|
# installer's behaviour depend on executing code from the tarball it just
|
||||||
|
# downloaded, ahead of any of it being installed. The installer reads that
|
||||||
|
# tree (sed for the pin); it does not run it.
|
||||||
|
templates_archive_urls() {
|
||||||
|
local host="${1%/}" repo="$2" ref="$3"
|
||||||
|
case "$host" in
|
||||||
|
https://github.com|http://github.com|*//github.com)
|
||||||
|
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref"
|
||||||
|
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref"
|
||||||
|
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
||||||
|
*)
|
||||||
|
printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;;
|
||||||
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- prerequisites -----------------------------------------------------------
|
# --- prerequisites -----------------------------------------------------------
|
||||||
|
|
@ -198,12 +269,15 @@ else
|
||||||
if [ -z "$REF" ]; then
|
if [ -z "$REF" ]; then
|
||||||
log "resolving the latest release of $REPO"
|
log "resolving the latest release of $REPO"
|
||||||
if ! REF="$(resolve_latest_tag "$REPO")"; then
|
if ! REF="$(resolve_latest_tag "$REPO")"; then
|
||||||
warn "could not resolve the latest release of $REPO — either no release exists yet, or GitHub was unreachable."
|
warn "could not resolve the latest release of $REPO — either no release exists yet, or ${RIG_HOST:-https://github.com} was unreachable."
|
||||||
warn "(rig has no release until 0.1.0 is cut — rig#32. Until then, install the development tree explicitly.)"
|
warn "(install the development tree explicitly with RIG_REF=main when no release exists yet.)"
|
||||||
die "set RIG_REF: e.g. curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | RIG_REF=main bash"
|
die "set RIG_REF: e.g. curl -fsSL $(install_script_url) | RIG_REF=main bash"
|
||||||
fi
|
fi
|
||||||
log "latest release: $REF"
|
log "latest release: $REF"
|
||||||
urls=("https://github.com/$REPO/archive/refs/tags/$REF.tar.gz")
|
# Tag-only: the channel resolved a RELEASE tag, so the download is that
|
||||||
|
# tag's archive and nothing else. Falling through to refs/heads would
|
||||||
|
# install a branch while INSTALLED_FROM still names the tag (#111 review).
|
||||||
|
mapfile -t urls < <(release_tag_url "$REPO" "$REF")
|
||||||
else
|
else
|
||||||
mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF")
|
mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF")
|
||||||
fi
|
fi
|
||||||
|
|
@ -219,7 +293,7 @@ else
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[ -n "$got" ] \
|
[ -n "$got" ] \
|
||||||
|| die "failed to download $REPO@$REF — not a tag and not a branch (tried refs/tags then refs/heads)"
|
|| die "failed to download $REPO@$REF — no candidate URL worked (host ${RIG_HOST:-https://github.com}; tried ${urls[*]})"
|
||||||
|
|
||||||
log "extracting archive"
|
log "extracting archive"
|
||||||
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
||||||
|
|
@ -247,6 +321,60 @@ set_exec() { # $1 = a rig tree: the executable bits install.sh owns
|
||||||
fi
|
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 host 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}"
|
||||||
|
# The same forge knob templates_resolve reads, from the same variable (#109).
|
||||||
|
# Snapshot and live fetch MUST agree about where the registry lives: an
|
||||||
|
# install that cached from GitHub while converge fetches from Forgejo would
|
||||||
|
# serve a snapshot the pin never named, and the pin-in-the-directory-name
|
||||||
|
# staleness guard cannot catch a WRONG-ORIGIN snapshot, only an old one.
|
||||||
|
host="${RIG_TEMPLATES_HOST:-https://github.com}"
|
||||||
|
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 ${host%/}/$repo@$pin"
|
||||||
|
while IFS= read -r url; do
|
||||||
|
if curl -fsSL "$url" -o "$TMPDIR/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
||||||
|
done < <(templates_archive_urls "$host" "$repo" "$pin")
|
||||||
|
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> -----------------------------------
|
# --- install into $DEST/versions/<version> -----------------------------------
|
||||||
VDIR="$DEST/versions/$new_ver"
|
VDIR="$DEST/versions/$new_ver"
|
||||||
newly_installed=0
|
newly_installed=0
|
||||||
|
|
@ -257,6 +385,7 @@ if [ -d "$VDIR" ]; then
|
||||||
log "RIG_REINSTALL=1 — replacing the installed $new_ver tree"
|
log "RIG_REINSTALL=1 — replacing the installed $new_ver tree"
|
||||||
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
|
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
|
||||||
rm -rf "$stage" "$old"
|
rm -rf "$stage" "$old"
|
||||||
|
snapshot_templates "$EXTRACTED"
|
||||||
set_exec "$EXTRACTED"
|
set_exec "$EXTRACTED"
|
||||||
mv "$EXTRACTED" "$stage"
|
mv "$EXTRACTED" "$stage"
|
||||||
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
|
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
|
||||||
|
|
@ -274,6 +403,7 @@ if [ -d "$VDIR" ]; then
|
||||||
else
|
else
|
||||||
log "installing $new_ver into $VDIR"
|
log "installing $new_ver into $VDIR"
|
||||||
mkdir -p "$DEST/versions"
|
mkdir -p "$DEST/versions"
|
||||||
|
snapshot_templates "$EXTRACTED"
|
||||||
set_exec "$EXTRACTED"
|
set_exec "$EXTRACTED"
|
||||||
mv "$EXTRACTED" "$VDIR"
|
mv "$EXTRACTED" "$VDIR"
|
||||||
newly_installed=1
|
newly_installed=1
|
||||||
|
|
|
||||||
3059
test/cli.sh
3059
test/cli.sh
File diff suppressed because it is too large
Load diff
402
test/drill.sh
Normal file
402
test/drill.sh
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
#!/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 forgejo_run_verdict forgejo_token_verdict forgejo_max_task_id forgejo_leg_row; do
|
||||||
|
awk "/^${fn}\(\) \{/,/^\}/" "$ROOT/drill/drill.sh" >> "$FNS"
|
||||||
|
done
|
||||||
|
for fn in tree_of assert_installed_from classify_leg capture_state emit_record forgejo_run_verdict forgejo_token_verdict forgejo_max_task_id forgejo_leg_row; 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
|
||||||
|
# =============================================================================
|
||||||
|
# =============================================================================
|
||||||
|
# forgejo_run_verdict — did OUR dispatched run land, and how (#129)
|
||||||
|
# =============================================================================
|
||||||
|
# The Forgejo half of the runner leg cannot reuse the GitHub reader. Measured
|
||||||
|
# against forgejo.heavyduty.builders (8.0.3+gitea-1.22.0) on 2026-07-30, a
|
||||||
|
# completed run in GET /repos/{o}/{r}/actions/tasks carries NO `conclusion`
|
||||||
|
# field at all — `status` holds the terminal outcome directly, where GitHub
|
||||||
|
# splits status:completed + conclusion:success. And `id` is a global task id
|
||||||
|
# (25) while the run's own URL ends in run_number (1), so the pre-dispatch
|
||||||
|
# guard has to compare `id`.
|
||||||
|
#
|
||||||
|
# The payload is also an ASSIGNED-task view: it reads total_count 0 for as
|
||||||
|
# long as a run sits queued (measured: 200s), so "no new id" is the ONLY
|
||||||
|
# signal that the runner never took the job. That is the verdict this leg
|
||||||
|
# exists to produce, which is why it gets its own function and its own tests.
|
||||||
|
FJ="$WORK/fj"; mkdir -p "$FJ"
|
||||||
|
printf '%s' '{"workflow_runs":[],"total_count":0}' > "$FJ/empty.json"
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":25,"status":"success","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-success.json"
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":25,"status":"failure","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-failure.json"
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":25,"status":"cancelled","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/new-cancelled.json"
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":24,"status":"success","run_number":1,"url":"https://f/o/r/actions/runs/1"}],"total_count":1}' > "$FJ/stale-only.json"
|
||||||
|
|
||||||
|
check "verdict: an empty task list is PENDING, never a pass" 0 "pending" \
|
||||||
|
forgejo_run_verdict "" "$FJ/empty.json"
|
||||||
|
check "verdict: a queued run the runner never took stays PENDING" 0 "pending" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/stale-only.json"
|
||||||
|
check "verdict: OUR new run, status success, is SUCCESS" 0 "success" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/new-success.json"
|
||||||
|
check "verdict: status carries the outcome — failure is FAILED, not success" 0 "failed" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/new-failure.json"
|
||||||
|
check "verdict: a cancelled run is FAILED, not silently passed" 0 "failed" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/new-cancelled.json"
|
||||||
|
check "verdict: the first run ever (no pre-id) still resolves" 0 "success" \
|
||||||
|
forgejo_run_verdict "" "$FJ/new-success.json"
|
||||||
|
# A task appears in this payload the moment it is ASSIGNED, which can be before
|
||||||
|
# it finishes — so a non-terminal status must read as pending, not as a failure.
|
||||||
|
# Calling a still-running job "failed" would make the leg flaky in exactly the
|
||||||
|
# window the leg is watching.
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":25,"status":"running","run_number":1}],"total_count":1}' > "$FJ/new-running.json"
|
||||||
|
check "verdict: an assigned-but-running task is PENDING, not FAILED" 0 "pending" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/new-running.json"
|
||||||
|
|
||||||
|
# grok/kimi on !130: the reader must not stop at the FIRST run. actions/tasks
|
||||||
|
# accumulates — the moment a repo is drilled twice, our run shares the payload
|
||||||
|
# with older ones, and nothing documents the sort order. Reading entry[0] makes
|
||||||
|
# a green job read as a timeout, which is a FALSE FAILURE on the very gate this
|
||||||
|
# leg exists to provide.
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":24,"status":"success"},{"id":25,"status":"success"}],"total_count":2}' > "$FJ/oldest-first.json"
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":25,"status":"success"},{"id":24,"status":"success"}],"total_count":2}' > "$FJ/newest-first.json"
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":25,"status":"running"},{"id":26,"status":"success"}],"total_count":2}' > "$FJ/ours-not-first.json"
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":23,"status":"success"},{"id":24,"status":"failure"}],"total_count":2}' > "$FJ/all-stale.json"
|
||||||
|
# Pretty-printed: the instance may or may not compact its JSON, and a parser
|
||||||
|
# that silently depends on one-line objects is a latent failure (kimi, !130).
|
||||||
|
# Written HERE, like every other fixture: a suite that copies from a scratch
|
||||||
|
# path passes only on the box that built it (grok/kimi, !130 round 2).
|
||||||
|
printf '%s\n' '{
|
||||||
|
"workflow_runs": [
|
||||||
|
{"id": 24, "status": "success"},
|
||||||
|
{"id": 25, "name": "drill", "status": "success"}
|
||||||
|
],
|
||||||
|
"total_count": 2
|
||||||
|
}' > "$FJ/pretty.json"
|
||||||
|
check "verdict: ours is LAST in the payload — order must not decide" 0 "success" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/oldest-first.json"
|
||||||
|
check "verdict: ours is FIRST in the payload — same answer" 0 "success" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/newest-first.json"
|
||||||
|
check "verdict: a stale RUNNING entry ahead of ours does not mask it" 0 "success" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/ours-not-first.json"
|
||||||
|
check "verdict: every entry at or below pre is stale — PENDING" 0 "pending" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/all-stale.json"
|
||||||
|
check "verdict: a pretty-printed payload parses too" 0 "success" \
|
||||||
|
forgejo_run_verdict "24" "$FJ/pretty.json"
|
||||||
|
|
||||||
|
# The PRE-DISPATCH snapshot has the same multi-entry hazard as the verdict, and
|
||||||
|
# getting it wrong is worse: a `head -n1` pre-id on an oldest-first payload
|
||||||
|
# names an OLD run as the baseline, so a later poll that finds the same body
|
||||||
|
# reports the PREVIOUS drill's run as ours — a false PASS on the take-a-job
|
||||||
|
# assertion, where the entry[0] bug only produced a false failure (grok, !130).
|
||||||
|
# Both sides must fold max over every id, which is why they share one function.
|
||||||
|
check "max id: oldest-first payload yields the NEWEST id, not the first" 0 "25" \
|
||||||
|
forgejo_max_task_id "$FJ/oldest-first.json"
|
||||||
|
check "max id: newest-first payload yields the same answer" 0 "25" \
|
||||||
|
forgejo_max_task_id "$FJ/newest-first.json"
|
||||||
|
check "max id: an empty payload has no id at all" 0 "" \
|
||||||
|
forgejo_max_task_id "$FJ/empty.json"
|
||||||
|
check "max id: a pretty-printed payload folds too" 0 "25" \
|
||||||
|
forgejo_max_task_id "$FJ/pretty.json"
|
||||||
|
# The false PASS, pinned end to end: snapshot the oldest-first body, dispatch,
|
||||||
|
# the runner never takes it so the body is unchanged — the verdict must stay
|
||||||
|
# pending. With head -n1 this returned success.
|
||||||
|
# The two halves composed exactly as the leg composes them.
|
||||||
|
verdict_after_no_new_run() { forgejo_run_verdict "$(forgejo_max_task_id "$1")" "$1"; }
|
||||||
|
check "no new run after dispatch: max-id baseline keeps it PENDING (false-PASS guard)" 0 "pending" \
|
||||||
|
verdict_after_no_new_run "$FJ/oldest-first.json"
|
||||||
|
check "…and the same composition on a pretty payload" 0 "pending" \
|
||||||
|
verdict_after_no_new_run "$FJ/pretty.json"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# forgejo_leg_row — the row is the WHOLE lifecycle, not just the job
|
||||||
|
# =============================================================================
|
||||||
|
# codex/grok/kimi on !130: keying the record row on the take-a-job outcome alone
|
||||||
|
# lets it read "PASS — registered, took a job, removed" when install failed, so
|
||||||
|
# long as SOMETHING answered runs-on: drill. That is not contrived — this leg
|
||||||
|
# removes locally and tells the operator to delete the stale runner by hand, so
|
||||||
|
# a leftover drill-labeled runner from the previous drill is the DESIGNED-FOR
|
||||||
|
# aftermath, and it answers the fixture exactly.
|
||||||
|
#
|
||||||
|
# drills/<v>.md is the release's durable evidence. A row claiming a lifecycle
|
||||||
|
# that did not happen is precisely what the gate exists to refuse, so PASS
|
||||||
|
# requires every assertion, not just the interesting one.
|
||||||
|
check "leg row: everything succeeded is the only PASS" 0 "PASS" \
|
||||||
|
forgejo_leg_row 1 1 success 1 1
|
||||||
|
check "leg row: install failed cannot PASS, even when a foreign runner took the job" 0 "FAIL" \
|
||||||
|
forgejo_leg_row 0 1 success 1 1
|
||||||
|
check "leg row: status failed cannot PASS either" 0 "FAIL" \
|
||||||
|
forgejo_leg_row 1 0 success 1 1
|
||||||
|
check "leg row: remove failed cannot PASS" 0 "FAIL" \
|
||||||
|
forgejo_leg_row 1 1 success 0 1
|
||||||
|
check "leg row: a runner still registered after remove cannot PASS" 0 "FAIL" \
|
||||||
|
forgejo_leg_row 1 1 success 1 0
|
||||||
|
check "leg row: no dispatch attempted, everything else clean, is PARTIAL" 0 "PARTIAL" \
|
||||||
|
forgejo_leg_row 1 1 none 1 1
|
||||||
|
check "leg row: a job that was never taken is a FAIL" 0 "FAIL" \
|
||||||
|
forgejo_leg_row 1 1 timeout 1 1
|
||||||
|
check "leg row: PARTIAL requires a clean lifecycle too" 0 "FAIL" \
|
||||||
|
forgejo_leg_row 0 1 none 1 1
|
||||||
|
|
||||||
|
# The end-to-end shape codex/grok/kimi asked for, composed the way the leg
|
||||||
|
# composes it: a tasks payload carrying a NEWER successful run (as a foreign
|
||||||
|
# drill-labeled runner would produce) must still not yield a PASS row when the
|
||||||
|
# drill's own install failed. This is the exact false-evidence case.
|
||||||
|
row_after_failed_install() {
|
||||||
|
forgejo_leg_row 0 1 "$(forgejo_run_verdict "$(forgejo_max_task_id "$1")" "$1")" 1 1
|
||||||
|
}
|
||||||
|
printf '%s' '{"workflow_runs":[{"id":24,"status":"success"},{"id":99,"status":"success"}],"total_count":2}' \
|
||||||
|
> "$FJ/foreign-runner-took-it.json"
|
||||||
|
check "install failed + a newer successful run in the payload is still FAIL, never PASS" 0 "FAIL" \
|
||||||
|
row_after_failed_install "$FJ/foreign-runner-took-it.json"
|
||||||
|
# …and the same payload with a clean lifecycle is the PASS, so the check above
|
||||||
|
# is discriminating rather than always-FAIL.
|
||||||
|
row_after_clean_install() {
|
||||||
|
forgejo_leg_row 1 1 "$(forgejo_run_verdict "" "$1")" 1 1
|
||||||
|
}
|
||||||
|
check "…while the same payload with a clean lifecycle does PASS" 0 "PASS" \
|
||||||
|
row_after_clean_install "$FJ/foreign-runner-took-it.json"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# forgejo_token_verdict — a configured leg that cannot mint must FAIL, not SKIP
|
||||||
|
# =============================================================================
|
||||||
|
# #129's own acceptance: "Token source present but the instance is unreachable
|
||||||
|
# -> the leg FAILS; it must not skip and must not pass". A mint that returns
|
||||||
|
# nothing because the instance is unreachable, the token is under-scoped or the
|
||||||
|
# repo name is wrong is a CONFIGURED leg failing — reporting "no token source"
|
||||||
|
# sends the operator to check an env var they already set, and writes SKIPPED
|
||||||
|
# where the record owes a FAIL. That is the UNREADABLE-vs-NONE shape
|
||||||
|
# drills/README.md names.
|
||||||
|
check "token: a resolved registration token is ok" 0 "ok" \
|
||||||
|
forgejo_token_verdict "reg-tok" ""
|
||||||
|
check "token: an explicit token wins even with no API token" 0 "ok" \
|
||||||
|
forgejo_token_verdict "reg-tok" ""
|
||||||
|
check "token: no token at all and no API token is a genuine SKIP" 0 "no-source" \
|
||||||
|
forgejo_token_verdict "" ""
|
||||||
|
check "token: API token offered but mint produced nothing is a FAILURE" 0 "mint-failed" \
|
||||||
|
forgejo_token_verdict "" "api-tok"
|
||||||
|
|
||||||
|
# The anti-false-positive guard, stated as its own case: an OLD completed run
|
||||||
|
# with the SAME id as pre_id must never be read as this dispatch's result.
|
||||||
|
check "verdict: a pre-existing success with the pre-id is NOT our run" 0 "pending" \
|
||||||
|
forgejo_run_verdict "25" "$FJ/new-success.json"
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
check "--forgejo-instance is a known flag (the leg's opt-in)" 2 "--users <path> is required" \
|
||||||
|
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-instance https://f.example.com --yes
|
||||||
|
check "--forgejo-runner-repo is a known flag" 2 "--users <path> is required" \
|
||||||
|
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-runner-repo o/r --yes
|
||||||
|
check "--help names the forgejo runner leg's flags" 0 "--forgejo-instance" \
|
||||||
|
bash "$ROOT/drill/drill.sh" --help
|
||||||
|
check "--forgejo-ref is a known flag (Forgejo's dispatch needs a ref)" 2 "--users <path> is required" \
|
||||||
|
bash "$ROOT/drill/drill.sh" --rig-ref r --box-ref b --forgejo-ref dev --yes
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
echo "$PASS passed, $FAIL failed"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
189
test/install-lifecycle.sh
Executable file
189
test/install-lifecycle.sh
Executable file
|
|
@ -0,0 +1,189 @@
|
||||||
|
#!/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 ]
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Fixture tests for the labels-reconcile state machine: a comment is a
|
|
||||||
# non-verdict whatever its body says (the AUTHOR escalates by requesting the
|
|
||||||
# human), a stale approval does not promote unreviewed code, and an explicit
|
|
||||||
# human request outranks everything.
|
|
||||||
# Dependency-free beyond jq; no network, no daemon — pure decide_state.
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
# shellcheck source=.github/scripts/labels-reconcile.sh
|
|
||||||
. .github/scripts/labels-reconcile.sh
|
|
||||||
|
|
||||||
# The DRAFT/HEAD_SHA/REQUESTED/REVIEWS_JSON assignments below are the state
|
|
||||||
# machine's inputs, consumed inside the sourced decide_state — not unused.
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
BOT1="${BOTS[0]}" BOT2="${BOTS[1]}" BOT3="${BOTS[2]}"
|
|
||||||
pass=0 fail=0
|
|
||||||
|
|
||||||
expect() { # $1 = description, $2 = want, $3 = got
|
|
||||||
if [ "$2" = "$3" ]; then
|
|
||||||
pass=$((pass + 1))
|
|
||||||
else
|
|
||||||
fail=$((fail + 1))
|
|
||||||
printf 'FAIL: %s — want %s, got %s\n' "$1" "$2" "$3"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review object
|
|
||||||
jq -n --arg u "$1" --arg s "$2" --arg c "$3" --arg b "$4" --arg t "$5" \
|
|
||||||
'{user: {login: $u}, state: $s, commit_id: $c, body: $b, submitted_at: $t}'
|
|
||||||
}
|
|
||||||
|
|
||||||
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
|
|
||||||
|
|
||||||
# -- drafts are building, whoever is requested --------------------------------
|
|
||||||
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
|
|
||||||
expect "draft PR is building" state:building "$(decide_state)"
|
|
||||||
|
|
||||||
# -- fresh ready PR with bots requested ---------------------------------------
|
|
||||||
DRAFT=false REQUESTED="$BOT1
|
|
||||||
$BOT2
|
|
||||||
$BOT3" REVIEWS_JSON='[]'
|
|
||||||
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a bot that never reviewed keeps the round open ---------------------------
|
|
||||||
REQUESTED="" REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)")"
|
|
||||||
expect "missing bot review means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a comment is a non-verdict, agreement body or not: the author escalates --
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" COMMENTED head1 "✅ **Reviewed — I agree with everything.**" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "comment-only agreement still parks on the author" state:addressing "$(decide_state)"
|
|
||||||
# ...and the author's escalation — requesting the human — flips it
|
|
||||||
REQUESTED="$HUMAN"
|
|
||||||
expect "author escalation flips to needs-human" state:needs-human "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- three formal approvals need no author judgment ---------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "three formal approvals reach needs-human" state:needs-human "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a comment WITHOUT a verdict parks the PR on the agent --------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" COMMENTED head1 "🔧 Reviewed — I agree with most; feedback below." t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "comment without verdict is addressing" state:addressing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- changes requested blocks, at any head ------------------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" CHANGES_REQUESTED old1 "blockers below" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a stale approval must not promote unreviewed code ------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED old1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "stale approval is addressing (agent owes re-request)" state:addressing "$(decide_state)"
|
|
||||||
|
|
||||||
# -- a re-requested bot reopens the round even with an old approval on file ---
|
|
||||||
REQUESTED="$BOT1"
|
|
||||||
expect "re-requested bot means bots-reviewing" state:bots-reviewing "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- only the LATEST review per bot counts ------------------------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" CHANGES_REQUESTED head1 "blockers" t1)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t3)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t4)")"
|
|
||||||
expect "later approval supersedes earlier block" state:needs-human "$(decide_state)"
|
|
||||||
|
|
||||||
# -- an explicit human request outranks the bot rounds ------------------------
|
|
||||||
REQUESTED="$HUMAN" REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" COMMENTED head1 "feedback, no verdict" t1)")"
|
|
||||||
expect "human requested outranks bots" state:needs-human "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- human CHANGES_REQUESTED puts the ball back on the agent ------------------
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)" \
|
|
||||||
"$(rev "$HUMAN" CHANGES_REQUESTED head1 "not yet" t4)")"
|
|
||||||
expect "human block with bots approving is addressing" state:addressing "$(decide_state)"
|
|
||||||
# ...and re-requesting the human hands it back to them
|
|
||||||
REQUESTED="$HUMAN"
|
|
||||||
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
# -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "old human comment + three approvals is needs-human" state:needs-human "$(decide_state)"
|
|
||||||
expect "old human comment still needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
# ...a stale human APPROVAL likewise needs a re-request for the new head
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$HUMAN" APPROVED old1 "" t0)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "stale human approval needs a fresh request" needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
# ...a HEAD-CURRENT human approval needs nothing more
|
|
||||||
REVIEWS_JSON="$(reviews \
|
|
||||||
"$(rev "$HUMAN" APPROVED head1 "" t0)" \
|
|
||||||
"$(rev "$BOT1" APPROVED head1 "" t1)" \
|
|
||||||
"$(rev "$BOT2" APPROVED head1 "" t2)" \
|
|
||||||
"$(rev "$BOT3" APPROVED head1 "" t3)")"
|
|
||||||
expect "head-current human approval needs no request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
# ...and a live request suppresses re-requesting
|
|
||||||
REQUESTED="$HUMAN"
|
|
||||||
expect "live human request suppresses re-request" not-needed "$(human_request_needed && echo needed || echo not-needed)"
|
|
||||||
REQUESTED=""
|
|
||||||
|
|
||||||
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
|
||||||
[ "$fail" -eq 0 ]
|
|
||||||
321
test/release.sh
321
test/release.sh
|
|
@ -1,17 +1,16 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# The release flow's testable half (#32): changelog extraction, latest-tag
|
# Rig's own half of the release surface (#32; trimmed in ceremony#13's
|
||||||
# resolution, and the installer's three channels. Dependency-free and
|
# 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
|
||||||
# NETWORK-FREE — wherever the code under test would call curl, the curl on
|
# 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
|
# PATH is a stub this harness wrote. Run: bash test/release.sh
|
||||||
# Deliberately no `set -e` — the harness asserts on failing commands.
|
# Deliberately no `set -e` — the harness asserts on failing commands.
|
||||||
set -u
|
set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$ROOT" || exit 1
|
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
|
PASS=0 FAIL=0
|
||||||
|
|
||||||
# check <desc> <want_exit> <want_substr> <cmd...>
|
# check <desc> <want_exit> <want_substr> <cmd...>
|
||||||
|
|
@ -37,181 +36,20 @@ check() {
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
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"
|
|
||||||
# shellcheck disable=SC2016 # same: positional args resolve inside the inner shell
|
|
||||||
check "CHANGELOG.md: the top section extracts non-empty (the format fits the tool)" 0 "" \
|
|
||||||
bash -c '. "$2/.github/scripts/release-lib.sh"; [ -n "$(changelog_section "$1" "$(grep -m1 "^## " "$1" | awk "{print \$2}")")" ]' _ "$ROOT/CHANGELOG.md" "$ROOT"
|
|
||||||
|
|
||||||
# --- 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 ------------------------------------
|
# --- the installer's ref logic, extracted ------------------------------------
|
||||||
# install.sh must stay a single curl|bash file, so its channel functions live
|
# install.sh must stay a single curl|bash file, so its channel functions live
|
||||||
# inline; extract them here and drive them for real (the valid_version awk
|
# inline; extract them here and drive them for real (the valid_version awk
|
||||||
# idiom from test/cli.sh), against a stub curl — never the network.
|
# idiom from test/cli.sh), against a stub curl — never the network.
|
||||||
RL="$WORK/installer-fns.sh"
|
RL="$WORK/installer-fns.sh"
|
||||||
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL"
|
# Grouped redirect — shellcheck SC2129 flags four individual >> to the same
|
||||||
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL"
|
# file (crossed the threshold when release_tag_url joined the extract set).
|
||||||
|
{
|
||||||
|
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
awk '/^release_tag_url\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
awk '/^install_script_url\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
} > "$RL"
|
||||||
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
|
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
|
||||||
|
|
||||||
STUB="$WORK/stub"; mkdir -p "$STUB"
|
STUB="$WORK/stub"; mkdir -p "$STUB"
|
||||||
|
|
@ -261,14 +99,57 @@ check "resolve: a tagless releases/tag/ redirect fails" 1 "" \
|
||||||
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
|
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
|
||||||
rlt CURL_STUB_FAIL=1
|
rlt CURL_STUB_FAIL=1
|
||||||
|
|
||||||
rcu_line() { # rcu_line <n> — the nth candidate URL for an explicit ref
|
rcu_line() { # rcu_line <n> [VAR=val ...] — the nth candidate URL for an explicit ref
|
||||||
bash -c 'set -euo pipefail
|
local n="$1"; shift
|
||||||
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$1"
|
# shellcheck disable=SC2016
|
||||||
|
env "$@" bash -c 'set -euo pipefail
|
||||||
|
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$n"
|
||||||
}
|
}
|
||||||
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
|
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
|
||||||
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
|
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
|
||||||
check "candidates: refs/heads is the fallback" 0 \
|
check "candidates: refs/heads is the fallback" 0 \
|
||||||
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
|
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
|
||||||
|
# RIG_HOST is host-only (#111): Forgejo serves the same refs/{tags,heads}/
|
||||||
|
# pair (measured 2026-07-29). Host substituted; grammar unchanged.
|
||||||
|
check "candidates: Forgejo host uses the same refs/tags form" 0 \
|
||||||
|
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
|
||||||
|
rcu_line 1 RIG_HOST=https://forgejo.example
|
||||||
|
check "candidates: Forgejo host keeps refs/heads as fallback" 0 \
|
||||||
|
"https://forgejo.example/acme/widgets/archive/refs/heads/1.2.3.tar.gz" \
|
||||||
|
rcu_line 2 RIG_HOST=https://forgejo.example
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "candidates: Forgejo host emits exactly two candidates" 0 "2" \
|
||||||
|
env RIG_HOST=https://forgejo.example bash -c 'set -euo pipefail
|
||||||
|
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | grep -c .' _ "$RL"
|
||||||
|
check "candidates: trailing slash on RIG_HOST is stripped" 0 \
|
||||||
|
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
|
||||||
|
rcu_line 1 RIG_HOST=https://forgejo.example/
|
||||||
|
|
||||||
|
# install_script_url — the curl|bash hint must match the forge (#111).
|
||||||
|
isu() {
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
env "$@" bash -c 'set -euo pipefail
|
||||||
|
REPO=heavy-duty/rig; . "$1"; install_script_url' _ "$RL"
|
||||||
|
}
|
||||||
|
check "install_script_url: GitHub default uses raw.githubusercontent.com" 0 \
|
||||||
|
"https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh" isu
|
||||||
|
check "install_script_url: Forgejo uses /raw/branch/main/" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
|
||||||
|
isu RIG_HOST=https://forgejo.example
|
||||||
|
|
||||||
|
# resolve_latest_tag follows RIG_HOST too — the probe URL must name the forge.
|
||||||
|
rlt_log="$WORK/rlt-log"
|
||||||
|
: > "$rlt_log"
|
||||||
|
check "resolve: RIG_HOST is the releases/latest origin" 0 "0.2.0" \
|
||||||
|
rlt CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/0.2.0 \
|
||||||
|
RIG_HOST=https://forgejo.example CURL_STUB_LOG="$rlt_log"
|
||||||
|
check "resolve: the probe hit the Forgejo host" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/rig/releases/latest" \
|
||||||
|
cat "$rlt_log"
|
||||||
|
# Forgejo's no-release path is a 404 (not GitHub's /releases redirect) —
|
||||||
|
# curl -f fails and || return 1 fires. Drive that branch under RIG_HOST.
|
||||||
|
check "resolve: Forgejo no-release is a failing curl (404), not a /releases redirect" 1 "" \
|
||||||
|
rlt RIG_HOST=https://forgejo.example CURL_STUB_FAIL=1
|
||||||
|
|
||||||
# --- the three channels, driven through the REAL installer -------------------
|
# --- the three channels, driven through the REAL installer -------------------
|
||||||
# Full install.sh runs against throwaway roots with the stub curl on PATH: the
|
# Full install.sh runs against throwaway roots with the stub curl on PATH: the
|
||||||
|
|
@ -312,6 +193,27 @@ check "channel latest: the refusal says what is missing" 1 "no release" \
|
||||||
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
||||||
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
|
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
|
||||||
|
|
||||||
|
# Channel 1, regression — a resolved tag whose archive is gone must FAIL,
|
||||||
|
# never fall through to refs/heads/<tag> and still claim the release
|
||||||
|
# (claude REQUEST_CHANGES on !114: INSTALLED_FROM would name the tag for a
|
||||||
|
# branch tree). CURL_STUB_OK only matches heads — if the installer tries it,
|
||||||
|
# the install would succeed and this check would fail.
|
||||||
|
H2b="$WORK/h2b"; B2b="$WORK/b2b"; LOG2b="$WORK/log2b"
|
||||||
|
check "channel latest: missing tag archive does NOT fall through to heads" \
|
||||||
|
1 "no candidate URL worked" rinst "$H2b" "$B2b" \
|
||||||
|
CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/3.3.3 \
|
||||||
|
CURL_STUB_OK=refs/heads/3.3.3 CURL_STUB_LOG="$LOG2b"
|
||||||
|
check "channel latest: ...and installed NOTHING (branch was never taken)" 1 "" \
|
||||||
|
test -e "$H2b"
|
||||||
|
# The log also holds the releases/latest probe; the download tries are the
|
||||||
|
# archive URLs. Exactly one archive try, and it is refs/tags — never heads.
|
||||||
|
check "channel latest: ...exactly one archive URL was tried" 0 "1" \
|
||||||
|
grep -c '/archive/' "$LOG2b"
|
||||||
|
check "channel latest: ...that try was refs/tags" 0 "refs/tags/3.3.3" \
|
||||||
|
cat "$LOG2b"
|
||||||
|
check "channel latest: ...refs/heads was never consulted" 1 "" \
|
||||||
|
grep -q 'refs/heads/' "$LOG2b"
|
||||||
|
|
||||||
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
|
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
|
||||||
# never consulted (a pin resolves nothing).
|
# never consulted (a pin resolves nothing).
|
||||||
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
|
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
|
||||||
|
|
@ -335,8 +237,65 @@ check "channel dev: ...then the branch URL" 0 "refs/heads/feature-x" \
|
||||||
|
|
||||||
# Neither a tag nor a branch: both candidates miss, and the die says so.
|
# Neither a tag nor a branch: both candidates miss, and the die says so.
|
||||||
H5="$WORK/h5"; B5="$WORK/b5"
|
H5="$WORK/h5"; B5="$WORK/b5"
|
||||||
check "channel: a ref that is neither tag nor branch dies naming both tries" \
|
check "channel: a ref that is neither tag nor branch dies naming the tries" \
|
||||||
1 "not a tag and not a branch" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
1 "no candidate URL worked" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
||||||
|
|
||||||
|
# Channel 4 — RIG_HOST=Forgejo: same refs/tags→refs/heads candidate order and
|
||||||
|
# the same /releases/latest redirect grammar (#111). The stub succeeds only
|
||||||
|
# when the refs/tags form is requested — a regression that still emitted the
|
||||||
|
# bare /archive/<ref> form would fail here.
|
||||||
|
H9="$WORK/h9"; B9="$WORK/b9"; LOG9="$WORK/log9"
|
||||||
|
check "channel forgejo latest: resolves and installs via refs/tags archive URL" 0 "done" \
|
||||||
|
rinst "$H9" "$B9" RIG_HOST=https://forgejo.example \
|
||||||
|
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/7.7.7-relflow \
|
||||||
|
CURL_STUB_OK='/archive/refs/tags/7.7.7-relflow.tar.gz' CURL_STUB_LOG="$LOG9"
|
||||||
|
check "channel forgejo latest: download URL is the refs/tags form" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/rig/archive/refs/tags/7.7.7-relflow.tar.gz" \
|
||||||
|
cat "$LOG9"
|
||||||
|
check "channel forgejo latest: the tree landed" 0 "" \
|
||||||
|
test -x "$H9/versions/7.7.7-relflow/bin/rig"
|
||||||
|
H10="$WORK/h10"; B10="$WORK/b10"
|
||||||
|
check "channel forgejo pinned: RIG_REF=main falls through to refs/heads" 0 "done" \
|
||||||
|
rinst "$H10" "$B10" RIG_HOST=https://forgejo.example RIG_REF=main \
|
||||||
|
CURL_STUB_OK='/archive/refs/heads/main.tar.gz'
|
||||||
|
check "channel forgejo pinned: the tree landed" 0 "" \
|
||||||
|
test -x "$H10/versions/7.7.7-relflow/bin/rig"
|
||||||
|
# Refusal hint on a non-GitHub host must not send the operator to
|
||||||
|
# raw.githubusercontent.com (that 404s from a Forgejo-only tree).
|
||||||
|
H11="$WORK/h11"; B11="$WORK/b11"
|
||||||
|
check "channel forgejo latest: no-release hint uses the Forgejo raw URL" \
|
||||||
|
1 "https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
|
||||||
|
rinst "$H11" "$B11" RIG_HOST=https://forgejo.example \
|
||||||
|
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases
|
||||||
|
|
||||||
|
# --- 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"
|
rm -rf "$WORK"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue