CI for Gunnar: the options
Question: what should actually run Gunnar's checks: brokkr on oden, GitHub Actions, or nothing? Written: 2026-08-03 · machine oden · repo at b3b003c Decided: 2026-08-05 — see the box below. Nothing here is implemented, and that is now the answer rather than a caveat. No workflow is enabled, no run was triggered, no repo is registered, no key was minted.
⚠ 2026-08-05: §4 below is written against a file that no longer exists.
There is no
.github/in this repository. It was added inb07ca38and deleted ine50b962on 2026-08-03 14:16 UTC, by a commit whose message reads "Nothing a tool parses moved: only prose" and whose diff is.github/workflows/ci.yml | 300 ---------. §4 andstinky.mdS-033 both went on describing it in the present tense for two days, agreeing with each other. §4 is kept, marked, rather than rewritten: what it settles about Actions is still the argument, and the fact that a settled decision rested on a deleted file is the finding.Nothing was restored. A workflow that has never run is not evidence.
The decision
cargo xtask ci IS Gunnar's CI, and a human types it. There is no other CI, and none is being added for this release.
That is an answer, not a deferral. It is acceptable for a released project here because:
- The command covers the tree. fmt, clippy, preflight, the workspace suite, the four detached packages
--workspacecannot see, the cold tier, the appliance build, thesystemdfeature check and the dependency guard — one command, noGITHUB_*variable, no runner image. - Every hosted option available today would ship a green nobody has seen red: a
brokkr.tomlgreens container steps having executed nothing (§2), Actions has never executed once (§4), and the livedwarves-serveron oden dispatches toDemoRunner(§3c). On a project whose whole guard culture is LAW 2, adopting one of those on release day would put the disease into the build system. - The one thing that made "type it yourself" untrustworthy is fixed. Until 2026-08-05,
cargo xtask cirun from a linked git worktree printedNOT RUNfor theznippyandcoldtiersteps and returned SUCCESS —crates/gunnar-coldtierreaches znippy by a relative path that resolves to the worktree's parent, so a lane at/home/rickard/scratch/cargo/<lane>/wtlooked for/home/rickard/scratch/cargo/znippyand found nothing. Every lane ran from a worktree. The covering command was quietly reporting a smaller suite than the same command in the main checkout, which is why two different suite totals circulated on 2026-08-04/05 and only one of them said which suite it was. It now refuses, naming the checkout to run in.
What is NOT claimed: nobody is stopped from pushing without running it. The cheapest fix is a pre-push hook, five minutes, deliberately not installed — a 5-minute gate on every push with forty lane worktrees live is a decision with a cost, and it is Rickard's.
Recommendation (as written 2026-08-03, and still the ranking)
cargo xtask ci already IS Gunnar's CI. The only open question is what invokes it, and today the honest answer is "a pre-push hook", not a build server.
- Now, ~5 minutes of work: call
cargo xtask cifrom apre-pushhook, or keep typing it. It needs nothing but cargo, reads noGITHUB_*variable and assumes no runner image. This is the whole gate; everything below is only about scheduling it. - When history/badges are wanted: add a
gunnar.runa.ron(not abrokkr.toml; see the trap below) and rundwarves run gunnar.runa.ronon oden. Costs nothing, runs on his own box, and really spawns the processes. It does not yet give automatic on-push triggering; that leg does not exist (§3). - GitHub Actions: not now. It is the only option that genuinely triggers itself, but it spends minutes on a private repo in an org Rickard does not otherwise work in, and two things would have to be fixed first (§4). (2026-08-05: the sentence that stood here said
.github/workflows/ci.yml"is kept, trigger reduced toworkflow_dispatch". The file was deleted on 2026-08-03; there is no.github/at all. See the box at the top.)
The ranking is not "brokkr is better than Actions". It is that Gunnar's checks are already portable and already pass, so the marginal value of any server is scheduling and history, and only Actions currently delivers scheduling, at a cost he has no reason to pay for a repo he does not work in.
1. What is being scheduled, and what it costs
Five checks, all reachable from one command (xtask/src/main.rs, task_ci):
| check | exact command |
| fmt | cargo fmt --all --check |
| clippy | cargo clippy --workspace --all-targets --features gunnar-store/fs -- -D warnings |
| preflight | cargo xtask preflight; hard-fails if git/ssh/ssh-keygen are absent |
| tests | cargo test --workspace --features gunnar-store/fs |
| appliance | cargo build -p gunnar-cli --no-default-features --features server |
| guard | cargo xtask guard, the §2 dependency guard and the P-020 pack-pipeline guard |
Measured 2026-08-03 on oden, against a fresh git clone --depth 1 at b3b003c in a directory with no edda beside it (CARGO_TARGET_DIR on md1, registry warm, target cold):
- fmt + clippy + appliance + guard +
cargo test -p xtask: ~78 s wall cargo test --workspace --features gunnar-store/fs --no-fail-fast: ~3.7 min wall, of which only 38.1 s is time inside tests across 83 binaries. The rest is rustc.- Result: 626 passed, 0 failed, 4 ignored.
So the whole thing is ~5 minutes on oden, and it is compile-bound, not test-bound. A warm target/ makes the repeat cost trivial. This is small enough that "run it on every push by hand" is a real answer and not a cop-out.
Two findings from that same run, both still open and both outside this doc's scope (they belong to the crates, not to CI):
cargo fmt --all --checkFAILS, 50 diffs across 17 files. Not a rustfmt-version artefact: stable 1.97.1, stable 1.96.0 and nightly all report the same 50. Whatever runs CI first will go red here. Fix iscargo fmt --allby whoever owns those crates. (xtask/'s single diff is already fixed.)crates/gunnar-cli/tests/control_plane_starvation.rsis load-sensitive. Its own vacuity assertion (hammered >= 2 * HAMMERS, i.e. 32 control-plane calls) is an absolute call count while the probe window is fixed at ~1 s / 200 probes, so on a slow box the loop cannot deliver 32 completions. Pinned withtaskset: 5/5 FAILED at 2 cores (22–28 calls answered), 2/2 FAILED at 4 cores, passes alone at 32 cores and passed inside the full 626 run. The assertion is right — it refuses to report a measurement of an idle server — but it is expressed in a unit that does not survive a small machine. This is what makes any hosted runner unreliable, and it is not a reason to weaken the test.
2. brokkr: what it is, and the trap to avoid
brokkr is the nordisk CI/build server, code in /home/rickard/git/dwarves, CLI /usr/local/bin/dwarves, docs dwarves/.nornir/brokkr-design.md + brokkr-guide.md.
⛔ The trap: brokkr.toml greens everything without executing it
This is the single most important fact in this document. brokkr has two pipeline formats, and only one of them runs anything.
brokkr.toml's RunKind is variant / container / vm (dwarves/crates/dwarves/src/brokkr/model.rs:156). The RealRunner handles variant for real via build-thing, and defers container and vm to a fallback runner (dwarves/crates/dwarves/src/runner.rs:165) which is always DemoRunner (runner.rs:74-76, :194-200). DemoRunner returns StepOutcome::ok("would run […] in […]") (runner.rs:31-42). There is no code path that injects a real container runner; RealRunner::new is called by nothing outside tests. dwarves' own test says it outright: "container step deferred to the demo fallback greens" (runner.rs:238).
The obvious brokkr.toml for Gunnar — container steps running cargo fmt, cargo clippy, cargo test, cargo xtask guard — would therefore report PASS having executed nothing, in both demo and --real mode. For a repo whose entire guard culture exists to prevent exactly that, adopting it would be worse than having no CI at all: it would produce a green nobody has seen red, on the build system itself. Do not write a brokkr.toml for Gunnar.
(The design doc at brokkr/runner.rs:5-7 promises a bollard container runner. It does not exist. A real container executor exists elsewhere — runa_backend.rs:379-453 via draupnir/podman — but it implements a different seam, is behind the non-default container-oci feature, is absent from the dwarves-server build variant, and cannot be reached from a brokkr.toml.)
✅ The path that does work: *.runa.ron
dwarves run <file>.runa.ron executes a typed runa pipeline over a real jera Scheduler and really spawns processes: Step::Xtask { cmd } lowers to WorkPayload::process("cargo", ["xtask", cmd]) (runa_exec.rs:745), and RunaBackend::spawn_process is a plain std::process::Command with the child's exit code surfaced (runa_backend.rs:168-200). Verified by reading both ends.
Gunnar is unusually well suited to this format, because it already routes every check through cargo xtask. That matters, because the typed test steps cannot express Gunnar's flags: Step::CargoTest is a unit variant with no fields — it lowers to a bare cargo test (runa_exec.rs:672), no --workspace, no --features gunnar-store/fs — and Step::Nextest { all_features } only offers --all-features (runa_exec.rs:665-671). Neither matches what Gunnar needs. Xtask does, exactly.
3. What brokkr would need for Gunnar, concretely
3a. Getting a private GitHub repo in: yes, with three sharp limits
dwarves repo add <ssh-url> clones/fetches over SSH with the forge's deploy key (dwarves/crates/dwarves/src/main.rs:6470, repo.rs:109). The host is not restricted to codeberg/nordisk; git@github.com:vetra-automation/gunnar.git parses fine (edda/crates/nornir-git/src/ssh.rs:48). The fetch is pure-Rust gix + russh (gitio.rs:357-364), i.e. it needs no git or ssh binary at all.
The sequence would be:
dwarves key show # or `dwarves key generate` — public half at ~/.dwarves/ssh/id_ed25519
# → paste as a DEPLOY KEY on github.com/vetra-automation/gunnar
dwarves repo add git@github.com:vetra-automation/gunnar.git
Three limits, each of which will look like a typo if hit:
- SSH only. HTTPS is hard-rejected before any I/O: "the forge clones over SSH ONLY … https is forbidden (LAW)" (
repo.rs:84-89). ~/.ssh/configHostaliases are NOT honoured.parse_ssh_urlresolves the literal host and russh connects straight to it; nothing readsIdentityFile. The URL must saygithub.com. (Note the contrast with a per-employer ssh alias; that trick does not work here.)- No token auth of any kind. No PAT, no GitHub App, and ssh-agent is not reachable from the forge path. A deploy-key file at
$DWARVES_SSH_KEY/~/.dwarves/ssh/is the only mechanism.
Also worth knowing before relying on it: dwarves repo add writes no persistent registration; it clones and prints a hint to run the pipeline manually (main.rs:6479-6483). Server-side RegisterPipeline keeps its registry in Arc<Mutex<Vec<…>>> (server.rs:52, :181), i.e. in memory, lost on restart.
3b. Automatic triggering on push: this leg does not exist
brokkr/trigger.rs:4 documents a trigger_monitor bridge from a fetch report to a run. That function is not in the tree; the name appears only in that comment. Nothing maps nornir's FetchReport into sindri's MonitorReport; the only producer of one is the webhook handler (server.rs:457), and the server's single background thread is cron-only, sleeps 60 s and does no git I/O (server.rs:844-869).
So brokkr today gives manual or cron, not "runs when you push". That is the decisive difference from GitHub Actions, and it is why the recommendation does not lead with it.
3c. The live oden forge runs nothing for real, as configured today
dwarves-server is up on oden as a user unit — PID 2427538, /home/rickard/.config/systemd/user/dwarves-server.service, DWARVES_SERVER_ADDR=127.0.0.1:7900 — not the designed User=dwarves system unit. Its environment has no DWARVES_REAL_RUNNER and its cmdline has no --real, so select_runner returns DemoRunner (runner.rs:175, :198): every step the live forge dispatches right now is a fake green. The real path is the local one, dwarves run gunnar.runa.ron, which does not go through that server.
3d. A gunnar.runa.ron: copy, don't invent
The precedent to copy is /home/rickard/git/dwarves/dwarves.runa.ron (the forge's own pipeline; siblings: holger.runa.ron, pipelines/release.runa.ron, edda/crates/nornir-fleet/fleet.runa.ron). There is also a generator:
dwarves pipeline new --build variant:gunnar-appliance --test none \
--trigger push:master --git git@github.com:vetra-automation/gunnar.git
Given the CargoTest/Nextest limitation above, the honest shape routes everything through Xtask, which means Gunnar would first need three new xtask verbs (fmt-check, clippy, test; it has guard, preflight and ci already):
Pipeline(
name: "gunnar",
triggers: [Push(branch: "master"), Manual],
stages: [
Stage(name: "lint", steps: [Xtask(cmd: "fmt-check"), Lint(tool: Clippy, deny_warnings: true)]),
Stage(name: "gate", needs: ["lint"], steps: [Xtask(cmd: "preflight")]),
Stage(name: "test", needs: ["gate"], steps: [Xtask(cmd: "test")]),
Stage(name: "build", needs: ["gate"], steps: [Build(variant: "gunnar-appliance")]),
Stage(name: "guard", needs: ["build"], steps: [Xtask(cmd: "guard")]),
],
)
Build(variant: …) is real (build-thing, in-process cargo build, zero shell) and the appliance build maps cleanly onto a BuildVariant, which supports package, bin, features, no_default_features, profile and target (edda/crates/nornir-build-thing/src/variant.rs:286-304):
[[build.variant]]
name = "gunnar-appliance"
package = "gunnar-cli"
bin = "gunnar"
no_default_features = true
features = ["server"]
profile = "release"
The simplest variant of all is one stage with Xtask(cmd: "ci"), since cargo xtask ci already runs all six checks in order and stops at the first failure. It gives up per-stage granularity in the DAG view and gains being impossible to get wrong.
3e. git and OpenSSH on oden, and what pins them
On the box today, yes, and exactly at the calibrated versions, measured 2026-08-03 on oden: git 2.53.0, OpenSSH_10.2p1. That matters because several tests assert on exact upstream wording (e.g. crates/gunnar-client/tests/real_git_push.rs:202 expects 2.53's "incorrect old value provided" where older git says "fetch first").
But brokkr does not guarantee it, and cannot. Stated plainly, because the capability system looks like it does:
- A
brokkr.tomlstep has norequires/tagsfield at all (brokkr/model.rs:131-149); a pipeline cannot even say it needsgit. - The requirement handed to the matcher is a hardcoded constant on every queued run:
requirements: vec. - The agents it matches against are invented:
demo_pool()registerslinux-1claiming["linux","docker","cargo"]andmac-1claiming["macos","cargo"](sindri.rs:757-762), on a box that has neither verified.runa_backend.rs:138-153registers aLocalAgentadvertisingcontainerandkvmunconditionally, in builds where the container runner is a compiled-out stub (runa_backend.rs:444). dwarves agent --caps …registers into a stack-local scheduler that is dropped at end of scope and prints its own TODO (main.rs:1125-1138).
The matching logic itself is correct and unit-tested (brokkr/agent.rs:58, brokkr/queue.rs:50); it is simply never given a real requirement, and the tags it compares are self-asserted strings nobody probed.
So the only real guarantee is Gunnar's own: cargo xtask preflight. It spawns git, ssh and ssh-keygen, asserts on output rather than exit status (a tool that spawns but says nothing is treated as missing, xtask/src/main.rs:140-177), and hard-fails the run before a single test executes. That is the right place for it: the check travels with the repo instead of depending on a build server's inventory. Any pipeline, on any host, must run preflight as an early step — that is the answer to "how is it guaranteed", and it is guaranteed by Gunnar, not by brokkr.
4. GitHub Actions: what it would take
STALE AS OF 2026-08-05, and kept for the argument rather than the file. Every present-tense sentence in this section about
.github/workflows/ci.ymlis false: the file was deleted ine50b962on 2026-08-03, hours after this section was written. What survives is the cost argument in the numbered list below, which does not depend on the file existing.
.github/workflows/ci.yml exists, is valid YAML, defines five jobs existed until e50b962, was valid YAML, defined five jobs, and never executed once. Its trigger was workflow_dispatch only, so it could not start itself; its header said all of this in the file.
Settled on 2026-08-03: a stale "KNOWN BLOCKER" header claimed a runner could not resolve the workspace at all, because Cargo.toml patched nornir-git to ../edda/crates/nornir-git. That claim is dead. nornir-git 0.1.6 is published (Cargo.lock, source = "registry+…crates.io…", checksum 554da94e…) and the patch block is gone; a fresh clone with no edda beside it runs cargo metadata --locked → 0, and clippy, the appliance build and the guard all pass against it.
What would still have to happen before it is worth enabling:
cargo fmt --all: 50 diffs, thefmtjob goes red on the first run (§1).control_plane_starvationreworked to express its vacuity threshold in something that survives 2 cores (§1). Without this thetestjob is red or flaky forever, for a reason that is not Gunnar being broken.- Somebody deciding that spending Actions minutes on a private
vetra-automationrepo is worth it. That is not a technical question.
One real defect was found and fixed in that file while it was being read, and it is worth recording because it is the house disease — the fix went with the file when e50b962 deleted it, so what remains is the lesson, not the code: the "No test may have skipped itself" step scanned the test log with if grep …; then fail; fi. grep on a missing file exits 2, and if cannot tell 2 ("could not look") from 1 ("looked, found nothing"), so whenever the previous step wrote no log, the step printed "no skip markers in the test log" and passed. Demonstrated by running the step's own body against a nonexistent path: exit 0. The scanner written to catch a silent green was itself a silent green. It now asserts the log exists and is non-empty, asserts at least one test result: line (a build that produced no test binaries must not pass), and proves the regex can fire against a known-positive probe before trusting the real scan. All five red paths were driven and seen to fail before the green was accepted.
Note also that no test in the tree prints a skip marker any more (S-026 replaced them all with hard assert!(tools_present(), …)), so that scan is now a regression detector, an absence assertion like guard.rs's FORBIDDEN_ANYWHERE, which is exactly why its ability to fire had to be demonstrated rather than assumed.
5. The three options side by side
| nothing (today) | brokkr on oden | GitHub Actions | |
| runs the real checks | yes, when typed | yes, via *.runa.ron + Xtask, real spawns |
yes |
| …but only if you avoid | — | brokkr.toml container steps: they green without executing |
— |
| triggers itself on push | no | no; the monitor→run bridge does not exist | yes |
| cost | 0 | 0 (his box, his tooling) | Actions minutes, private repo, org he does not use |
| setup | 0 | deploy key + repo add + gunnar.runa.ron (+ 3 new xtask verbs) |
fix fmt + fix the 2-core test |
| history / badge | no | yes (dwarves history, dwarves badge) |
yes |
guarantees git/OpenSSH |
xtask preflight |
xtask preflight (brokkr checks nothing) |
xtask preflight + explicit apt install |
| trust in a green | high, you watched it | high, if Xtask/Build steps only |
unknown, never run |
Chosen 2026-08-05: column 1, "nothing", stated as a decision — cargo xtask ci, typed. See "The decision" at the top of this document for why that is a defensible answer for a released project and what would change it.
The row that decides it: the checks are already portable and already green. What is missing everywhere is not a runner, it is a trigger, and the only option that supplies one is the one Rickard has the least reason to pay for.
What was NOT determined
- Whether GitHub Actions is enabled for
vetra-automation/gunnar, whether a runner is available, and whether the org has minutes. Could not be checked:ghis unauthenticated on oden, both stored tokens invalid (gh auth statusfails forIgnalinaandrickard-lundin), so the API answers 404 for a private repo. Git over SSH works fine (ssh -T git@github.com→Hi Ignalina!); only the REST API is blocked. Re-authenticating needs Rickard's own hands, and was deliberately not attempted. - Whether a
gunnar.runa.ronactually runs green end-to-end. Not written, not run. - Whether
dwarves repo addsucceeds against this specific repo: no key was minted and no deploy key was added to the GitHub repo.