Commit graph

128 commits

Author SHA1 Message Date
fbdce5284e fix: the ci-box token guidance says what a Forgejo token actually is
Round finding from @codex-reviewer-andresmgsl, elevated to blocking by
@grok-reviewer-andresmgsl and @kimi-reviewer-andresmgsl. Unanimous, and right.

creds.md called the registration token "short-lived" and said it was "consumed
at registration". Both are GitHub's facts, copied across the forge boundary
with the rest of the sibling's shape. Forgejo's primary source, read rather
than inferred:

  models/actions/runner_token.go — ActionRunnerToken has NO expiry field. Only
  IsActive, Created, Updated. NewRunnerToken flips IsActive false on prior
  tokens at the same scope and only there, so a token dies when somebody mints
  its replacement, never on a clock.

  routers/api/actions/runner/runner.go — Register reads the token, refuses it
  when !IsActive ("please use the latest one"), and returns WITHOUT setting
  IsActive = false. Registration does not spend it. One token registers as many
  runners as it is shown to.

So it is long-lived and reusable — the precise opposite of the adjective, and
GitHub's really does expire in about an hour, which is why runner-install.sh is
correct to use it.

This is not a wording nit because of where the wording lives. creds.md is
spliced into the ci-box's own CONTEXT.md: it is the paragraph an agent INSIDE
the box reads about its own credentials. Telling that reader the token
self-expires is telling it a leaked one stops mattering on its own, while it is
still registering runners.

Pinned, not merely fixed, per codex's ask — the phrase arrived by copying from
the GitHub sibling, so the same copy can bring it back. Four rows: absence from
both files, and presence of the true claim, so the pin cannot be satisfied by
deleting the sentence instead of correcting it. The first draft of the CIBOX
pin was a phrase match and passed against the exact text it was written to
catch — the old wording wrapped across two comment lines. It is a plain absence
check now, and the file explains the ban without spelling the word.

Mutation-checked: all four go red against the old wording, green after.
2026-07-28 16:18:33 +00:00
ab29ee78fb fix: the --version read cannot die ahead of its own refusal
Self-review of the whole head after the panel converged. One real defect,
reproduced verbatim before it was touched.

runner_version_of is a pipeline, and every forgejo-runner-* command runs under
set -euo pipefail. When the binary exits non-zero the pipeline fails, and
`PRESENT_VER="$(runner_version_of "$BIN")"` is an ASSIGNMENT — so install died
at that line, exit 1, printing nothing at all. The lib documented the opposite
("empty when it cannot answer"), and the refusal written for exactly this case
twenty lines later — "the download landed but cannot run" — was unreachable
code that could never have fired.

The shape is not hypothetical. `[ -x ]` yes, runnable no is what a truncated or
wrong-arch download leaves behind, and a ci-box's template preinstalls the
binary at mint — so the silent death sat on the one path this command family
exists to serve.

json_field, two files away, already carries `|| true` with this reasoning
written above it: "callers run under set -e with pipefail, where a grep that
matches nothing would otherwise kill the script with no message". The same
guard, for the same reason, at both sites: the lib reader and install's own
read of --version.

The test could not have caught it. vparse drove the lib through `bash -c` with
NO shell options, and its "bad" stub exits 0 — it proved garbage-in-empty-out
in a permissive shell, which is not where the function is used. vparse now runs
under set -euo pipefail, and a stub that exits non-zero is a third row.
Mutation-checked: that row exits 1 against the unpatched lib and 0 against the
fixed one. Install's site is grep-pinned, since reaching it for real needs root
and a downloaded binary.

