DESIGN.md

Nix-native CI/CD for Tangled — Design

Status: design agreed, not yet implemented (2026-10-16).

A Tangled.org-compatible CI/CD system for a self-hosted soft-serve forge, built on Nix, devenv, NixOS and Colmena. It speaks Tangled’s lexicons and ATProto semantics (DIDs, AT-URIs, lexicon-typed records, cursor-resumable event streams), runs workflows in each repo’s devenv shell, and gates deploys behind a human decision that can be made from a phone.

1. Goals and non-goals

Goals

Non-goals (for now)

2. Principles (the decisions behind this design)

  1. No programming in YAML. The workflow file is an inventory of actions plus the default order. All logic (what to show, what happens next, when to ask a human) lives in code — Nix, shell, Justfiles — and reaches the engine through a typed JSON result contract.
  2. The engine is the product; the runner is a host for it. The same ci binary runs locally and on the runner, pinned per repo through devenv.
  3. Steps are the happy path; actions are everything the workflow can do. A step’s result can route to any declared action, including happy-path steps.
  4. Gates are monotonic. Code can add a gate, never remove one.
  5. Health is notify-only. Transient errors are common after a deploy; the operator decides whether to roll back.
  6. Secrets are a repo. An encrypted vault cloned at a known path; the secrets tool behaves identically everywhere. The engine has no secrets concept.
  7. Simplicity over isolation strength. Sandboxed systemd units, not VMs, until something needs more.

3. System overview

 forge host (infra.rtw.run, initially everything lives here)
 ┌──────────────────────────────────────────────────────────────────────────┐
 │ soft-serve ──post-receive──► bridge ── /events (sh.tangled.git.refUpdate)┐│
 │                                                                         ▼│
 │ runner daemon ── reads .tangled/workflows @ newSha, matches `when:`      │
 │    │             per run: worktrees, sandboxed unit ─► ci (engine)        │
 │    │              (ci.slice, user ci, pasta netns)       in devenv shell │
 │    │  tails <run>/events.ndjson                                          │
 │    └─► /events (sh.tangled.pipeline[.status], run.rtw.ci.*) , /logs/...  │
 │                                                                          │
 │ appview ◄── bridge /events + runner /events,/logs ── SQLite index        │
 │    └─► browser / phone (Tailscale or authed public), ntfy, JSON+text API │
 │                                                                          │
 │ host nix-daemon ─► binfmt aarch64-linux (─► optional remote builder)     │
 └──────────────────────────────────────────────────────────────────────────┘
 dev machine:  `ci run <workflow>` — same engine, same manifest, same recipes

The bridge, runner daemon and appview are three roles of one Go binary. They can run as one service but keep real websocket boundaries, so they can be split across hosts later.

Tangled role Here Responsibility
Knot bridge next to soft-serve Turn pushes into sh.tangled.git.refUpdate events
Spindle runner daemon + engine Discover and match workflows, run them, emit pipelines, status and logs
Appview appview Index streams, show runs, gates, actions

4. Protocol and identity

5. Bridge (forge side)

6. Workflow manifest

.tangled/workflows/<name>.yml. Pure data, validated against run.rtw.ci.workflow. No conditionals, expressions or loops.

when:                                   # matched by the runner when a ref update arrives
  - event: [push]
    branch: [main]
engine: devenv                          # Tangled's spindle ignores unknown engines
repos:                                  # extra checkouts, pinned per run
  - { repo: servers, path: ../servers }
  - { repo: data,    path: ../data, env: SECRETS_DIR }   # exports the path

steps: [test, plan, deploy, health-check, record, notify]   # the happy path

actions:
  test:         { command: just test }
  plan:         { dir: ../servers, command: just plan-project sovrn }
  deploy:       { dir: ../servers, command: just deploy-project sovrn,
                  gate: { prompt: "Deploy sovrn?" }, concurrency: fleet,
                  audited: true }
  health-check: { dir: ../servers, command: just prod-health-check sovrn }
  record:       { dir: ../servers, command: just record-deploy sovrn }
  notify:       { command: just notify-deploy }
  rollback:     { dir: ../servers, command: just rollback, style: danger,
                  manual: true, audited: true,
                  gate: { prompt: "Roll back sovrn?" } }

A smaller project needs only what it uses:

when: [{ event: [push], branch: [main] }]
engine: devenv
steps: [test, deploy]
actions:
  test:   { command: devenv test }
  deploy: { command: just deploy infra.mymood.at, gate: { prompt: "Deploy moods?" } }

Action fields

