nixbot

builds

failed treefmt-check checks.aarch64-darwin.treefmt · build #252 · raw

1treefmt v2.5.0traversed 295 files2emitted 246 files for processing3formatted 246 files (1 changed) in 1.536s4 M PLAN-radicle.md5diff --git a/PLAN-radicle.md b/PLAN-radicle.md6index 7af7117..2b1ad28 1006447--- a/PLAN-radicle.md8+++ b/PLAN-radicle.md9@@ -2,200 +2,189 @@10 11 Add Radicle as a fourth forge next to GitHub, Gitea and GitLab.12 13-Radicle has no HTTP API, no webhooks and no commit-status endpoint.14-Everything runs against a local `radicle-node` instead:15+Radicle has no HTTP API, no webhooks and no commit-status endpoint. Everything16+runs against a local `radicle-node` instead:17 18 - **Change source**: subscribe to the node's event stream.19 - **Fetching**: repos already sit on disk in the node's storage.20-- **Reporting**: write "job" collaborative objects (COBs) that gossip21- through the network.22+- **Reporting**: write "job" collaborative objects (COBs) that gossip through23+ the network.24 25-radicle-desktop renders job COBs in its commit view, patch review and26-source header, so users see a ✓/✗ per commit linking to nixbot's web27-frontend.28+radicle-desktop renders job COBs in its commit view, patch review and source29+header, so users see a ✓/✗ per commit linking to nixbot's web frontend.30 31 ## Why not a ci-broker adapter?32 33-Radicle's CI broker (`radicle-ci-broker`) handles triggering, filtering34-and COB writing, and dispatches to adapters — nixbot could be one. But35-its one-shot "run adapter, get verdict" model cannot express36-per-attribute builds, rerun-from-UI or effects, and it would stack a37-second queue and state machine in front of nixbot's scheduler. nixbot38-talks to the node directly; operators who want the broker's report39-pages can run it separately.40+Radicle's CI broker (`radicle-ci-broker`) handles triggering, filtering and COB41+writing, and dispatches to adapters — nixbot could be one. But its one-shot "run42+adapter, get verdict" model cannot express per-attribute builds, rerun-from-UI43+or effects, and it would stack a second queue and state machine in front of44+nixbot's scheduler. nixbot talks to the node directly; operators who want the45+broker's report pages can run it separately.46 47 ## 1. Tests first (red before green)48 49 Write these tests first; §2–§6 make them pass.50 51 - **Harness**: a pytest fixture building a real radicle setup in a temp52- `RAD_HOME` (`rad auth --stdin`, `rad init`, `radicle-node`), like the53- nixpkgs radicle tests. All fixtures come from it, no hand-written54- JSON. Adds `radicle-node` and `radicle-job` to the dev shell and55- flake test inputs; tests skip when the binaries are missing.56-- **Unit, events** (§2): event JSON → change mapping; patch-COB57- revision extraction (head, base, author DID) from a real patch.58-- **Unit, poster** (§4): status transitions → expected `rad-job`59- invocations; `StatusState` mapping.60-- **Idempotency** (§4): re-posting a terminal state after a nixbot61- restart must not duplicate runs.62-- **Integration** (§2–§5): spawn `radicle-node` in a temp `RAD_HOME`,63- `rad init` a flake repo, open a patch; assert nixbot builds it,64- `rad-job list` shows the runs, the central clone borrows objects via65- alternates (near-empty objects dir), and clone corruption after a66- storage prune recovers via re-clone.67-- **NixOS VM test** (§6): three nodes (alice, a seed, nixbot), modeled68- on `nixos/tests/radicle-ci-broker.nix`.69+ `RAD_HOME` (`rad auth --stdin`, `rad init`, `radicle-node`), like the nixpkgs70+ radicle tests. All fixtures come from it, no hand-written JSON. Adds71+ `radicle-node` and `radicle-job` to the dev shell and flake test inputs; tests72+ skip when the binaries are missing.73+- **Unit, events** (§2): event JSON → change mapping; patch-COB revision74+ extraction (head, base, author DID) from a real patch.75+- **Unit, poster** (§4): status transitions → expected `rad-job` invocations;76+ `StatusState` mapping.77+- **Idempotency** (§4): re-posting a terminal state after a nixbot restart must78+ not duplicate runs.79+- **Integration** (§2–§5): spawn `radicle-node` in a temp `RAD_HOME`, `rad init`80+ a flake repo, open a patch; assert nixbot builds it, `rad-job list` shows the81+ runs, the central clone borrows objects via alternates (near-empty objects82+ dir), and clone corruption after a storage prune recovers via re-clone.83+- **NixOS VM test** (§6): three nodes (alice, a seed, nixbot), modeled on84+ `nixos/tests/radicle-ci-broker.nix`.85 86 ## 2. Change source: the node event stream87 88-New module `nixbot/nixbot/forge/radicle.py`, enabled by a `radicle`89-config section (`RadicleConfig`):90+New module `nixbot/nixbot/forge/radicle.py`, enabled by a `radicle` config91+section (`RadicleConfig`):92 93-| option | default | meaning |94-|-----------------|--------------|------------------------------------------|95-| `radicle_home` | `~/.radicle` | control socket, storage, keys |96-| `rid_allowlist` | unset | repos to build (default: all seeded) |97-| `follow_nodes` | unset | NIDs whose announced repos are auto-seeded and enabled |98+| option | default | meaning |99+| --------------- | ------------ | ---------------------------------------------------------- |100+| `radicle_home` | `~/.radicle` | control socket, storage, keys |101+| `rid_allowlist` | unset | repos to build (default: all seeded) |102+| `follow_nodes` | unset | NIDs whose announced repos are auto-seeded and enabled |103 | `patch_trust` | `delegates` | whose patches to build: `delegates` \| `followed` \| `all` |104 105 **Subscribing.** Connect to `node/control.sock` and send the JSON line106-`{"command": "subscribe"}`; the node streams events as JSON lines (the107-stream `rad node events` prints). Subscriptions have no server-side108-expiry — the stream ends only on socket EOF or error; then reconnect109-with backoff.110+`{"command": "subscribe"}`; the node streams events as JSON lines (the stream111+`rad node events` prints). Subscriptions have no server-side expiry — the stream112+ends only on socket EOF or error; then reconnect with backoff.113 114 **Catching up.** Events missed while disconnected are gone. On every115-(re)connect, compare sigrefs of all seeded repos against known heads116-and process whatever moved. For patches, build only the latest revision117-per patch; revisions superseded while offline are skipped, like118-force-pushed-away commits on other forges. Patch close/merge does not119-cancel in-flight builds.120+(re)connect, compare sigrefs of all seeded repos against known heads and process121+whatever moved. For patches, build only the latest revision per patch; revisions122+superseded while offline are skipped, like force-pushed-away commits on other123+forges. Patch close/merge does not cancel in-flight builds.124 125 **Events consumed:**126 127-- `RefsFetched` — refs arrived from a peer. Its `updated` list uses128- namespaced names (`refs/namespaces/<nid>/refs/...`):129- - delegate namespace, `refs/heads/<default>` moved → default-branch130- build (`head_changed`);131- - `refs/cobs/xyz.radicle.patch/*` moved (any namespace) → reload the132- patch COB and build its latest revision. The revision records head133- and base, so the change event carries `commit_sha=<head>`,134- `base_sha=<base>`, `branch=<default>`, `pr_author=<author DID>` and135- the existing merge-head-into-base pipeline works unchanged.136-- `LocalRefsAnnounced` — a local push; carries only a sigrefs pointer,137- so run the catch-up for that repo.138-- `SeedDiscovered` / `InventoryAnnounced` — repo lists from peers. When139- the announcing NID is in `follow_nodes`, seed the RID (`rad seed`,140- scope `followed`) and register the project. Radicle has no "seed141- repos of followed users" policy (following only scopes ref fetching;142- a node-wide allow policy would seed the whole network), so this143- subscription lives in nixbot.144-145-**Discovery.** `rad ls` has no JSON output, so enumerate the146-`storage/<rid>` directories and read each identity payload with147-`rad inspect --payload <rid>` (name, description, default branch,148-visibility) into `DiscoveredRepo`: `forge="radicle"`,149-`forge_repo_id=<RID>`, `clone_url=<storage path>`. A private RID150-becomes a private project under the existing `visibility.py` rules.151+- `RefsFetched` — refs arrived from a peer. Its `updated` list uses namespaced152+ names (`refs/namespaces/<nid>/refs/...`):153+ - delegate namespace, `refs/heads/<default>` moved → default-branch build154+ (`head_changed`);155+ - `refs/cobs/xyz.radicle.patch/*` moved (any namespace) → reload the patch COB156+ and build its latest revision. The revision records head and base, so the157+ change event carries `commit_sha=<head>`, `base_sha=<base>`,158+ `branch=<default>`, `pr_author=<author DID>` and the existing159+ merge-head-into-base pipeline works unchanged.160+- `LocalRefsAnnounced` — a local push; carries only a sigrefs pointer, so run161+ the catch-up for that repo.162+- `SeedDiscovered` / `InventoryAnnounced` — repo lists from peers. When the163+ announcing NID is in `follow_nodes`, seed the RID (`rad seed`, scope164+ `followed`) and register the project. Radicle has no "seed repos of followed165+ users" policy (following only scopes ref fetching; a node-wide allow policy166+ would seed the whole network), so this subscription lives in nixbot.167+168+**Discovery.** `rad ls` has no JSON output, so enumerate the `storage/<rid>`169+directories and read each identity payload with `rad inspect --payload <rid>`170+(name, description, default branch, visibility) into `DiscoveredRepo`:171+`forge="radicle"`, `forge_repo_id=<RID>`, `clone_url=<storage path>`. A private172+RID becomes a private project under the existing `visibility.py` rules.173 174 ## 3. Fetching175 176 Nothing to authenticate: the clone URL is the local bare repo under177 `storage/<rid>`; the credentials provider returns empty credentials and178-`git fetch` uses the path. The default branch comes from the identity179-payload, not HEAD guessing.180+`git fetch` uses the path. The default branch comes from the identity payload,181+not HEAD guessing.182 183 **Storage sharing via alternates.** Create the central clone with184-`git clone --bare --reference storage/<rid>`: it borrows objects from185-the node's storage, so fetches only update refs and the clone stays186-near-empty. Build merge commits land in nixbot's clone; the node's187-storage is never written to, and worktrees work unchanged. If the node188-prunes borrowed objects the clone corrupts — covered by gitrepo.py's189-existing delete-and-re-clone recovery. Do not point worktrees at190-`storage/<rid>` directly: merges and fetch locks would fight the node.191+`git clone --bare --reference storage/<rid>`: it borrows objects from the node's192+storage, so fetches only update refs and the clone stays near-empty. Build merge193+commits land in nixbot's clone; the node's storage is never written to, and194+worktrees work unchanged. If the node prunes borrowed objects the clone corrupts195+— covered by gitrepo.py's existing delete-and-re-clone recovery. Do not point196+worktrees at `storage/<rid>` directly: merges and fetch locks would fight the197+node.198 199 ## 4. Reporting: job COBs200 201-`RadicleStatusPoster` implements the existing `CommitStatusPoster`202-protocol:203+`RadicleStatusPoster` implements the existing `CommitStatusPoster` protocol:204 205-- **pending** → create (or find) the job COB for the commit, attach a206- `Started` run with the `log` URL = nixbot's build page;207+- **pending** → create (or find) the job COB for the commit, attach a `Started`208+ run with the `log` URL = nixbot's build page;209 - **success** → mark the run `Succeeded`;210-- **failure / error** → mark it `Failed` (`StatusState` has no211- cancelled member; cancellation arrives as error);212+- **failure / error** → mark it `Failed` (`StatusState` has no cancelled member;213+ cancellation arrives as error);214 - announce after each write so seeds pick it up.215 216-COB writes shell out to the `rad-job` CLI (`new`, `run`, `succeeded`,217-`failed`, `list`), which signs with the profile key in `radicle_home`.218-`rad-job run` generates the run UUID and prints it; `succeeded`/`failed`219-take commit oid + UUID, so run UUIDs are persisted in the DB the way220-`CheckRunStore` persists GitHub check-run ids.221+COB writes shell out to the `rad-job` CLI (`new`, `run`, `succeeded`, `failed`,222+`list`), which signs with the profile key in `radicle_home`. `rad-job run`223+generates the run UUID and prints it; `succeeded`/`failed` take commit oid +224+UUID, so run UUIDs are persisted in the DB the way `CheckRunStore` persists225+GitHub check-run ids.226 227-**Granularity.** One run per commit: an aggregate verdict plus a link,228-like the single "nixbot" status context on Gitea/GitLab. One run per229-check attribute is deferred: runs have no name field and desktop labels230-rows by the log URL, so dozens of near-identical labels may be231-unreadable. Validate in desktop before adopting.232+**Granularity.** One run per commit: an aggregate verdict plus a link, like the233+single "nixbot" status context on Gitea/GitLab. One run per check attribute is234+deferred: runs have no name field and desktop labels rows by the log URL, so235+dozens of near-identical labels may be unreadable. Validate in desktop before236+adopting.237 238 ## 5. Wiring into the service239 240-- `service.py` builds the event source, credentials provider and status241- poster when `config.radicle` is set, and registers `"radicle"` in the242- forge dispatch tables.243+- `service.py` builds the event source, credentials provider and status poster244+ when `config.radicle` is set, and registers `"radicle"` in the forge dispatch245+ tables.246 - Projects are stored with `forge="radicle"`, `forge_repo_id=<RID>`.247-- **Patch numbers.** The builds schema types `pr_number` as `int`, but248- radicle patch ids are git OIDs. A `radicle_patches(project_id,249- patch_oid, number)` table hands out a per-project sequential number250- on first sight; schema and queries stay untouched, users get short251- patch numbers, the full OID appears on the build page.252-- **Permissions.** No forge login exists; restart/cancel follows the253- existing non-forge login rules (`visibility.py`). Which patches get254- built is `patch_trust` (global and per repo): `delegates` (default),255- `followed` (authors the node follows), `all` (rely on the sandbox).256+- **Patch numbers.** The builds schema types `pr_number` as `int`, but radicle257+ patch ids are git OIDs. A `radicle_patches(project_id,258+ patch_oid, number)`259+ table hands out a per-project sequential number on first sight; schema and260+ queries stay untouched, users get short patch numbers, the full OID appears on261+ the build page.262+- **Permissions.** No forge login exists; restart/cancel follows the existing263+ non-forge login rules (`visibility.py`). Which patches get built is264+ `patch_trust` (global and per repo): `delegates` (default), `followed`265+ (authors the node follows), `all` (rely on the sandbox).266 267 ## 6. NixOS module268 269 `services.nixbot.radicle.enable` plus options mirroring `RadicleConfig`.270 271-nixbot runs its **own dedicated radicle-node** with its own identity272-(alias "nixbot") instead of sharing an operator's seed node: job-COB273-runs are attributed per node identity, so results show up under the274-nixbot alias in desktop, and a nixbot compromise never touches the seed275-node's identity. Costs: a second node process and storage copy (largely276-neutralized by §3 alternates), and keeping "enabled in nixbot" in sync277-with "seeded by the node" — the module seeds the allowlist; discovery278-seeds the rest.279-280-The node is **publicly reachable by default** (listen address, opened281-firewall port, configurable `externalAddresses`), so seeds and users282-fetch nixbot's job COBs directly. Users must allow/follow the nixbot283-node identity for its COBs to replicate to them; document this in the284-setup guide.285+nixbot runs its **own dedicated radicle-node** with its own identity (alias286+"nixbot") instead of sharing an operator's seed node: job-COB runs are287+attributed per node identity, so results show up under the nixbot alias in288+desktop, and a nixbot compromise never touches the seed node's identity. Costs:289+a second node process and storage copy (largely neutralized by §3 alternates),290+and keeping "enabled in nixbot" in sync with "seeded by the node" — the module291+seeds the allowlist; discovery seeds the rest.292+293+The node is **publicly reachable by default** (listen address, opened firewall294+port, configurable `externalAddresses`), so seeds and users fetch nixbot's job295+COBs directly. Users must allow/follow the nixbot node identity for its COBs to296+replicate to them; document this in the setup guide.297 298 ## Version floor299 300-radicle-job 0.6 pins the `radicle` crate at 0.24; current heartwood301-ships 0.25.1 (`rad` release 1.10.x). Require nixpkgs radicle-node302-≥ 1.10. The `Event` enum is `#[non_exhaustive]`: the JSON parser must303-ignore unknown event types and fields.304+radicle-job 0.6 pins the `radicle` crate at 0.24; current heartwood ships 0.25.1305+(`rad` release 1.10.x). Require nixpkgs radicle-node ≥ 1.10. The `Event` enum is306+`#[non_exhaustive]`: the JSON parser must ignore unknown event types and fields.307 308 ## Out of scope309 310 - Per-attribute runs in the job COB (§4).311-- Patch comments: radicle-explorer cannot render job COBs yet, so312- web-only users see no status. An edit-in-place summary comment per313- patch revision (`rad patch comment`) is a follow-up.314+- Patch comments: radicle-explorer cannot render job COBs yet, so web-only users315+ see no status. An edit-in-place summary comment per patch revision316+ (`rad patch comment`) is a follow-up.317 - Radicle-based login in the web UI.318 - Merge gating — radicle has no required-checks concept.319 320 ## Risks321 322-- The job-COB schema may still move (radicle-job 0.6.x); all COB I/O323- goes through the `rad-job` CLI, so only the CLI needs updating.324+- The job-COB schema may still move (radicle-job 0.6.x); all COB I/O goes325+ through the `rad-job` CLI, so only the CLI needs updating.326 - No event delivery guarantee across restarts; the catch-up in §2 is327 load-bearing, not an optimization.328-- Patch builds evaluate attacker-controlled Nix code — same threat329- model as PRs, mitigated by the `delegates` default (§5) and330- sandbox-only execution.331+- Patch builds evaluate attacker-controlled Nix code — same threat model as PRs,332+ mitigated by the `delegates` default (§5) and sandbox-only execution.