Merge pull request #100 from dan-claude-bot/docs/changelog-one-line
docs(changelog): one line per entry, and a pass over the whole file
This commit is contained in:
commit
b8ce8e5e1c
2 changed files with 70 additions and 815 deletions
853
CHANGELOG.md
853
CHANGELOG.md
|
|
@ -8,843 +8,66 @@ on the way to cutting its first release, and this file starts there.
|
|||
|
||||
### Fixed
|
||||
|
||||
- **Deleting a shipped release heading from `CHANGELOG.md` is now caught, on
|
||||
every PR** (#98, heavy-duty/box#122) — the arming rule (#66) asks one
|
||||
question about **one** heading: does the top section agree with `VERSION`?
|
||||
It says nothing about the rest of the file, and the failure that lives there
|
||||
is an author adding an entry under `## Unreleased` who *replaces* the line
|
||||
`## 0.2.0 — 2026-07-19` below it instead of inserting above it. git merges
|
||||
that cleanly — a one-line edit in a file nobody touched concurrently, so no
|
||||
conflict and no signal — and `changelog_armed()` stays green on that exact
|
||||
tree, correctly: `## Unreleased` is still on top and still right for
|
||||
`VERSION`. Verified here rather than assumed: the arming rule was run
|
||||
against a deliberately mangled copy of this repo's real tree and passed,
|
||||
while `changelog_section()` extracted `0.2.0` to zero lines. The damage
|
||||
would have surfaced a whole release later, when `release.yml` refused to
|
||||
publish an empty section it could no longer find by heading.
|
||||
|
||||
`.github/scripts/changelog-monotonic.sh` asserts the complementary
|
||||
invariant: release headings are **append-only**, so the set of `## X.Y.Z`
|
||||
headings on a branch must be a **superset** of the set at its merge base.
|
||||
Its own script, not a clause in the arming check, because "a heading
|
||||
disappeared" is a property of a DIFF, not of a tree — and because the arming
|
||||
rule is driven from `test/release.sh` against constructed `VERSION` +
|
||||
`CHANGELOG.md` pairs that are not git repos and could not express it. The
|
||||
ceremony's stamp passes by construction (it *adds* `X.Y.Z` and removes none;
|
||||
`Unreleased` fails the version shape and is deliberately outside the guarded
|
||||
set), and no reachable base ref is a loud SKIP locally but a hard failure in
|
||||
CI, which sets `CHANGELOG_MONOTONIC_STRICT=1` and now checks out with
|
||||
`fetch-depth: 0` so the guard can never quietly stop guarding.
|
||||
|
||||
It carries box's second half too: containment catches a **deleted** heading
|
||||
but cannot catch a **duplicated** one, since the duplicate is head-side
|
||||
surplus and `comm -23` (base minus head) is blind to extras on the head side
|
||||
— with or without `sort -u`, and multiset comparison does not close it for
|
||||
the same reason. So version headings are also asserted **unique on HEAD**.
|
||||
rig's symptom there is not box's, and the guard says so in its own words:
|
||||
box's extractor re-arms on every matching `## ` line and *absorbs* whatever
|
||||
sits between the copies, while rig's `changelog_section()` has
|
||||
`if (found) exit` and stops dead at the second copy — a duplicate
|
||||
**truncates**, publishing only what sits between the two headings and
|
||||
silently dropping the release's real body underneath. Different symptom,
|
||||
same class: no conflict, no red run, found only by a human reading the
|
||||
published notes.
|
||||
|
||||
- **...and the uniqueness half no longer sits behind conditions it does not
|
||||
depend on** (#98, heavy-duty/box#143) — containment is a property of a DIFF
|
||||
and genuinely needs the base ref, the merge base and the base blob.
|
||||
Uniqueness is a property of **HEAD alone** and needs none of the three. It
|
||||
sat downstream of all of them anyway, so every degradation path returned
|
||||
success on a tree carrying a duplicate in plain sight.
|
||||
|
||||
The base-blob case was the worst of the three, because it was not a skip at
|
||||
all: a branch that *introduces* `CHANGELOG.md` hit a bare `exit 0` on a
|
||||
message that was true about deletion and silent about the duplicate in front
|
||||
of it. `STRICT=1` could not reach it — STRICT guards the two `skip()` calls,
|
||||
and that path is not one of them. Off CI the two skips had the same shape, so
|
||||
a shallow clone or an unpacked tarball would not look at a duplicate the
|
||||
author was about to push.
|
||||
|
||||
That inverted the value of the two halves. Deletion is the failure that needs
|
||||
a diff to see; duplication is the one `changelog_section()` actually
|
||||
mis-renders, stopping dead at the second copy and truncating the release's
|
||||
real body. The half with the live extraction bug behind it was the half with
|
||||
the most ways to silently not run.
|
||||
|
||||
Fixed by moving, not rewriting: uniqueness now runs directly after the file
|
||||
exists, before any git access, under a boundary comment saying so. The skip
|
||||
messages say *containment* skipped and that uniqueness already passed, so a
|
||||
skip no longer claims nothing was checked — and the **success** line got the
|
||||
same treatment, because dropping the gate below made `merge_base == HEAD` a
|
||||
routine path rather than a degradation. On a push to main containment
|
||||
compares the file against itself and asserts nothing, so the line now reports
|
||||
containment *vacuous* and names uniqueness as the half that ran, instead of
|
||||
claiming N headings were verified present by a comparison that could not have
|
||||
detected their absence. Both forms are pinned, including a negative that the
|
||||
two do not collapse.
|
||||
|
||||
The CI step is also no longer gated to `pull_request` — deletion is vacuous
|
||||
on a push to main, but duplication is vacuous on no tree, so a duplicate
|
||||
reaching main by any other route went unasserted. The pin that holds that
|
||||
open is scoped to the step's own block: as a file-wide grep it forbade any
|
||||
FUTURE step in `ci.yml` from being `pull_request`-gated, and a companion
|
||||
check keeps the block extractor from silently matching nothing and turning
|
||||
the negative into a tautology. That gate could not simply be dropped:
|
||||
`github.base_ref` is empty on a push, and a bare `origin/` under `STRICT=1`
|
||||
is a hard failure on *every* push to main, so the base ref falls back to
|
||||
`github.ref_name`. The regression cases pin the ORDER rather than the exit
|
||||
code, since the clean base-absent tree is green either way — asserting only
|
||||
the code is what let the original ship. `changelog-monotonic.sh` is also now
|
||||
`100755`, matching cast's copy of the same file.
|
||||
|
||||
- **An unreadable check rollup no longer reads as "nothing is failing"** (#90)
|
||||
— when `gh pr view` failed, the fallback left the `statusCheckRollup` key
|
||||
absent entirely, and `(.statusCheckRollup // [])` collapsed that into the
|
||||
same `NONE` as a PR that genuinely has no checks. `NONE` blocks nothing, so
|
||||
a transient API failure presented the PR as mergeable by a human: an unknown
|
||||
certified as green, which is the exact shape of the bug #87 was opened to
|
||||
stop, surviving in the one place that fix never looked.
|
||||
|
||||
`checks_state` now separates the two — `UNREADABLE` for an absent key (a
|
||||
read that failed), `NONE` for a present-but-empty array (a PR that really
|
||||
has no checks) — and the sweep leaves an `UNREADABLE` PR exactly as it
|
||||
found it rather than recomputing labels from facts it did not read.
|
||||
Deliberately *not* a blocker: blocking would flap the whole board on one bad
|
||||
API call, and the next tick is fifteen minutes away. Caught by the author
|
||||
after opening the PR, not by review.
|
||||
|
||||
- **CI runs `test/labels-reconcile.sh`, which it had never run** (#90) — the
|
||||
file arrived with #87 and `ci.yml` was not extended to call it, so the label
|
||||
state machine that gates every PR in this repo went covered only by whoever
|
||||
remembered to run its fixtures by hand. #88 merged reporting 51 passing
|
||||
fixtures: true on the author's machine, never once verified here. Box and
|
||||
cast both ran the suite already; only rig did not, so this closes a rig-local
|
||||
gap rather than a family-wide one. It was found by asking, while adding
|
||||
fixtures to the suite, where the suite actually ran.
|
||||
|
||||
- **`state:needs-human` no longer appears on PRs a human cannot merge**
|
||||
(#87, heavy-duty/box#136) — `decide_state()` derived state from three inputs
|
||||
(draft flag, requested reviewers, submitted reviews) and read *nothing* about
|
||||
mergeability or checks. Combined with the `if requested "$HUMAN"`
|
||||
short-circuit at the top of its precedence, the label was **sticky**: once
|
||||
the maintainer was requested, the PR read `state:needs-human` through
|
||||
conflicts, through red CI, through a force-push that staled every approval.
|
||||
Nothing demoted it.
|
||||
|
||||
This repo paid for it directly. During the ten-PR batch merged on 2026-07-20,
|
||||
every merge re-conflicted the PRs below it through `CHANGELOG.md` — and each
|
||||
one kept its `state:needs-human` label the whole time, inviting a merge that
|
||||
could not happen. It was noticed only by opening them one at a time, which is
|
||||
the exact work the label exists to save.
|
||||
|
||||
The rule the label now keeps is that **`state:needs-human` means a human
|
||||
could merge this right now**, so anything making that false outranks the
|
||||
request that put it there. A `CONFLICTING` branch or a failing check is the
|
||||
agent's to fix: new `state:needs-rebase`. Approvals staled by a push mean
|
||||
nobody reviewed this tree: `state:addressing`, because the agent owes a
|
||||
re-request. An *unfinished* round still yields to an explicit human request —
|
||||
a maintainer pulling a PR to themselves early is deliberate, and `MISSING`
|
||||
(nobody has reviewed yet) is a different fact from `STALE` (everyone reviewed
|
||||
something else). Precedence is applied to the round as a whole, after every
|
||||
verdict is collected: deciding inside the loop let the order of `BOTS` pick
|
||||
the answer, so a round that was *both* unfinished and staled returned on the
|
||||
`MISSING` before any later bot's `STALE` was read — and came out
|
||||
`needs-human` over a head nobody had reviewed, the original bug wearing a
|
||||
different hat.
|
||||
|
||||
Whether a check blocks is judged by listing the outcomes that *don't* —
|
||||
`SUCCESS`, `NEUTRAL`, `SKIPPED`, and the pending set — rather than the
|
||||
outcomes that do. The rollup mixes two closed enums (`CheckRun.conclusion`
|
||||
and `StatusContext.state`), and an outcome the list forgets is one the label
|
||||
cannot certify as mergeable: `ERROR`, `CANCELLED` and `STALE` all read as
|
||||
green under an allow-list of failures. The costs are not symmetric — a false
|
||||
failure parks the PR on the agent, who looks; a false success invites a human
|
||||
to merge a tree that will not merge. Superseded runs are dropped first, each
|
||||
context collapsing to its newest entry: a re-run does not evict the run it
|
||||
replaced, so box#137's own tip carried a `CANCELLED` `scope` beside the
|
||||
`SUCCESS` `scope` that superseded it, and judging every entry would have
|
||||
stranded every re-run PR in `needs-rebase`.
|
||||
|
||||
Which entry is newest is dated by **when the run began** — `startedAt`,
|
||||
falling back only if it never recorded one — discarding *both* spellings of
|
||||
absent. A run still in flight has no completion, but `gh` does not omit the
|
||||
field: its Go struct marshals the zero time as the string
|
||||
`0001-01-01T00:00:00Z`, and `//` falls through `null` and `false` only.
|
||||
Dating by completion therefore sorted the *live* re-run to the bottom and
|
||||
let `last` pick the very run it superseded — a green context with a
|
||||
replacement mid-flight read `SUCCESS`, inviting a merge the button had
|
||||
already disabled, which is #136 restored by the fix for it.
|
||||
|
||||
Taking the *newest* stamp each run carries is not a fix either, and this is
|
||||
the subtle part: it compares a finished predecessor by when it **ended**
|
||||
against a live successor by when it **began**, which is not an ordering on
|
||||
runs at all. A run cancelled by the concurrency group does not stop the
|
||||
instant its replacement starts — the runner has to receive the signal and
|
||||
wind down — so the predecessor completing *after* the successor started is
|
||||
the ordinary case, 13s wide on the box#137 tip that motivated the supersede
|
||||
rule. For that whole drain window the dying predecessor out-dated its own
|
||||
replacement. One consistent quantity, start time, is the only ordering that
|
||||
holds. An entry carrying no usable timestamp at all sorts last rather than
|
||||
first, so something that cannot be dated is never discarded in favour of a
|
||||
stale success. Every ambiguity here resolves toward "not settled".
|
||||
|
||||
`UNKNOWN` mergeability is deliberately not treated as unmergeable: GitHub
|
||||
reports it for about a minute after every merge while it recomputes, and
|
||||
flapping every open PR through `needs-rebase` on each merge would be worse
|
||||
than the bug. A failed read of either fact degrades to the same "do not know"
|
||||
value, for the same reason.
|
||||
|
||||
Also adds `merge-next`, because a correct `needs-human` still does not say
|
||||
*which* PR to merge first, and order matters when they conflict. Queue order
|
||||
is intent, so the reconciler never sets it — it only **clears** it once the
|
||||
PR stops being mergeable-by-a-human, which is precisely the staleness that
|
||||
made `needs-human` untrustworthy. Both live shapes, the mixed round, the
|
||||
whole check-outcome enum, and the re-run in every temporal arrangement it
|
||||
occurs in — superseded, still in flight in both spellings of an absent
|
||||
completion, undateable, and still draining past its replacement's start —
|
||||
are pinned in `test/labels-reconcile.sh`. Ported from heavy-duty/box#137 so
|
||||
the three repos' reconcilers stay byte-identical; fixtures 19 → 51.
|
||||
|
||||
- **A missing `/run/sshd` no longer reads as a broken sshd config** (#92) —
|
||||
`sshd -t` folds two questions into one exit code: is the merged config
|
||||
parseable, and is the privilege-separation directory there. Both call sites
|
||||
that validate before bouncing the daemon ran it as `sshd -t 2>/dev/null` and
|
||||
read *any* non-zero exit as the first question's answer, discarding the one
|
||||
line that named the second. Bootstrap aborted with `sshd rejects the merged
|
||||
config` — a verdict sshd never reached — and the follow-up hint, `Run 'sshd
|
||||
-t' to see which file is bad`, sent the operator to audit `/etc/ssh` files
|
||||
that were never broken, on a box whose SSH door was serving their own session
|
||||
at that moment.
|
||||
|
||||
The absent directory is not an exotic state. `/run` is a tmpfs and
|
||||
`/run/sshd` is `ssh.service`'s `RuntimeDirectory`, which systemd creates with
|
||||
that unit and removes when it stops — so it is legitimately gone under socket
|
||||
activation (`ssh.socket`, the default on current Debian/Ubuntu), where
|
||||
per-connection `ssh@.service` instances serve the door and no long-lived unit
|
||||
holds the directory open. Reported from a box mid-bootstrap right after an
|
||||
`openssh-server` upgrade.
|
||||
|
||||
The classification is now a pure, sourceable `sshd_privsep_gap` — the STATUS
|
||||
is the verdict and the text only classifies a *failure*, so a passing `sshd
|
||||
-t` is never diverted whatever its output says — and `sshd_config_ok` repairs
|
||||
the gap with an idempotent `install -d` and retests once. The repair creates
|
||||
exactly what systemd would and is not meant to outlive a reboot; by then the
|
||||
ssh unit has recreated it. **A genuine parse refusal still refuses, and the
|
||||
rollback is untouched**: the daemon is never bounced into a config it rejects.
|
||||
|
||||
The refusals now carry sshd's own stderr instead of asserting a cause and
|
||||
withholding the evidence for it. `rig users close-root` had the identical
|
||||
three lines — the more dangerous copy, since it is the command that shuts the
|
||||
root door — and now reaches the shared judgement through `lib/sshd.sh` rather
|
||||
than keeping a second copy of it, which is what #31 extracted that lib for
|
||||
and what let this bug ship twice.
|
||||
- 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
|
||||
|
||||
- **`rig platform` — what is this machine, calculated at run time, stored
|
||||
nowhere** (#64) — rig read no hardware at all; the single exception was
|
||||
`uname -m` in `runner-install.sh`, used to pick a tarball and then
|
||||
discarded. So "is this the 32GB one, or the M900?" was answered by logging
|
||||
in and running `free -h`, `nproc`, `df -h` and `uname -r` by hand, four
|
||||
commands deep, on a machine you were already unsure about. `rig platform`
|
||||
prints hostname, OS, kernel, CPU, memory, disk and virtualization, then a
|
||||
provenance block (which rig, when, and the role marker's traits). It
|
||||
**computes rather than stores**: specs change without rig doing anything —
|
||||
RAM added, root disk resized, the unattended-upgrades bootstrap itself
|
||||
enables patching the kernel — so a stored spec is stale the moment the
|
||||
machine changes, and refreshing one per run would collide with bootstrap's
|
||||
"a second run changes nothing" contract. Nothing is written, so nothing can
|
||||
go stale. The corollary is deliberate: reading only `/proc`, `uname`,
|
||||
`/etc/os-release`, `df` and `systemd-detect-virt` means it needs no root,
|
||||
makes no network call, and **runs on a pristine Debian box rig has never
|
||||
bootstrapped** — useful for deciding what to converge a machine into, not
|
||||
only for auditing it afterwards. That also makes it the rare rig command
|
||||
the harness can RUN for real instead of grepping: the tests assert the
|
||||
actual answer describes the actual test machine. Provenance is read, never
|
||||
written, and degrades per-file — `/etc/rig/manifest` is #61 and does not
|
||||
exist yet, so those lines read `not bootstrapped` on every machine today and
|
||||
nothing else depends on it. The reader is keyed to #61's documented schema
|
||||
(`schema`, `bootstrapped_by`/`_at`, `converged_by`/`_at`) and fixtures pin
|
||||
that exact spelling, so the integration cannot land silently broken; birth
|
||||
and latest stay separate rather than one being inferred from the other. A
|
||||
fresh machine writes both pairs equal, so two identical lines read as
|
||||
"never re-converged"; a manifest missing the pair is partial rather than
|
||||
fresh, and says so instead of backfilling from birth. Named `platform` and not `status` on purpose:
|
||||
`users status` and `runner status` cross-check recorded against live state
|
||||
and print `DRIFT`, and a command that records nothing cannot drift — which
|
||||
also leaves `rig status` free for the machine-wide roll-up. Known
|
||||
limitation, stated rather than guessed at: `CPU`/`MEMORY` are read from
|
||||
`/proc` with no cgroup awareness, and whether an `lxc` guest sees its own
|
||||
limits or the host's totals depends on whether `lxcfs` is in play — it is
|
||||
unverified, so those two lines are unreliable there.
|
||||
|
||||
- **`/etc/rig/manifest` records which rig converged a machine, and when**
|
||||
(#61) — the entire durable output of a bootstrap run was one line in
|
||||
`/etc/rig/role`, and that line is about what the box *is*, never about what
|
||||
built it. `VERSION` was read in exactly one place (`bin/rig:9`, for
|
||||
`--version`) and that reports the *currently installed* tree, not the one
|
||||
that ran; there was no timestamp anywhere in the codebase. SSH into a
|
||||
control plane six months on and a machine converged by `0.1.0-dev` was
|
||||
indistinguishable from one converged by `0.4.0`. Bootstrap — both the
|
||||
machine roles and the box tenant roles — now stamps a second file beside
|
||||
the marker: `schema=1`, `bootstrapped_by`/`bootstrapped_at` (the rig that
|
||||
*first* converged this machine, pinned forever) and
|
||||
`converged_by`/`converged_at` (the newest rig to have converged it), read
|
||||
back with a new `rig manifest [<key>]`. `key=value`, one per line, `0644` —
|
||||
never JSON or YAML, because this is the one file that must stay readable on
|
||||
the most broken machine in the fleet and a rig-bootstrapped box has no YAML
|
||||
parser and no `jq`.
|
||||
|
||||
Only **decided** facts go in, which is what keeps `bootstrap.sh:3`'s
|
||||
contract ("a second run changes nothing", enforced by cmp-guards at nine
|
||||
sites) intact: `bootstrapped_*` is first-write-wins, and `converged_*`
|
||||
updates **only when the version actually differs** — it is the time the
|
||||
converging version last changed, not the time of the last run. A naive
|
||||
timestamp would have made every re-run a diff and had rig report a change it
|
||||
did not make. **Observed** facts — cores, RAM, disk, kernel — are
|
||||
deliberately absent: they go stale without rig doing anything, so they
|
||||
belong to `rig platform` (#64), which computes them fresh and stores
|
||||
nothing. `/etc/rig/role` is untouched — the marker holds traits and has six
|
||||
readers; the manifest holds provenance. Readers must ignore keys they do not
|
||||
know, and the writer preserves lines it does not own, so a manifest written
|
||||
by a newer rig stays readable to (and survives a rewrite by) an older one.
|
||||
- `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` 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 `state:needs-human` themselves when handing
|
||||
a PR to the maintainer.
|
||||
|
||||
A review landing was never a trigger. There is no
|
||||
`pull_request_review_target`, and on fork PRs — which is all of them here —
|
||||
`pull_request_review` runs with a read-only token and cannot label anything.
|
||||
So the exact moment the label became true, the third bot approving, fired
|
||||
nothing at all: the label waited for the `*/15` cron, or for somebody to
|
||||
touch an unrelated PR. And that cron does not run at its declared rate —
|
||||
GitHub deprioritises short intervals hard enough that, measured across the
|
||||
three repos over a two-hour window on 2026-07-20, each got **one** scheduled
|
||||
run against the eight `*/15` implies. heavy-duty/rig#94 took its third
|
||||
approval and sat on `state:bots-reviewing` for hours; box and cast happened
|
||||
to catch a tick and flipped correctly, on byte-identical workflow files. The
|
||||
lag was worst on the quietest repo, which depends on the cron most and
|
||||
receives it least.
|
||||
|
||||
The two halves fix each other: the author's own label write is what fires
|
||||
the sweep that validates it. That makes it an optimistic write rather than a
|
||||
transfer of ownership — seconds later the reconciler either confirms the
|
||||
label or corrects it, and the cron falls back to being a genuine last
|
||||
resort, for the round an agent forgets to hand off. It cannot loop, because
|
||||
the reconciler's own writes use `GITHUB_TOKEN` and GitHub does not create
|
||||
workflow runs from `GITHUB_TOKEN`-triggered events, while agent writes use a
|
||||
PAT and do. `labels-reconcile.sh` is unchanged: it already recomputes every
|
||||
open PR from scratch on every run, which is exactly what makes the
|
||||
optimistic write safe. The `scope` job is skipped on the two new actions,
|
||||
where no path can have changed and labeler has nothing to derive.
|
||||
|
||||
Landed in all three repos together (heavy-duty/box#142,
|
||||
heavy-duty/cast#132) — `labels.yml` and the label taxonomy are shared.
|
||||
|
||||
- **PR labels split into two axes: `state:*` (whose ball) and `blocker:*`
|
||||
(what is in the way)** (heavy-duty/box#137) — `state:needs-rebase`, added
|
||||
here only days ago by #87, is retired in the same breath. In its place:
|
||||
`blocker:conflict`, `blocker:ci-red` and `blocker:unrequested`, applied
|
||||
additively. A single rule joins the axes — `state:needs-human` requires zero
|
||||
blockers — which is the invariant #87 was reaching for, stated once instead
|
||||
of defended at every branch of a precedence chain.
|
||||
|
||||
The single-label design projected independent facts onto one totally-ordered
|
||||
value. Mergeability, check status and the review round move on their own
|
||||
clocks; a PR can be conflicted *and* red *and* stalled at the same instant.
|
||||
A total order has to pick one of those to say, so the rest vanish. Every
|
||||
precedence bug this machine has had lived on that ordering, #87's included:
|
||||
the fix there was to reorder the chain and collect the round before deciding,
|
||||
which bought correctness for one more configuration without removing the
|
||||
reason the next one would break. `state:needs-rebase` was the design's
|
||||
clearest tell — a single label fired by both a conflict and a failing check,
|
||||
two problems needing opposite work, telling an agent to rebase when what it
|
||||
owed was a bug fix. Box's board has the case in the open: #120 was conflicted
|
||||
**and** red, and could only ever say one of them.
|
||||
|
||||
Blockers are a set. There is no precedence between them to get wrong, and
|
||||
adding a fourth one later cannot reshuffle the meaning of the other three.
|
||||
What stays on the ordered axis is purely the review round, which is the one
|
||||
place here where an ordering is genuinely meaningful — a round really does
|
||||
have a sequence.
|
||||
|
||||
`state:bots-reviewing` tightens with it, to mean strictly *a request is live
|
||||
and an answer is coming*. A ready PR nobody was asked to review used to read
|
||||
as "waiting on the reviewers" until the stale sweep caught up; it now reads
|
||||
`state:addressing` + `blocker:unrequested`, because the agent owes the ask
|
||||
and the board should say so. `blocker:unrequested` covers both shapes of
|
||||
"this head has no verdict from somebody": nobody reviewed it, or everybody
|
||||
reviewed an older tree and the approvals staled behind a push. The second is
|
||||
the worse of the two, since it leaves approvals on the page that no longer
|
||||
describe the code. Drafts stay exempt — the bots ignore drafts by design —
|
||||
as does an explicit human request, since a maintainer claiming a PR early is
|
||||
deliberate.
|
||||
|
||||
The reconciler strips `state:needs-rebase` on sight via a `RETIRED` list, so
|
||||
the retirement heals the existing board instead of stranding a label that
|
||||
nothing recomputes. It also filters every label it is about to *add* against
|
||||
the repo's actual label set, read once per sweep: `gh issue edit` rejects the
|
||||
whole call on one unknown name, so on a repo that has not yet bootstrapped
|
||||
the new `blocker:*` labels a single missing one would have taken the state
|
||||
convergence down with it — on exactly the PRs this change exists to heal.
|
||||
Now the state still converges and the missing labels are named in the log.
|
||||
Fixtures 51 → 72.
|
||||
|
||||
- **BREAKING: `--class human|server` is now `--root-door closed|open`** (#77) —
|
||||
the trait was named for who *lives on* a box; what it decides is one thing,
|
||||
and it is not occupancy: whether root SSH stays open as the control plane's
|
||||
automation door, or `rig users close-root` shuts it once named operators can
|
||||
get in. The roles had been saying so for a while. `dev-server` is an
|
||||
unattended VM-host appliance — nobody lives there, operators visit it to mint
|
||||
boxes and leave — and by occupancy it is plainly a server. It was
|
||||
`class=human` anyway, and correctly so, because operators enter it *as
|
||||
themselves* and its root door must close. The trait was right; its name
|
||||
described the wrong axis.
|
||||
|
||||
That stayed cheap until a second thing wanted the word "server". After #76
|
||||
the `-server` suffix names the machine *family*, so `dev-server` carried a
|
||||
suffix saying server and a trait saying human, and nothing in the name told a
|
||||
reader that the two words were answering unrelated questions. `dev-server
|
||||
--root-door closed` says exactly what is true, and `-server` means one thing
|
||||
everywhere. The values moved with the name: `human` → `closed`, `server` →
|
||||
`open`, and the marker field follows as `root-door=`.
|
||||
|
||||
Other names were considered. `--root-door open|closed` describes a
|
||||
*destination* rather than the state at bootstrap time — bootstrap leaves root
|
||||
SSH open on every box, and the door only shuts later, when `close-root` runs
|
||||
— so `--root-door closes|stays` was on the table for naming the fate as a
|
||||
verb, as was `--automation-door yes|no` for naming the thing itself. Both were
|
||||
rejected in favour of the plainer pair: the marker is already a declaration of
|
||||
*intent* rather than a report of observed state everywhere else in this repo
|
||||
(`host=yes` claims a box hosts VMs; #58 settled that the marker's claim wins
|
||||
over probing the machine), so a trait that states the door's designed end
|
||||
state is consistent with how every other field is read. Every string that
|
||||
prints the trait says "once operators exist" or names `close-root` explicitly,
|
||||
so the tense never has to be inferred.
|
||||
|
||||
**Old markers still resolve, permanently, and that is the substance of this
|
||||
change.** Unlike #76's role rename — role names are informational, nothing
|
||||
reads them back — this field is written into `/etc/rig/role` and read *from*
|
||||
there on live machines, where it gates `rig users close-root`. Every box
|
||||
bootstrapped before this carries `class=human` or `class=server` and carries
|
||||
it until someone re-bootstraps it, which for a fleet is never. Dropping the
|
||||
old read would have broken in both directions at once and both are incidents:
|
||||
a machine whose door is supposed to close loses the ability to close it, and
|
||||
— through `bootstrap-tenant`'s machine-marker guard, which used the presence
|
||||
of `class=` as its "is this a real fleet machine?" test — a live box stops
|
||||
looking like a machine at all, so a tenant converge sails past the refusal
|
||||
that exists to protect it and clobbers its marker. That second one is the
|
||||
fail-*open* direction and was the least obvious part of the change.
|
||||
|
||||
So one resolver, `root_door_of`, reads both vocabularies, and every consumer
|
||||
goes through it — close-root's gate, apply's root-SSH note, and the tenant
|
||||
guard — because a compat read that lives at three call sites is three chances
|
||||
to drift. `root-door=` wins where both fields are present and agree;
|
||||
`class=` answers alone on every pre-#77 marker. A marker carrying **both and
|
||||
disagreeing** resolves to a refusal rather than a winner: bootstrap writes one
|
||||
line fresh and never produces that state, so a marker in it was hand-edited,
|
||||
and rig declines to arbitrate between two equally-authored claims about a root
|
||||
door. A marker naming **neither** refuses too, unchanged from before. Both
|
||||
refusals fail closed, which here means the door stays open and the operator is
|
||||
told to re-run bootstrap — never a door welded shut on a machine whose only
|
||||
entrance it was.
|
||||
|
||||
The resolver matches **whole fields, not substrings** — the marker is one
|
||||
line of space-separated `key=value` pairs, so it pads both ends and matches
|
||||
on field boundaries. Review caught the first cut doing unanchored matching,
|
||||
which resolved any value that *extended* a real one: `root-door=closedish`
|
||||
read as `closed` and passed close-root's gate — the single arm that
|
||||
authorizes an irreversible act — and `class=humanoid` did the same through
|
||||
the compat arm, both contradicting the resolver's own promise that a value
|
||||
outside the set resolves empty and fails closed. Only reachable by hand-
|
||||
editing a marker, so never a live incident, but this is the one function
|
||||
every consumer trusts and it owes them exactness rather than nearly. Both
|
||||
vocabularies are anchored: fixing only the current spelling would have left
|
||||
every pre-#77 box carrying the hole. Whitespace is normalised first, so a
|
||||
hand-edit using tabs reads the same rather than trading one silent misread
|
||||
for another.
|
||||
|
||||
**New markers are written in the new vocabulary only.** Writing both would
|
||||
keep an old rig reading a new marker, but it would entrench the retired
|
||||
spelling on every box rig ever converges and make the disagreement case
|
||||
reachable from rig's own hand instead of only from a text editor. The compat
|
||||
obligation runs the other way and only the other way: new rig reads old
|
||||
markers. The bounded consequence to know about is downgrade — flipping a
|
||||
box back to a pre-#77 rig with `rig use` leaves that older code unable to
|
||||
recognize the new marker; the flip already WARNS on a bootstrapped host (#35),
|
||||
and re-running bootstrap under whichever rig you settle on rewrites the line.
|
||||
|
||||
The suite proves the compat read rather than asserting it. Fixture markers are
|
||||
kept **deliberately** at the retired spelling — byte for byte as a real
|
||||
pre-#77 box reads, the same convention #76's `pre-rename-cp` fixture
|
||||
established — and pinned at both consumers: `close-root` still passes on
|
||||
`class=human` and still *refuses* on `class=server`, with today's refusal text
|
||||
naming today's flag, and the tenant guard still recognizes a pre-#77 machine
|
||||
marker as a machine. Deleting the compat arm turns ten of them red.
|
||||
|
||||
- **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other
|
||||
half of the rename below. `claude` → `claude-box`, `codex` → `codex-box`,
|
||||
`grok` → `grok-box`, `staging` → `staging-box`, so a role name always says
|
||||
which family it belongs to: `-server` builds a fleet machine, `-box`
|
||||
converges a guest a box minted.
|
||||
|
||||
**The role carries the suffix; nothing inside the guest does.** A tenant user
|
||||
is the account the box *seed* created (`BOX_USER`) and each agent CLI reads
|
||||
its own dotdir, so `claude-box` still converges the `claude` user and still
|
||||
writes `~/.claude/CLAUDE.md`. The suffix is rig's word for "this is a guest",
|
||||
not a rename of anything the guest contains — no path, no account, and no CLI
|
||||
binary moved.
|
||||
|
||||
**Migration: hard cut, no aliases**, same as the machine roles. The old names
|
||||
are refused as unknown tenant roles at both entrypoints — `rig bootstrap
|
||||
<name>` and the tenant script directly — and the suite asserts each one at
|
||||
both, because an alias left in for a single tenant is exactly the shape that
|
||||
survives review: the taxonomy reads complete while one old name still quietly
|
||||
converges. The practical consequence is cross-repo: a box seed carrying
|
||||
`BOX_BOOTSTRAP_ROLE="claude"` now fails its own mint-time bootstrap, so
|
||||
heavy-duty/box#125 (closing heavy-duty/box#123) updates the seeds and must
|
||||
land after this.
|
||||
|
||||
- **BREAKING: machine roles carry a `-server` suffix, and the VM host gets its
|
||||
name back** (#76) — rig builds two kinds of thing that sit on opposite sides
|
||||
of a trust boundary: tailnet **machines** it converges, and **guests** a box
|
||||
mints. Both families lived in one flat namespace, and no role name said which
|
||||
one you were asking for. `staging` is where that stopped being cosmetic — the
|
||||
word names the metal that hosts guests *and* the guests on it, only one of
|
||||
them could have the name, and #31 gave it to the guests. The VM-host shape
|
||||
was left with no name at all, spelled `custom --class server --host yes
|
||||
--join authkey`, which is what every refusal in the tree recited at an
|
||||
operator who had confused the two.
|
||||
|
||||
So the suffix names the family: `control-plane-server`, `workload-server`,
|
||||
`runner-server`, `dev-server`, and the restored `staging-server`
|
||||
(`class=server host=yes join=authkey` — the preset #31 retired, back under a
|
||||
name that cannot be mistaken for its own guests). `host=yes` already installs
|
||||
the box CLI and runs box's `setup-host`, so `staging-server` is a table row
|
||||
rather than new machinery, and it stays **out** of the `tag:server`
|
||||
allow-list on purpose: a host is never managed by the control plane, its
|
||||
guests are, so mint its key with `tag:local`.
|
||||
|
||||
**`custom` and `workstation` keep bare names**, and that is the rule rather
|
||||
than an exception to it. `custom` presets nothing and can be any shape — a
|
||||
guest included — so a family claim is one it cannot make. `workstation` is
|
||||
somebody's own device rather than fleet infrastructure: it joins by
|
||||
interactive login, comes up user-owned and untagged, and the tailnet never
|
||||
manages it.
|
||||
|
||||
**Migration — this is a hard cut, with no aliases.** The old names are
|
||||
refused as unknown roles; a box bootstrapped under one is re-bootstrapped
|
||||
rather than migrated, which at this fleet size costs less than four
|
||||
deprecation paths each quietly keeping an old name alive. Two consequences
|
||||
worth knowing before you re-run anything. `TS_HOSTNAME` defaults to the role
|
||||
name, so a box that took the default now comes up as `control-plane-server`
|
||||
rather than `control-plane` — pass `--hostname` to hold a name steady, and
|
||||
check anything pinning one (ACL entries, a `cast` `environments.yaml` server
|
||||
name, host keys). And `rig coolify install` / `rig coolify backup install`
|
||||
match the **role name** in `/etc/rig/role`, so they now look for
|
||||
`role=control-plane-server`; a pre-rename control plane takes their warning
|
||||
branch until it is re-bootstrapped. That check has always been advisory and
|
||||
never a gate, so the run still proceeds and the warning names the repair.
|
||||
|
||||
The rename also reaches every string that *tells an operator to run a role*,
|
||||
not just the code that accepts one — `bootstrap-tenant.sh` emits the staging
|
||||
guest's tailnet-join next step (`sudo rig bootstrap workload-server`), and
|
||||
two of its refusals recite the machine-role list. A stale next-step is worse
|
||||
than a stale flag: it fails when someone copy-pastes it, on a different box,
|
||||
minutes after the run that printed it reported success. `test/cli.sh` sweeps
|
||||
every shipped script for pre-rename role names rather than pinning the known
|
||||
sites, because the next instance of this will be somewhere else.
|
||||
|
||||
`dev-server` was `class=human` when this landed, which read like a
|
||||
contradiction and was not: the suffix names the family, the class named the
|
||||
root-SSH door policy, and operators enter a dev box as themselves so
|
||||
`close-root` shuts its door. The two axes genuinely shared the word "server",
|
||||
which was a wart — #77, above, renames the trait to what it actually controls
|
||||
and retires it. It stayed a separate change because it reaches markers on live
|
||||
machines that guard root SSH, and so needed a compat read this rename did not.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CI's shellcheck sweep now reaches `.github/scripts/`** (#70) — the step
|
||||
ran `shopt -s globstar` and globbed `bin/* **/*.sh`, but globs skip
|
||||
dot-prefixed names without `dotglob`, so `**/` never descended into
|
||||
`.github/` and two tracked scripts were linted by nothing:
|
||||
`labels-reconcile.sh` and `release-lib.sh`. The second is the one that
|
||||
stings — it holds `changelog_section`, the extraction `release.yml` sources
|
||||
to build the published release body and the same function `test/release.sh`'s
|
||||
`changelog_armed` guard (#66) calls to decide whether main is armed. The
|
||||
script deciding both what ships and whether the changelog is safe was the
|
||||
script CI never read. Adding `dotglob` pulls in exactly those two files and
|
||||
nothing else; both already pass, so this closes a hole in the net rather
|
||||
than fixing a defect behind it. Paired with a class check that fails the
|
||||
step when any tracked `.sh` falls outside the globbed set, so the gap
|
||||
cannot reopen quietly — including via a symlinked directory, which
|
||||
`globstar` declines to traverse.
|
||||
|
||||
- **Ctrl-D at the `rig uninstall` confirm no longer aborts in silence**
|
||||
(#68) — `uninstall_confirm`'s `read -r reply` was unguarded. Under
|
||||
`set -euo pipefail`, and called as a plain statement, EOF made `read`
|
||||
return non-zero and killed the shell *at the read* — the `case` on the
|
||||
next line never ran, so `die "aborted."` never fired. The operator saw
|
||||
the question, pressed Ctrl-D, and got nothing back: no message, just
|
||||
exit 1, at the exact moment the tool had asked whether to delete their
|
||||
install. It failed closed (nothing was ever removed), but nothing said
|
||||
so. Now `read -r reply || reply=""`, so EOF falls through to the `*)`
|
||||
arm and aborts out loud — the spelling `commands/db.sh` already used for
|
||||
the same `[y/N]` shape, one file away. `test/cli.sh` gains the first
|
||||
drills of the interactive path, driving `y` and Ctrl-D through a real
|
||||
pty (util-linux `script`, skipped where it is absent) and asserting the
|
||||
MESSAGE rather than the exit code, which the bug also produced.
|
||||
|
||||
- **`users apply` now tells "revoke everyone" apart from "I truncated the
|
||||
file"** (#65) — a users file naming zero users is a valid instruction to
|
||||
revoke every operator on the box, and it is indistinguishable from a file a
|
||||
stray `>` produced. The per-user warnings apply already emitted arrive after
|
||||
the decision and scale wrong: twenty operators is twenty lines of scrollback,
|
||||
so the signal was loudest exactly where it read as noise. The `/etc/rig/users`
|
||||
ledger draws the line apply needs — an empty file against an empty ledger is
|
||||
an unambiguous no-op; against a populated one it closes every named door — so
|
||||
only the second case now stops, states how many operators are about to be
|
||||
revoked, and requires explicit consent: `--yes`, `RIG_YES=1` (the
|
||||
installer-family variable `rig uninstall` already reads), or a `y` on a TTY.
|
||||
Without a terminal and without consent it exits 2, in `uninstall_confirm`'s
|
||||
words, rather than assume a yes it cannot ask for or hang on a prompt nothing
|
||||
can answer. A **confirmation**, not `rig bootstrap`'s flat refusal of the same
|
||||
file (#57/#59): bootstrap asserts who lives on a box, apply converges, and
|
||||
converging to zero stays a legitimate de-provisioning. Ledger entries already
|
||||
marked `revoked` don't count toward the number, so a second identical run
|
||||
stays the silent no-op. Mass revocation below the empty-file bright line (a
|
||||
file dropping 19 of 20) is deliberately still ungated — that needs a threshold
|
||||
someone has to justify, and #65 stays open for it.
|
||||
- `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) — role
|
||||
`box` resolved to exactly one action, `usermod -aG incus`. That is the
|
||||
socket; it is step 1 of the five `box grant` performs, so every box-role
|
||||
user still needed an admin to run `box grant <user>` by hand before their
|
||||
first `box new` would do anything but refuse ("your project has no box-net
|
||||
profile"), and until that admin arrived they held an `incus` membership
|
||||
with no converged project — incus-user would lazily hand them a stock
|
||||
unhardened NAT bridge, which is worse than no grant at all. On `host=yes`
|
||||
apply now calls `box grant` per box-role user, after `useradd` (grant
|
||||
refuses an unknown account) and with the group ADD deferred to grant, so a
|
||||
grant that fails partway can take the socket back with it. Failures split
|
||||
the way the `host=` guard beside them already splits: a missing `box` CLI
|
||||
on `host=yes` dies (a broken VM host), a per-user grant failure warns and
|
||||
continues (one box-role user must not stop apply for the fleet). `host=no`
|
||||
and marker-less boxes keep their existing skip-with-warning. An
|
||||
`incus-admin` member is warned, not fatal — `box grant` refuses them today,
|
||||
which heavy-duty/box#99 fixes box-side with no rig change needed.
|
||||
- `users apply` grants the box *tier*, not just its socket (#49)
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING: `rig bootstrap` takes the users file, and requires it** (#51) —
|
||||
bootstrap already knew everything else about what a box *is* (class, host,
|
||||
join, hostname) and wrote `/etc/rig/role` to say so; the users file was the
|
||||
last piece of that answer it did not take, so bring-up was two commands and
|
||||
the second was the forgettable one. `--users <path>` now runs the `users
|
||||
apply` convergence as bootstrap's **final phase** — after the traits, after
|
||||
the verified tailnet join, after the role marker (apply *reads* that
|
||||
marker), and after the `host=yes` box install (so box-role users find the
|
||||
`incus` group box's own `setup-host` built). One command, and the box has
|
||||
its people on it. The file is still passed per invocation and **never
|
||||
persisted**; `--users -` is refused, because bootstrap's stdin belongs to
|
||||
the pre-auth key prompt.
|
||||
|
||||
**Migration: every existing `rig bootstrap` invocation must add `--users
|
||||
<path>` or `--no-users`.** Omitting both is now a usage error (exit 2)
|
||||
naming both flags, and passing both is a usage error too. Scripted
|
||||
bring-up that already ran `rig users apply` as a separate step can either
|
||||
fold it in (`--users ./users`, and drop the separate call) or keep the old
|
||||
shape verbatim by adding `--no-users`. Required on `class=server` as well
|
||||
as `class=human`: 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 — so the complete path is the default
|
||||
path, and skipping it is deliberate rather than an omission that looks
|
||||
identical to forgetting. The box TENANT roles (`claude|codex|grok|
|
||||
staging`) take neither flag: a guest is minted non-interactively by box,
|
||||
never joins the tailnet, and has no SSH door of its own — entry is `box
|
||||
shell`, gated by the host's `incus` grants.
|
||||
|
||||
A bad users file is caught **up front** now (the same parser apply uses,
|
||||
before `apt`, the hostname change, and any spent pre-auth key), and on
|
||||
`host=yes` with `RIG_SKIP_BOX_INSTALL=1` a box-role user with no `incus`
|
||||
group refuses immediately instead of a hundred lines later — the one case
|
||||
where the outcome is already certain. rig still never installs Incus and
|
||||
never calls `box setup-host` on its own account; every other way that step
|
||||
can fail lands in `users apply`'s existing refusal, unchanged.
|
||||
- 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) — the ceremony stamps `## Unreleased` to
|
||||
`## X.Y.Z — YYYY-MM-DD` and stops. Every PR authored before that merge
|
||||
wrote its entry under `## Unreleased`; with the heading gone, git files
|
||||
the entry under whatever now occupies the position — the release that
|
||||
already shipped. There is no conflict, because the stamped heading and
|
||||
the incoming entry never overlap textually, so the one signal an author
|
||||
relies on ("git told me to look") is absent exactly when the outcome is
|
||||
wrong. It happened here: #60's #58 entry landed inside `## 0.1.0` at
|
||||
`67386b4` and was repaired two minutes later by `0ff520c`; #54 would
|
||||
have filed a **BREAKING** entry the same way. The published release body
|
||||
is never affected — `release.yml` extracts it from the tree at the tag,
|
||||
before the late merges land — so the only file that drifts is the one
|
||||
only maintainers read, which is why it survived a whole release batch
|
||||
unnoticed. Fixed in both halves the failure has. The ceremony now
|
||||
**re-arms**: it adds a fresh empty `## Unreleased` above the section it
|
||||
just stamped, so a late merge has somewhere correct to land with no
|
||||
author action. That belongs to the ceremony step in
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md), not to `release.yml` — no workflow
|
||||
has ever touched the heading; the stamping was always by hand, and the
|
||||
`-dev` re-arm the workflow does perform was only ever about `VERSION`.
|
||||
And `test/release.sh` now keys its guard to `VERSION` rather than
|
||||
demanding a literal heading: a stamped top section is legal exactly when
|
||||
`VERSION` is bare, and the moment it carries `-dev` — main, where
|
||||
feature PRs merge — the top section must be `## Unreleased`. That
|
||||
distinguishes the two states the old check collapsed into one, so it
|
||||
catches a disarmed main **without** re-breaking the ceremony's own tree
|
||||
the way the pre-#44 guard did. The rule is proven against seven
|
||||
constructed `VERSION` + `CHANGELOG.md` pairs, including a re-armed
|
||||
ceremony whose top section is legitimately empty — the state the old
|
||||
non-empty assert would have rejected. box and cast carry the same flow
|
||||
and the same exposure (`heavy-duty/box#96`); cast is disarmed on `main`
|
||||
as of this writing and is getting the sibling fix.
|
||||
|
||||
- **A `host=no` box with an `incus` group no longer hands out the bare
|
||||
socket** (#58) — `users apply` consulted the `host=` trait only when group
|
||||
`incus` was ABSENT (die on `host=yes`, skip on `host=no`). When the group
|
||||
was PRESENT the trait was never asked, so a `host=no` or marker-less box
|
||||
that nonetheless carried the group — `box setup-host` ran, then the box was
|
||||
re-bootstrapped with other traits — gave every box-role user a bare
|
||||
`usermod -aG incus`: the socket with no tier behind it, which `incus-user`
|
||||
answers by lazily building an UNHARDENED project under whoever opens it
|
||||
(`incusbr-<uid>`, NAT on v4 and v6, no ACL, no `dns.mode=none`, no port
|
||||
isolation). The marker now decides in BOTH directions, through one new pure
|
||||
gate (`assert_marker_hosts_vms`, testable against fixture markers non-root
|
||||
like `assert_marker_human`): the box role applies only where the box CLAIMS
|
||||
to host VMs, so the verdict is identical whether or not the group exists.
|
||||
The machine deliberately does not overrule the marker — but the skip is not
|
||||
silent either: when the group exists and the trait disagrees, the warning
|
||||
names the contradiction and `rig bootstrap` as the repair. On such a box
|
||||
exact-membership convergence now strips box-role users out of `incus`, on
|
||||
the same reasoning: a membership inherited from a previous life is the same
|
||||
half-grant as a freshly added one.
|
||||
- **Dropping the box role revokes through `box`, not behind its back**
|
||||
(#50) — `users apply` converged group `incus` with a bare `gpasswd -d`,
|
||||
the same move it makes for `rig-admin` and `rig`. Those two are rig's;
|
||||
`incus` is box's, and `box revoke` does strictly more with it: it says
|
||||
out loud that supplementary groups are read at LOGIN, so a session the
|
||||
dropped operator already holds keeps the Incus socket until it dies, and
|
||||
hands over `loginctl terminate-user <user>` as the remedy. rig logged
|
||||
`removed <user> from incus` and moved on, 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.
|
||||
Both removal paths (the per-user convergence and the dropped-user sweep)
|
||||
now call `box revoke`, which keeps one owner for the group. Never
|
||||
`--purge`: that deletes the user's boxes, images and project, and
|
||||
destroying someone's running machines is not a convergence step — it
|
||||
stays an explicit admin act. The exit code is not trusted (#12's lesson):
|
||||
a revoke that returns 0 with the membership still standing has not closed
|
||||
the socket, and rig falls back to removing the group itself, as it also
|
||||
does where box is not installed. Every fallback path carries the session
|
||||
warning, because the silence was the bug.
|
||||
- **`rig bootstrap` refuses a users file that names no users** (#57) — an
|
||||
empty, comments-only or whitespace-only file is not a parse error, so it
|
||||
passed pre-flight, converged nothing, and left the box root-only: the exact
|
||||
outcome `--no-users` exists to make explicit, reached by the flag added to
|
||||
guarantee the opposite. Bootstrap's pre-flight now catches the zero-user
|
||||
parse — before `apt`, the hostname change, or a spent pre-auth key — and
|
||||
refuses, naming `--no-users` as the way to ask for a root-only box out loud.
|
||||
Scoped to `rig bootstrap`'s contract only: a standalone `rig users apply`
|
||||
against an emptied file is a real de-provisioning operation and is
|
||||
unchanged.
|
||||
- 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The release suite accepts the ceremony's own tree** (#44) —
|
||||
`test/release.sh` demanded a literal `## Unreleased` heading in the real
|
||||
`CHANGELOG.md`, extracting non-empty and containing `#32`. All three are
|
||||
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.
|
||||
- The release suite accepts the ceremony's own tree (#44)
|
||||
- The installer survives an environment with no `$HOME` (#39, #41)
|
||||
- Headless credential prompts refuse loudly instead of dying silently (#42)
|
||||
|
||||
### Added
|
||||
|
||||
- **Merging a release-labeled PR IS the release — and the release re-arms
|
||||
main itself** (#47) — the rig twin of heavy-duty/box#96, born of the
|
||||
ceremony retro: the tag was a separate, manual, silent-when-forgotten
|
||||
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.
|
||||
- Merging a release-labeled PR IS the release, and the release re-arms main
|
||||
(#47)
|
||||
- Tagged releases, and an installer that installs them (#32)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,38 @@ labels tell you where everything is without opening anything.
|
|||
convention): add it under `CHANGELOG.md`'s `## Unreleased` heading —
|
||||
that section becomes the release notes verbatim when a release is cut.
|
||||
|
||||
## Changelog entries
|
||||
|
||||
Every PR that changes behaviour adds one line to `## Unreleased`. One line is
|
||||
the whole rule — if it wraps more than twice in your editor, cut it down.
|
||||
|
||||
- **Say what changed, and stop.** Why it was wrong, how it was found, what it
|
||||
cost, what it implies — that belongs in the PR body and the commit message,
|
||||
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
|
||||
|
||||
A release is a PR, and merging it is the release (#47; box#96's design, on
|
||||
|
|
|
|||
Loading…
Reference in a new issue