Field Meaning
command Shell command, run as devenv shell -- bash -euo pipefail -c '<command>'
dir Working directory relative to the repo checkout (default .)
gate { prompt, timeout } — always ask a human before running (default timeout 24h)
concurrency Named lock; { group, supersede } — supersede cancels older runs waiting at a gate for the same group
manual Show as a button on the run page at any time
style normal or danger (rendering only)
audited Local runs that execute this action report to the appview (§10)

The set of actions is closed: results may only route to declared actions. That makes the manifest an honest list of what the workflow can do, and it is what the UI renders as buttons.

7. Step result contract

stdout/stderr are logs. The result is written to the file at $CI_RESULT.

{
  "version": 1,
  "status": "failure",
  "summary": "mx99.eu.sovrn.at /healthz 503 for 4m (stalwart: connection refused)",
  "next": [
    { "action": "rollback", "prompt": "mx99 degraded after deploy. Roll back sovrn?", "then": "stop" },
    { "action": "health-check", "label": "Check again" }
  ],
  "gated": true,
  "attachments": [ { "label": "health probes", "path": "health.ndjson", "type": "ndjson" } ],
  "outputs": { "hosts": ["mx99.eu.sovrn.at"] }
}
Field Meaning
status success · warning · failure
summary Markdown shown on the run page, at gates and in notifications
next Omitted → default routing. "stop" → end the run. Otherwise one option or a list
next[].action A declared action (happy-path step or not)
next[].then After that action succeeds: continue (resume the happy path) or stop. Default: continue for happy-path steps, stop otherwise
next[].prompt, label, inputs Text for the person; data passed to the action as $CI_INPUTS
gated Ask a human before taking next
attachments Files under $CI_ARTIFACTS, rendered by type (ndjson, text, markdown)
outputs Merged into $CI_STATE/outputs.json

Fallbacks and errors

A ci-result helper on PATH writes valid results from shell:

ci-result fail --summary "mx99 /healthz 503 for 4m" \
  --next rollback --prompt "Roll back sovrn?" --then stop \
  --next health-check --label "Check again" \
  --attach "health probes=health.ndjson"

8. Routing semantics

The run keeps a cursor into steps.