748 passed (was 746), release 31, drill 43, shellcheck clean.
2026-07-28 09:26:17 +00:00
cf5858bb60 fix: one checksum policy, labeler coverage, orphaned-unit removal
Some checks failed
ci / check (pull_request) Has been cancelled
ci / install (pull_request) Has been cancelled
ci / db-integration (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Net-new review findings from grok and kimi on !110. Their items 1-3 were
codex's, already fixed in 1933b07; these are the ones only they raised.

grok #4 — the two downloaders would drift. docs/templates/ci-box/install.sh
and the download block in forgejo-runner-install.sh were near-copies, and grok
named the exact consequence with the exact evidence: fail-open survived in BOTH
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 whole policy —
fetch, unreadable, mismatch — is now fetch_and_verify_sha256, byte-identical in
both files and diffed by test/cli.sh. They cannot share a lib: the command
sources commands/lib/, and the template is a registry definition that runs
standalone inside a mint with rig's tree nowhere in reach, which is the same
situation valid_version faces between bin/rig and install.sh. Mutation-checked
by drifting one copy's message and confirming the diff goes red.

kimi #2 — the labeler could not see this family. scope:runner matched
commands/runner-*.sh only, so forgejo-runner-*.sh and the staged ci-box
definition scored no scope at all. Globs extended and the label's description
now says either forge rather than GitHub.

kimi #4 — remove stranded a unit whose user was gone. The missing-user check
exited 0 before the unit was ever looked at, so a deleted account with a
leftover forgejo-runner.service reported "nothing to remove" while the
absence-assert that never ran implied the opposite. The unit is now checked
independently. Auditing that fix surfaced a hazard kimi did not mention: with
the user gone RUNNER_DIR is "", and the later unguarded "$RUNNER_DIR/.rig-labels"
would have expanded to "/.rig-labels" — an rm at the filesystem root, as root.
Every RUNNER_DIR path is now gated, and a test pins that none is unguarded.

kimi #1 — the README handed out a config that breaks rig's own gates.
DEFAULT_ACTIONS_URL is a single fallback and rig's workflows need two origins;
measured: code.forgejo.org serves actions/checkout (200) but not
heavy-duty/ceremony (404), which lives on the Forgejo instance. With the value
the README recommended, all eight ceremony references fail to resolve. The
section now states the conflict with the counts, says which references would
break, and explicitly does NOT pick a side — that is an infra decision, and
rig's CI running on Forgejo is not something rig forgejo-runner depends on.
Asked the maintainer for direction.

746/31/43 pass, shellcheck clean, labeler.yml parses.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:19:33 +00:00
1933b07fd4 fix: honour --version, scope .rig-labels, close the checksum gate
Some checks failed
ci / check (pull_request) Has been cancelled
ci / install (pull_request) Has been cancelled
ci / db-integration (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Three defects from review !110, all the same family — a stated contract the
code did not keep.

--version was swallowed on the path this command exists for. The download
block skipped on mere presence, copying runner-install.sh's shape without its
justification: actions/runner SELF-UPDATES, so freezing it would only make
GitHub refuse its jobs. forgejo-runner does not self-update, so nothing else
ever moves the version — and a ci-box's template preinstalls the binary at
mint, which meant the documented deterministic-pin lever could never fire on a
ci-box. It now converges toward the pin, downward included, because a pin is
an instruction and not a floor; absent a pin an existing binary is left alone,
since chasing latest would make a re-run an unrequested upgrade. The decision
moved to runner_download_decision in the lib as a pure function: the first
attempt at a test here grepped for a log string and survived the logic being
disabled, which is exactly the weak test the review warned about. The binary
is now renamed into place rather than written over — the converge path runs
while the daemon is live, and in-place is ETXTBSY.

.rig-labels outlived the registration it described. The write had escaped the
registration branch, where runner-install.sh correctly keeps its copy, so a
plain re-run stamped this invocation's labels over a registration made with
different ones and status then reported confidently wrong labels while Forgejo
still held the originals. Scoped again, and an EXPLICIT --labels on a re-run
now warns that Forgejo owns labels from registration time rather than letting
the request evaporate silently.

The checksum gate failed open. A missing .sha256 warned and installed anyway,
contradicting both the README and the template'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 precisely what the checksum exists to catch. Both paths refuse
now, with no bypass flag; if upstream really moves its assets that is a rig PR
editing the URL, not an operator improvising past a security gate.

Tests: the checksum paths are now DRIVEN against a stub curl through the real
template install.sh — matching, missing, mismatched and empty — instead of
grepped, and all three fixes were mutation-checked by reverting each and
confirming the suite goes red.

739/31/43 pass, shellcheck clean.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:08:15 +00:00
903d8371b3 feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
Some checks failed
ci / check (pull_request) Has been cancelled
ci / install (pull_request) Has been cancelled
ci / db-integration (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.

The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.

ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.

rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
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. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.

Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.

Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.

bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.

Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.

forgejo#109

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00
claude-bot-andresmgsl
559a5b23ba test: drive cron converge against stubbed systemctl
The service converge+assert block becomes converge_cron(), behavior-
identical, so test/cli.sh can lift it verbatim (the drop_incus precedent)
and drive it: the already-enabled/active re-run is a no-op, a disabled
stopped unit converges, and an unrecoverably masked or start-dead unit
dies nonzero naming cron and #162 — proven by execution, not only by
structural pins. The converge logs now state the probe fact instead of
claiming an unverified success (panel nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:11:13 +00:00
claude-bot-andresmgsl
29c4a0840b fix: arm cron on agent tenant boxes
Agent tenants exist to run the cron-driven duty engine, and the engine's
installer runs unprivileged — it can detect a missing cron but never
apt-get it. Install cron with the shared toolbelt, then assert the
effective state, not the package: crontab on PATH AND cron.service
enabled and active, converging (unmask/enable/start) best-effort first.
A masked daemon with the binary present is exactly the silent-inert box
the issue reports, so the service asserts are the authority and either
failing dies naming cron.

staging-box stays exempt with the rest of the agent-assert block: no
agent, no duty engine. Machine roles (bootstrap.sh) are deliberately
not widened — the issue names tenants as the demonstrated gap.

Closes #162

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 18:54:27 +00:00
Daniel Marin
34ff1c8917
Merge pull request #161 from claude-bot-andresmgsl/build/160-self-scoped-tags
fix: scope the netmap tag read to Self, not the whole document
2026-07-25 17:16:07 +01:00
claude-bot-andresmgsl
56478d7a7d fix: scope the netmap tag read to Self
json_string_array took the first "Tags" array anywhere in the document,
justified by Self-before-Peer field order. An untagged Self omits the key
entirely (Go omitempty), so the match fell through into Peer and returned
a peer's tag — false-refusing every login join and false-verifying
untagged authkey joins on any tailnet with a tagged node. Extract Self by
brace counting (Location nests an object, so slicing to the next key
would truncate) and read the array inside it.

Refs #160

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:02:54 +00:00
codex-bot-andresmgsl
b849eb57d3 test: prove drill records registry source 2026-07-25 13:41:01 +00:00
codex-bot-andresmgsl
247d9b9ec5 feat: install and record registry snapshot 2026-07-25 13:38:54 +00:00
codex-bot-andresmgsl
5794a80d0e feat: prefer installed template snapshot 2026-07-25 13:34:58 +00:00
codex-bot-andresmgsl
c08bcc6622 fix: align machine role boundaries 2026-07-25 13:07:53 +00:00
codex-bot-andresmgsl
88e59b6ec8 test: cover machine template registry 2026-07-25 11:00:21 +00:00
claude-bot-andresmgsl
79363c2631 test: the tenant surface re-proven against fixture registries — offline, by refusal and by identity (#110)
The suite drives the whole new surface without a network: the family-suffix
dispatch and hard cut, the marker guards firing with the registry
unreachable, the unknown-role refusal listing the resolved source, every
malformed-definition refusal named by key, DIR-vs-REF identity through a
stubbed curl (tags-first precedence pinned), the renderer's box#80 guard,
the one-line pin, and template-lint's whole gate.
2026-07-24 23:08:47 +00:00
claude-bot-andresmgsl
11b02eb070 feat(drill): the record cites the rig-templates SHA the converge read (#110)
Override via RIG_TEMPLATES_REF when the drill was pointed somewhere; else
the pin read from the INSTALLED candidate tree — what actually landed,
never this checkout's copy.
2026-07-24 23:02:12 +00:00
codex-bot-andresmgsl
b6d580ea3e test: make bootstrap provenance count assertive 2026-07-24 10:17:22 +00:00
codex-bot-andresmgsl
53ff0daeff test: isolate bootstrap undo fixtures 2026-07-24 09:41:48 +00:00
codex-bot-andresmgsl
c0272cbae6 test: drive bootstrap undo safety gates 2026-07-24 09:41:48 +00:00
claude-bot-andresmgsl
5a8badd532 feat: CI drills the install lifecycle — four beats against a real tree (#106)
install from this checkout via RIG_INSTALL_SOURCE, assert what landed,
a converging re-run proven by an empty diff, uninstall --all ending in
the absence assert (! -e AND ! -L). test/install-lifecycle.sh carries
the beats plus the instrument-honesty checks (the diff and the absence
assert are broken against scratch trees on every run, so 'demonstrated,
not assumed' is mechanical); test/release.sh gains the local channel's
contract — dir, tarball, loud refusal, never a silent download fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:58:53 +00:00
claude-bot-andresmgsl
bd2918d0c5 test(platform): the identity contract, pinned — derivation, determinism, shape, confidentiality, loud degradation (#95)
Both fixture digests are computed outside the implementation, so a refactor
that changes the prefix, the hash or the slicing renames the whole fleet and
fails here. The negative half is the spec's: empty and 'uninitialized'
machine-ids must never be hashed (the collision id is asserted absent, not
just the unavailable line present), and the raw machine-id must never
appear in the output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:48:45 +00:00
Daniel Marin
8dcf52940e
Merge pull request #122 from codex-bot-andresmgsl/build/103-box-ref-release-pin
fix: pin bootstrap to a released box
2026-07-24 08:01:14 +01:00
codex-bot-andresmgsl
c02226cb66 fix: propagate pinned box ref 2026-07-24 00:39:24 +00:00
claude-bot-andresmgsl
77cb4bdd64 test: the instrument's honesty, proven without hardware
test/drill.sh awk-extracts the harness's decision functions (the
release.sh pattern) and drives them against fixtures: the ref refusal
names both refs, a loud skip never classifies as a pass, the idempotence
verdict is a real diff that goes non-empty when convergence is broken —
demonstrated mechanically on every CI run — and the record emitter
cannot produce a clean-sweep reading over a skipped leg. CI runs it in
the check job.

The tests caught three real harness bugs before any reviewer could:
printf eating a '- '-leading format as options (a silently empty Failed
section — the exact lie the record exists to prevent), tree_of trusting
GNU readlink -f's exit 0 on a dangling final component, and the arg
refusals sitting behind the root check in violation of the repo's own
validated-before-root doctrine.

(ceremony flow: issue #105)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:31:53 +00:00
codex-bot-andresmgsl
493ae31811 fix: pin bootstrap box release 2026-07-23 23:56:54 +00:00
claude-bot-andresmgsl
be71e1c8b2 feat: the machinery hands over — guard scripts deleted, CI pins ceremony's actions at 0.1.0 (ceremony#13)
changelog-armed returns (rig#44's revert, now version-keyed upstream);
docs-sync guards the doctrine mirror the next commit vendors.
test/release.sh keeps rig's own surfaces — installer channels and
latest-tag resolution; the machinery halves and the workflow-shape pins
are tested in ceremony's own test/. test/labels-reconcile.sh goes whole:
it drove the deleted reconciler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:27:10 +00:00
dan-claude-bot
5bf0acd940 feat: kimi-box joins the box tenant roles — the Kimi CLI agent guest
The fourth agent tenant, through the one parameterized mechanism
(lib/tenant-config.sh + bootstrap-tenant.sh) — never a fifth
hand-maintained copy. The official installer (code.kimi.com/install.sh)
is uv-managed and lands kimi in ~/.local/bin, run as the tenant user
(grok's lesson: a root-owned install under a 0700 home is a CLI that
exists and cannot run); no node, uv brings its own Python. The context
file lands at ~/.kimi/AGENTS.md — the <dotdir>/AGENTS.md convention —
with an honest note that kimi documents only project-level AGENTS.md
today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:03:25 +00:00
dan-claude-bot
dbe04c7c36 test: the guard's verdict must match the tree, not always pass 2026-07-21 16:42:41 +00:00
dan-claude-bot
11dfeb2ecd refactor: one drill record per version, in drills/
Drill records move from sections inside drill/RUNS.md to one file per
version at drills/<version>.md.

The old guard had to parse headings: an em-dash prefix match, an optional
" — DATE" tail, a whole-version comparison so 0.3.0-rc1 could not satisfy
0.3.0, and a separate non-blank-body rule. All of it existed only because
records shared one file, and both sibling repos shipped a defect out of
that complexity in review — a `sed '/./,$!d'` extractor where `.` matches
a space (box#149, cast#138), and heading-grammar drift. One file per
version makes nearly all of it unrepresentable: 0.3.0.md and 0.3.0-rc1.md
are different files, and the filesystem does the whole-version comparison.

The awk drill_section() machinery is gone. What survives is the one rule
splitting the files does not make unrepresentable: a file of only
whitespace is not a record.

Plain drills/, NOT .drills/ — a dot-directory is invisible to globs
without dotglob, the cause of #70 here and box#116/box#118.

drill/RUNS.md is deleted; it was created in this same unmerged PR, held no
real records, and its useful reasoning moves to drills/README.md. (box
keeps ITS drill/RUNS.md — that one is a genuine harness log.)

Also corrects the ordering framing in CONTRIBUTING and the new README: the
three repos' drills are INDEPENDENT, run in any order. What makes that safe
is that each pins the same fixed set of candidate refs, so box and rig
measure the same pair — that, not sequencing, is what dissolves the mutual
recursion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:35:51 +00:00
dan-claude-bot
b234f48e68 test: pin that whitespace is not a drill record 2026-07-21 15:59:57 +00:00
dan-claude-bot
77a9a1ad76 feat: CI refuses a release PR with no drill record
CONTRIBUTING has always required a real-hardware drill on a release, and
nothing enforced it — so no release in this family has ever carried one.
Every other ceremony step is checked by a script; the one that costs an
afternoon was checked by a reviewer remembering. A reviewer bot finally
blocked on it.

- drill/RUNS.md: rig's own run log, starting EMPTY of records. rig has no
  drill harness of its own yet; the harness lives in box's drill/ and this
  file is the record, not the instrument.
- .github/scripts/drill-recorded.sh: a -dev tree asserts nothing; a bare
  VERSION requires a non-empty '## Release drill — X.Y.Z' section, version
  matched WHOLE so an -rc1 record is not evidence for the final.
- Per-repo on purpose. A cross-repo lookup into box fails on a token, a fork
  checkout or a network blip, and all of those degrade to 'pass' on precisely
  the tree that ships — the UNREADABLE-vs-NONE shape #90 fixed.
- It asks for a RECORD, not a RESULT, so a maintainer waiver stays possible
  but has to be written down under that version.
- Fixtures carry their own VERSION and RUNS.md (heavy-duty/box#146: fixtures
  reading the repo's real VERSION exercised only the -dev branch and went red
  first while cutting a release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:24:42 +00:00
Daniel Marin
9c82d56230
Merge pull request #93 from claude-bot-andresmgsl/fix/sshd-privsep-verdict
fix: don't read a missing /run/sshd as a broken sshd config
2026-07-21 14:07:35 +01:00
dan-claude-bot
1db7779b03 test: terminate the ci.yml step block at the job boundary too
The monotonic step is the LAST step of its job, so stopping only at the next
`- name:` ran the block into the job below and swallowed that job level `if:`.
Unanchored `grep -q "if:"` then fired on it — the same bug the scoping was
meant to fix, moved from "any step in the file" to "this step plus the head of
the next job".

Terminates on a new step OR a new job now, and the key is anchored so an `if:`
inside a `run:` line is not mistaken for a step condition.

Found by claude-bot-andresmgsl on heavy-duty/box#144; this port carried the
identical awk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:56:57 +00:00
dan-claude-bot
c199e8fd04 fix(changelog-monotonic): report containment vacuous when the base IS HEAD
Dropping the pull_request gate made merge_base == HEAD a routine path rather
than a degradation, and the success line did not follow. On every push to main
the step printed "all N release heading(s) at the merge base are still present"
— a containment claim on the one event where deletion is undetectable, since
the comparison is the file against itself.

That is the dishonesty this PR fixed in the skip messages, surviving in the
success message. The line now has two forms: containment vacuous, naming
uniqueness as the half that ran, or the existing containment wording when a
real base exists. Both pinned, plus a negative that the two do not collapse.

The "an untouched branch passes" case turned out to be this exact shape — its
fixture never commits on 'work', so it was asserting containment on a
self-comparison. Its assertion moved to uniqueness's count, and a companion
case with a real base now carries the containment wording it used to claim.

Also scopes the ci.yml negative pin to the monotonic step's own block. As a
file-wide grep it forbade any FUTURE step from being pull_request-gated and
would have failed citing #98 when one legitimately was. A companion check
keeps the awk extractor from matching nothing and going tautological.

Ported from heavy-duty/box#144 (box@94d830c).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:49:02 +00:00
dan-claude-bot
7ffc30bacc fix(changelog-monotonic): check uniqueness before anything base-side (#98)
Uniqueness is a property of HEAD alone — no base ref, no merge base, no base
blob. It sat downstream of all three, so every degradation path returned
success on a tree carrying a duplicate.

The base-blob path was the worst: 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 could not reach it — STRICT guards the two
skip() calls, and that is not one of them.

That inverted the two halves. Deletion needs a diff to see; duplication is the
one changelog_section() actually mis-renders, stopping at the second heading
and truncating the release's real body. The half with the live extraction bug
behind it had the most ways to silently not run.

Moved, not rewritten. The skip messages now say containment skipped and that
uniqueness already passed. The CI step is no longer pull_request-only, with a
`github.ref_name` fallback because base_ref is empty on a push and a bare
`origin/` under STRICT would redden every push to main. The script is also now
100755, matching cast's copy of the same file.

Regression cases pin the ORDER, not the exit code: verified they go red
against the pre-fix script (7 failures) and green against the fixed one.

Same defect fixed upstream in heavy-duty/box#144 (heavy-duty/box#143), which
rig's copy of this script was ported from. Found by claude-bot-andresmgsl and
codex-bot-andresmgsl reviewing #99.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:22:43 +00:00
dan-claude-bot
b2e7febf08 fix: catch a deleted or duplicated release heading in CHANGELOG.md (#98)
The arming rule (#66) guards ONE heading — does the top section agree with
VERSION? — and is silent about the rest of the file. The failure that lives
there is an author adding an entry under `## Unreleased` who replaces the
shipped heading below it instead of inserting above it. git merges the
one-line edit cleanly, `changelog_armed()` stays green (correctly: the top
section is still right), and the shipped release loses its section entirely.
It surfaces a whole release later, when release.yml refuses to publish a
section `changelog_section()` can no longer find by heading.

"A heading disappeared" is a property of a DIFF, not of a tree, so this is
its own script rather than a clause in the arming check — which is also
driven from test/release.sh against constructed non-git VERSION + CHANGELOG
pairs that could not express it. The rule needs no tuning: release headings
are append-only, so SUPERSET is exact, and the ceremony's stamp passes by
construction because `Unreleased` fails the version shape.

Ported from heavy-duty/box#122, with box's second half intact: containment
cannot catch a DUPLICATED heading, since the duplicate is head-side surplus
and `comm -23` is blind to extras there — so uniqueness on HEAD is asserted
alongside it. rig's symptom differs from box's and the comments say so:
box's extractor re-arms on every `## ` line and ABSORBS what sits between
the copies, while rig's `changelog_section()` has `if (found) exit` and
TRUNCATES at the second copy, dropping the release's real body.

Wired on pull requests only (on a push to main the merge base is HEAD, so
the assert is vacuous), with CHANGELOG_MONOTONIC_STRICT=1 and fetch-depth: 0
so a checkout that cannot reach the base ref fails rather than skipping
quietly forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:00:48 +00:00
Daniel Marin
9ba16946fd
Merge pull request #91 from codex-bot-andresmgsl/fix/issue-89-readme-channel
docs: align README quick start with main
2026-07-20 19:41:12 +01:00
dan-claude-bot
5eddf2e2ed fix(labels): a missing state label skips the edit, not the whole PR
Round 2 review (claude-bot, codex-bot — both raised this, independently).

The `return` added in round 1 aborted all of reconcile_pr, not just the
label edit. Everything below it is independent of the state:* taxonomy:
`merge-next` clearing and the stale sweep both stopped running. So a
cold-start repo left `merge-next` claiming "merge this one next" on a PR
the board had moved to the agent — the original false-invitation bug,
reintroduced inside the very fix meant to survive a cold start.

It was also a regression against main, not just a missed improvement:
the old code failed the `gh issue edit`, logged, and fell through to both
blocks. Round 1 turned a per-edit failure into a per-PR abort.

Now a `skip_edit` flag skips only the edit and control reaches the rest.

Also from review: drop the dead `"$desired"` term from the filter loop
(it was appended and then unconditionally continued past), and turn
`[ -n "$missing" ] && log` into a proper `elif` rather than an
&&-as-statement under `set -e`.

Adds the first four fixtures that exercise reconcile_pr itself, stubbing
run/gh to probe a cold-start repo against a bootstrapped one. Everything
before this tested pure functions, which is exactly why a per-PR return
got through: nothing could see it.

Fixtures 68 -> 72.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:02:03 +00:00
claude-bot-andresmgsl
2dc47112f2 fix: don't read a missing /run/sshd as a broken sshd config
`sshd -t` folds two questions into one exit code — is the merged config
parseable, and is the privilege-separation directory there. Both call
sites ran it as `sshd -t 2>/dev/null` and read any non-zero exit as the
first question's answer, discarding the line that named the second.

Bootstrap aborted with "sshd rejects the merged config", a verdict sshd
never reached, and sent the operator to audit /etc/ssh files that were
never broken. /run is a tmpfs and /run/sshd is ssh.service's
RuntimeDirectory, so it is legitimately absent under socket activation
on a box whose SSH door is serving connections normally.

Classification is now a pure, sourceable sshd_privsep_gap: the status is
the verdict, the text only classifies a failure, so a passing sshd -t is
never diverted. sshd_config_ok repairs the gap with an idempotent
install -d and retests once. A genuine parse refusal still refuses and
the rollback is untouched.

Refusals now carry sshd's own stderr. users-close-root had the identical
three lines and now reaches the shared judgement through lib/sshd.sh
instead of keeping a second copy of it.

Fixes #92

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:58:10 +00:00
codex-bot-andresmgsl
491055c4cf docs: point stable installs at matching README 2026-07-20 17:50:18 +00:00
dan-claude-bot
b07e734fc0 fix(labels): survive a repo that lacks the new blocker labels
Round 1 review (claude-bot, codex-bot — both raised 1 and 2).

1. `gh issue edit --add-label` rejects the WHOLE call on one unknown
   label name, and the blocker:* labels are created only by the
   dispatch-only bootstrap. So the first sweep after this lands would
   have converged NOTHING on exactly the PRs this change exists to heal,
   surfacing only as a WARNING in a cron log. Batching state and blockers
   into one edit for anti-flicker is what widened that blast radius.
   Every label about to be ADDED is now filtered against the repo's real
   label set, read once per sweep; removals need no filter because they
   are built from has_label. An unreadable label set does not filter, so
   a failed read cannot silently strip the board.

2. blocker:unrequested fired only on MISSING, so a round whose approvals
   all staled behind a push — with nothing re-requested — carried no
   blocker at all, though the agent owes exactly the same ask. Now
   MISSING or STALE: both mean this head has no verdict from that
   reviewer.

3. LABELS.md: restore the substantive "Leaves when" text for
   state:addressing, and widen the blocker:unrequested row to name both
   shapes now that (2) changes what the label means.

Fixtures 66 -> 68.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:49:44 +00:00
dan-claude-bot
4f40cfabf2 fix(labels): distinguish an unreadable check rollup from no checks
When `gh pr view` failed, the fallback left the `statusCheckRollup` key
absent, and `(.statusCheckRollup // [])` collapsed that into the same
NONE as a PR with genuinely no checks. NONE blocks nothing, so an API
hiccup presented the PR as mergeable by a human — an unknown certified
as green, the shape #87 exists to stop, in the one place it never looked.

checks_state now returns UNREADABLE for an absent key versus NONE for a
present-but-empty array, and the sweep leaves an UNREADABLE PR alone
rather than recomputing on facts it did not read. Not a blocker on
purpose: blocking would flap the board on one bad call.

Fixtures 64 -> 66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:38:32 +00:00
codex-bot-andresmgsl
b88495105d test: tighten README channel contract 2026-07-20 17:32:43 +00:00
dan-claude-bot
3f6f05766f refactor(labels): split PR labels into state and blocker axes
Retires `state:needs-rebase` in favour of two independent axes: `state:*`
(whose ball, exactly one) and `blocker:*` (what is in the way, additive).
One rule joins them: `state:needs-human` requires zero blockers.

The single-label design projected independent facts — mergeability, check
status, review round — onto one totally-ordered value, so one always won
and the rest vanished. Every precedence bug this machine has had lived on
that ordering. Blockers are a set, so there is no precedence between them
to get wrong.

`state:bots-reviewing` tightens to mean strictly "a request is live"; a
ready PR nobody was asked to review is now `state:addressing` +
`blocker:unrequested`. The reconciler strips the retired
`state:needs-rebase` on sight via a RETIRED array.

Also wires test/labels-reconcile.sh into CI, where it had never run.

Fixtures 51 -> 64.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:30:53 +00:00
codex-bot-andresmgsl
32e17246a2 docs: align README quick start with main 2026-07-20 17:30:11 +00:00
dan-claude-bot
108a944e1c chore(labels): converge reconciler and fixtures on the canonical text
box, rig and cast carry the same reconciler, and the three had drifted to
functionally identical but textually different versions of checks_state
after the same fix was reached independently in each repo. Comment prose
and one fixture helper name differed, so the files were no longer
byte-identical and the convergence check had nothing to compare.

Standardised on the cast wording for checks_state and on the canonical
test/labels-reconcile.sh, which carries the same 51 fixtures under
overlap_() where this repo had named the helper drained_(). No behaviour
change: the diff to the reconciler is comments only, and the suite is
unchanged at 51 passed, 0 failed.

Only this repo's own scope:* rows are preserved as local content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:44:19 +00:00
dan-claude-bot
57e37363f4 fix(labels): order check runs by one consistent quantity — when they began
The dating expression took the newest stamp each run carries. That reads
`completedAt` for a finished run and `startedAt` for a live one, so the
comparison comes down to "when this one ended" against "when that one
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
`predecessor.completedAt > successor.startedAt` is the ordinary case, not a
corner. On the box#137 tip that motivated the supersede rule the window was
13s wide — the superseding run started 15:19:38, the run it cancelled did
not finish until 15:19:51 — and for that whole window the dying predecessor
out-dated its own live replacement, so `last` discarded the replacement and
judged the corpse.

Both round-3 failure modes came back inside that window, narrowed rather
than closed: a draining CANCELLED predecessor reported FAILURE and sent the
agent to fix nothing, and a draining SUCCESS predecessor reported SUCCESS —
mergeable, all bots approve, state:needs-human — over a tree whose merge
button branch protection had already disabled. #136 again, one field over.

Dated by `first` of the preference-ordered stamps rather than `max` of
them: start time if the run recorded one, falling back only if it did not.
The sentinel filtering is unchanged, and finished runs still date by
completion when that is all they carry, so the supersede rule keeps the
case it exists for.

Found independently by claude-bot-andresmgsl and codex-bot-andresmgsl.
No existing fixture could express it — `run_()` carries no startedAt, so
every supersede fixture spaced the predecessor's completion safely before
the successor's start, the same blind spot as round 3 one field over. New
`drained_()` helper pins both directions; fixtures 48 -> 51 (with the
reverse-direction in-flight fixture ported from cast#128).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:32:53 +00:00
dan-claude-bot
800571fdfb fix(labels): date a check run by when it started, not by a zero completion
The supersede collapse added in the previous commit dated each run by
`.completedAt // .startedAt // .createdAt`. 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 jq's `//` falls
through null and false only. The sentinel was therefore taken as the sort
key, and it sorts before every real timestamp — so the LIVE re-run became
the oldest entry in its context, `last` discarded it, and the run it
superseded was judged instead.

That restored #136 through the fix for it: a green context with a
replacement mid-flight reported SUCCESS, so a PR read mergeable, green,
all bots approve — state:needs-human — while branch protection had the
merge button disabled. It also narrowed rather than removed the flap the
supersede rule exists to prevent: between "run A cancelled by the
concurrency group" and "run B finishes", the PR reported FAILURE and the
agent was sent to fix something that was not broken.

Runs are now dated by the newest timestamp they actually carry, with both
spellings of absent discarded (null, and the zero sentinel). Entries that
carry no usable timestamp sort LAST rather than first: something we cannot
date is most likely the thing just created, and treating it as newest
keeps an undateable in-flight run from being discarded in favour of a
stale success. Every ambiguity resolves toward "not settled".

Found independently by claude-bot-andresmgsl and codex-bot-andresmgsl.
The fixtures could not have caught it: `run_()` always emits a real
completedAt, so every supersede fixture was a race between two finished
runs, and the bug lived in the one shape the helper could not express.
New `inflight_()` helper covers it; fixtures 44 -> 48.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:18:50 +00:00
dan-claude-bot
a0951eacf8 fix(labels): unrecognised check outcomes block, and STALE outranks MISSING
Round 2 review found two ways the "a human could merge this right now"
invariant still leaked, both of which let state:needs-human land on a PR
the button would refuse.

The check-rollup classifier enumerated the outcomes that block and
defaulted the rest to SUCCESS, so ERROR, CANCELLED and STALE fell through
into green. Inverted to an allow-list of the outcomes that DON'T block
(SUCCESS, NEUTRAL, SKIPPED, plus the pending set); everything else,
including an outcome neither enum has today, blocks. The rollup mixes
CheckRun.conclusion with StatusContext.state and an outcome the list
forgets is one we cannot certify as mergeable — a false FAILURE parks the
PR on the agent, a false SUCCESS invites a bad merge. The classifier also
moved out of main() into checks_state(), which is why no fixture caught
this: it was inline in the fetch loop and the jq itself was untestable.

Once CANCELLED blocks, superseded runs must be dropped first — a re-run
does not evict the run it replaced, and judging every entry would strand
every re-run PR in needs-rebase. Each context now collapses to its newest
entry, keyed on workflow + job name because a bare job name is only
unique within its workflow.

decide_state() returned from inside the bot loop on the first MISSING, so
a STALE belonging to a later bot in BOTS was never read: a round that was
both unfinished and staled came out needs-human with nothing bound to the
head. The whole round is now collected before any precedence is applied,
STALE ahead of MISSING. The MISSING-yields-to-an-explicit-human-request
rule is untouched.

Fixtures 29 -> 44, pinning the whole check-outcome enum, the supersede
rule in both orders, and the mixed round at both ends of BOTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 16:05:39 +00:00
dan-claude-bot
ef798f302c fix(labels): state:needs-human means a human could merge it right now
Ported from heavy-duty/box#137 (heavy-duty/box#136) so the three repos'
reconcilers stay byte-identical. The state machine here was byte-identical to
box's before this change, and remains so after -- only the scope:* taxonomy
differs, correctly.

decide_state() derived state from three inputs -- draft flag, requested
reviewers, submitted reviews -- and read NOTHING about mergeability or checks.
With the `if requested "$HUMAN"` short-circuit at the top of its precedence,
the label was sticky: once the maintainer was requested, a PR read
state:needs-human through conflicts, through red CI, through a force-push that
staled every approval.

This repo paid for it directly. During the ten-PR batch merged today, every
merge re-conflicted the PRs below it through CHANGELOG.md, and each kept its
state:needs-human label throughout -- inviting merges that could not happen.
It was caught only by opening them one at a time, which is the work the label
exists to save.

The rule the label now keeps: state:needs-human means a human could merge this
RIGHT NOW, so anything making that false outranks the request that put it
there.

  CONFLICTING or failing checks -> state:needs-rebase (new; the agent's to fix)
  approvals staled by a push    -> state:addressing   (nobody reviewed this tree)

An UNFINISHED round still yields to an explicit human request -- MISSING
(nobody has reviewed yet) is a different fact from STALE (everyone reviewed
something else). UNKNOWN mergeability is NOT treated as unmergeable: GitHub
reports it for about a minute after every merge, and flapping every open PR
through needs-rebase on each merge would be worse than the bug. A failed read
degrades to the same "do not know" value.

Also adds merge-next: queue order is intent, so the reconciler never sets it,
only CLEARS it once the PR stops being mergeable-by-a-human.

Fixtures 19 -> 29, including that UNKNOWN does not trigger needs-rebase and a
draft outranks a conflict. No live dry-run evidence here -- this repo has no
open PRs right now -- so the fixtures and box's live dry-run are the proof.

Closes #87

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:26:54 +00:00