run action → read result
  ├─ next omitted  → success: happy-path action → next step; other action → stop
  │                   failure: stop
  ├─ next = "stop" → end run
  └─ next = [A, …] → gate if (any option's action declares gate) OR result.gated OR >1 option
                      gate offers each option + Dismiss (end run with this step's status)
                      no gate → run A
  naming a happy-path step moves the cursor there (forward skips, backward repeats)
  after A succeeds: then=continue → next step after cursor; then=stop → end run

Rules

  1. Gates are monotonic: a declared gate can’t be bypassed by a result. A choice between several options always needs a person.
  2. Off-path actions end the run unless their then or their own result says otherwise. Nothing slides back onto the happy path by accident.
  3. The chosen action receives $CI_TRIGGER_RESULT (the result that routed to it), $CI_INPUTS, and the shared $CI_STATE.
  4. Loop guard: at most 50 transitions per run (configurable).
  5. Final status, by how the run ends:
    • it runs off the end of the happy path → success;
    • a result says next: "stop" → success, or failure if that result is a failure (the way for an action to end a run early on purpose);
    • an action’s then: stop → failure if that action is off the happy path (“diverted: stopped after rollback”), even when the last step had already started; for a happy-path step, success only if it was the last one;
    • a failure with no next → failure; a contract or engine error → failure; the loop guard → failure;
    • dismissing a declared gate reached by default routing → cancelled; dismissing a gate raised by a result → that result’s status (failure stays failure, success/warning → success); an expired gate, --until or Ctrl-C → cancelled. These map onto sh.tangled.pipeline.status (cancelled and failed).
  6. Gates always notify, and runs that end unsuccessfully always notify (runner config, not code).
  7. Approving a gate or running a danger action requires the owner’s logged-in session. The agent API token (used from /rc) can read, trigger and re-run, but not approve.

9. Engine (ci)

One Go binary. Repos pin it as a devenv input (packages = [ inputs.ci… ]), so the dev machine and the runner use the identical engine for a given commit.

Commands

ci validate                         # manifest + actions resolvable; schemas
ci run <wf>                         # from the working copy; gates prompt in the terminal
ci run <wf> --clean [--rev @-]      # fresh worktree of a commit, as the runner does
ci run <wf> --until plan            # stop before an action: safe rehearsal
ci run <wf> --stub health-check=fixtures/degraded.json   # fake a result, test routing
ci run <wf> --detach                # exit at gates, as on the runner
ci action <name> --no-follow        # run one action, print result + what routing would do
ci resume <run> [--choose rollback|N] [--dismiss] [--expire]   # answer a paused run's gate
ci action rollback --run <run>      # manual action against an earlier run's state
ci view <run>                       # render events.ndjson
ci runs                             # list local runs ($CI_RUNS_DIR, default ~/.local/state/ci/runs)
ci trigger <repo> <wf> [--rev]      # ask the runner for a manual pipeline
ci wait <repo> <sha>                # block until done; print failing step's logs

Actions run as devenv shell bash -euo pipefail -c 'cd "$CI_ACTION_DIR"; <command>' started in the main checkout, so the workflow’s repo pins the toolchain and the engine even when an action’s dir is another repo (../servers). --shell bash skips devenv, for debugging only. Exit codes: 0 success, 1 failure, 2 usage, 3 cancelled, 75 paused at a gate.

A local ci run deploy deploys production for real, behind the same gates. --until and --stub are the rehearsal tools.

Environment contract (versioned with the result schema)

Variable Contents
CI_RUN_DIR The run’s directory
CI_RESULT Where this action writes its result
CI_STATE Shared state for the run’s actions
CI_ARTIFACTS Attachment directory
CI_INPUTS Path of a JSON file: the inputs of the next entry that routed here ({} if none)
CI_TRIGGER_RESULT Path of the result file that routed here (empty if none)
CI_ROOT, CI_WORKFLOW, CI_ATTEMPT Main checkout; workflow name; execution number in the run
CI_SHA, CI_REF, CI_REPO What’s being run
CI_RUN_ID, CI_ACTION Identity of the run and action
CI_HOST local or runner. Informational; recipes must not branch on it
CI, TANGLED_* CI=true and upstream’s TANGLED_SHA, TANGLED_REF, TANGLED_REF_NAME, TANGLED_REF_TYPE, TANGLED_PIPELINE_ID, TANGLED_PIPELINE_KIND, TANGLED_REPO_*, set in both modes for compatibility with upstream workflows
per-repo env: Paths exported by repos: entries (e.g. SECRETS_DIR)

Run directory

<run>/manifest.json        state.json   (cursor, history, pending gate)
      events.ndjson        (log lines, results, gates, transitions — one format everywhere)
      state/  artifacts/  results/<n>-<action>.json
      <repo>/  <extra repos>/   (worktrees; layout mirrors ~/projects)

Repos

Manual actions against a run: ci action --run <run> <action> (only manual: true actions, as in the appview) starts a child run that shares the parent’s checkouts and $CI_STATE (and gets $CI_PARENT_RUN). It runs the action, follows only what its result explicitly routes to, and ends with the action’s own status, so a rollback is recorded as its own run (“rollback done”) instead of rewriting the deploy run’s history. Declared gates apply.

Segments: a gate ends the process. The engine runs actions until it reaches a gate, writes a gate event, saves state.json and exits with a “paused” status. Locally it prompts inline instead (unless --detach). On the runner the daemon publishes the gate and later starts ci resume <run> --choose X in a new unit. Nothing runs while a gate waits, and runner restarts never lose a run.

Concurrency: flock on <runs>/.locks/<group>.lock (the runner passes /var/lib/ci/locks). A run takes a group’s lock when it first runs an action in the group and holds it until it ends or pauses at a gate, so a run waiting for a person never blocks others. supersede: true cancels older runs of the same repo and workflow waiting at a gate in that group when a newer run reaches one. Gates past expiresAt expire on ci resume (any answer) or ci resume --expire (the runner calls it on a timer). Local runs with the appview configured (CI_APPVIEW_URL, CI_TOKEN) take their groups from the runner instead: a lease on the same file lock the runner’s segments use (POST /api/locks/<group>, renewed every 30 s, released after 2 minutes without renewal), so a laptop deploy and a CI deploy can’t overlap. If the runner can’t be reached they warn once and lock locally; --local-locks forces that. Optional later: ask the runner for the lock when reachable, so a laptop deploy and a CI deploy can’t overlap.

10. Runner daemon (CI host)

systemd-run --unit=ci-<run>-<seg> --slice=ci.slice --uid=ci --gid=ci \
  -p ProtectSystem=strict -p ProtectHome=yes -p PrivateTmp=yes -p NoNewPrivileges=yes \
  -p "ReadWritePaths=<run> <cache>/home/<repo> <locks> /nix/var/nix/daemon-socket" \
  -p LoadCredential=age-identity:<path> -p StandardOutput=append:<run>/segment-<n>.log \
  --setenv=HOME=<cache>/home/<repo> \
  -- pasta --config-net -t none -u none -T none -U none -- ci runner-exec <run>

Isolation (protects the host’s config and services, not against attack)

Concern Control
Writes to /etc, units, users, system profile ProtectSystem=strict, non-root ci user, not in trusted-users. Only an explicit deploy action can change host config
Port clashes (devenv postgres vs. production postgres) Opt-in network namespace per segment via pasta (egress, pastaHostPorts forwarded to the host). Off by default: in pasta’s user namespace commands run as uid 0, root-owned files look unowned (OpenSSH refuses its config, so no SSH and no deploys) and the hostname reads pasta-<host>. A systemd-made namespace with pasta attached from outside would avoid that, but pasta’s own sandboxing refuses to open the unit’s namespace (tried; see bug 7a3d5cf/db62e70 notes)
CPU / memory / IO starvation ci.slice with low CPUWeight/IOWeight, MemoryMax; swap or zram so builds can’t OOM soft-serve; 1–2 concurrent runs
Disk Run dirs expire after N days; host nix.gc and min-free/max-free
Nix store Shared through the host nix-daemon socket; append-only, so growth is disk usage, not pollution

The runner daemon runs as root (it creates transient units for ci; granting ci polkit’s manage-units would let a segment start root units), hands each run directory and its repo’s HOME to ci before a segment starts, and is reachable on localhost only. Segments get a fixed PATH (engine, bash, coreutils, git, openssh, nix, jq, curl, just, devenv), NIX_REMOTE=daemon, CI_SELF_HOST (deploy recipes put that host last) and credentials: the age identity (SECRETS_IDENTITY) and an SSH key, found by the ci user’s static ~/.ssh/config (IdentityFile ${CREDENTIALS_DIRECTORY}/ssh-key; OpenSSH reads the passwd home, and ${VAR} expands per unit), with strict host keys except the forge on localhost. nix/module.nix packages all of this as services.ci.{bridge,runner,appview}; nix/tests/e2e.nix checks it in a VM.

Multi-arch: jobs use the host nix-daemon, which routes derivations by system. x86_64 work runs natively. aarch64 work (nix build / colmena build / flake checks) goes to a builder host as a remote builder: any host with the builder role in servers’ hosts.json (planned: mx99.eu.sovrn.at, the aarch64 staging host), listed in the CI host’s nix.buildMachines from that data (servers/roles/ci/builders.nix). boot.binfmt.emulatedSystems = [ "aarch64-linux" ] stays as a slow fallback. The builder role (servers/roles/builder) is separate from the sovrn roles: a key-only nix-ssh login whose forced command is nix-daemon --stdio, with its own key pair (keys/nix-builder.pub, secret nix-builder-ssh-key). It is a trusted user, since the CI host’s own build outputs carry no signature the builder knows; that grants nothing new, because the CI host already has root on the fleet. The builder fetches inputs from the caches itself (builders-use-substitutes), and limits builds: max-jobs = 1, cores = 1, idle CPU/IO scheduling, and a memory ceiling plus low weights on nix-daemon.service (the daemon runs every build, including those that arrive over SSH; a VM test checks the cgroup), and zram. servers’ checks.x86_64-linux.builder exercises both halves. Measured on mx99 (2 vCPU / 3.8 GB): small builds are unnoticeable, but a from-source Rust compile the size of Stalwart’s needs well over 4 GB. With the limits above it was throttled (MemoryHigh hit millions of times, zram full, about a third of a core of CPU) and never killed, and the host’s services stayed up. Builders of this size are not what mx99 is for: such builds go to the dev machine (qemu, slow, 64 GB) or, later, a dedicated ARM builder.

Binary cache: the CI host’s own /nix/store is the cache, served read-only by harmonia behind Caddy (basic auth, one shared netrc: store paths reveal host layout, even though they hold no secrets). Harmonia signs what it serves, so producers need no key: they push with nix copy --to ssh-ng://root@<ci host> (root over SSH, as deploys do); outputs a remote builder returns to the CI host’s store arrive without any push:

The cache is not a repository of project state (the runner pushes nothing to repos): losing it only costs rebuild time. GC on the CI host (min-free / max-free) must not eat it silently: pin GC roots for the current closure of each host (last N deployed generations), and watch disk (30 GB on infra.rtw.run). Only identical derivations hit; a dirty tree or different input pin builds fresh, which is correct.

Workspaces: per-run worktrees (a gate can hold a run open for hours); HOME and .cache shared per repo (accepted shared state). .devenv/ is per run; activation still hits a warm store. Host substituters include devenv.cachix.org, since the ci user can’t add substituters itself.

Host sizing: infra.rtw.run stays unresized (2 vCPU / 3.8 GB, x86_64, no swap). That is enough because builds happen elsewhere: x86_64 on the dev machine, aarch64 on mx99, both pushed to the cache, so the runner mostly evaluates and substitutes (fleet eval ~1-2 GB). Requirements: zram on the host, ci.slice with MemoryMax about 2.5 GB, one concurrent run, and nix.settings.max-jobs of 1. Revisit (resize, or a dedicated ARM builder) if runs start building locally or two overlapping runs become necessary. infra.mymood.at (4 vCPU / 8 GB) was considered as the runner host and rejected: it carries the live moods app and backup MX, and the runner holds root on the fleet.

11. Appview

Minimal server-rendered HTML (Go templates, plain CSS, ~40 lines of vanilla JS for live logs and confirmations; every page works without JS), reachable from a phone. It is a stateless view over the runner’s API (one runner, so no index of its own); a second runner would bring back an index fed by the runners’ /events.

Auth: the owner signs in with a password (bcrypt hash file, e.g. mkpasswd -m bcrypt -R 12); the session is an HMAC-signed, SameSite=Strict cookie, and every form carries a session-bound CSRF token. Agents use bearer tokens from a <name> <token> file: they can read runs and plain-text logs, trigger workflows and run manual actions that aren’t style: danger, never approve gates or cancel. Approvals are recorded with the owner’s DID as actor. ci trigger <repo> [workflow] and ci wait <repo> [sha] use the agent API (CI_APPVIEW_URL, CI_TOKEN); ci wait exits like the run (75 when it is waiting for you).

12. Secrets

13. CD pattern for Colmena projects (lives in project repos, not the engine)

servers/Justfile recipes that any Colmena fleet can copy:

Recipe Does Result
plan-project P nix flake update P; colmena build affected hosts; nvd diff against readlink /run/current-system summary = diffs
deploy-project P Record each host’s current system to $CI_STATE/prev-systems.json; colmena apply --on <hosts>; CI host last —
prod-health-check P Poll the project’s health URLs (status code only, as sovrn’s /healthz contract defines); N consecutive passes within a window success, or failure offering rollback / “Check again”, gated, with probes attached
rollback Per host from prev-systems.json: nix-env -p /nix/var/nix/profiles/system --set <prev> && <prev>/bin/switch-to-configuration switch —
record-deploy P Commit and push flake.lock (the lock bump stays the deploy log) —

Notes

14. Local vs CI parity

Local default Runner Narrowing the gap
Engine, manifest, recipes, devenv identical (pinned by commit) identical —
Source working copy clean worktree at sha --clean --rev
Extra repos, vault clean clones at pushed revs same --repo override
Sandbox none, your user ci user, ProtectSystem, pasta, slice --isolated (systemd-run --user + pasta)
Nix config your substituters, binfmt runner’s keep both in a shared config fragment
Secrets identity yours runner’s both are vault recipients
Locks local flock runner flock optional remote lock
History local run dir appview --report / audited

15. Implementation order

Tracked as issues in this repo under eae4f87 (bug ls), one at a time:

  1. f25b56a secrets: multiple recipients, rekey, SECRETS_IDENTITY.
  2. 5434405 Contracts and project skeleton: lexicons, generated types, Go module.
  3. de85a99 Engine core: manifest, action execution, result contract, routing, events, local CLI.
  4. b9fb99a Engine: repo checkouts at pinned revisions, env: exports, --clean.
  5. dea75b4 Engine: gates, pause/resume, manual actions, concurrency, loop guard.
  6. 65e8495 Adopt locally: sovrn, servers, moods workflows and recipes; local gated CD works end to end.
  7. de35782 Bridge.
  8. 7a3d5cf Runner daemon.
  9. 7f8343d Appview.
  10. db62e70 Deploy onto the forge host: NixOS modules, host resize/rebuild, CI identity via Colmena.
  11. adca87e Cutover and end-to-end validation (phone + /rc flow, self-deploy).
  12. 78c84e0 Local run reporting and cross-host locks.

Later: remote aarch64 builder; contributing a devenv engine to upstream’s spindle (it now has a microVM engine and pluggable engines); ATProto OAuth login; service-auth JWTs; monitor reading health checks; nspawn/microvm engines for untrusted code; publishing records to a PDS.