Release rehearsal — the holger marriage end-to-end
How nornir releases a multi-repo workspace safely: rehearse the whole publish cascade against an embedded, throwaway crates.io (/sparring) before touching the real one. This is the full reference; the short version is release-for-idiots.md; the design rationale is in design.md → The holger marriage.
The marriage (why there's no holger-server to run)
nornir links holger (holger-server-lib) directly — the embed-holger cargo feature, on by default. src/holger_embed.rs::EmbeddedHolger::start spins a dedicated tokio runtime, writes the dev-pair config, instantiates the backends, wires the routes and starts holger's gRPC + HTTP listeners in-process; dropping the handle stops them. So nornir cache up and release stage --execute bring holger up inside the nornir process — one binary, no orchestration.
A lean client build opts out: cargo install nornir --no-default-features …. Then the same call sites spawn a real holger-server dev-pair subprocess instead (pass --holger-bin /path/to/holger-server).
Two sparse-index gateways on one HTTP port (default 127.0.0.1:18464, gRPC at :18465):
| Endpoint | What it is | Used by |
/cache |
transparent crates.io mirror/proxy | nornir cache up, builds |
/sparring |
writable fake crates.io | the release rehearsal |
The rehearsal writes a cargo config (src/release/stage.rs::cargo_config_toml) that (a) defines /sparring as a publish registry and (b) source-replaces crates.io with it — so a dependent's build resolves the just-published workspace crates from /sparring, and anything else falls through /sparring's crates.io upstream.
The three tiers
1. nornir release doctor — advisory (read-only)
Mutates nothing. Composes these signals (src/release/doctor.rs):
- dirty trees — real local
git statusper repo. - external-dependency skew — per shared crate, target = highest declared version; each repo is
ok/behind/forbidden.--forbid arrow=56bans a major. Only crates that diverge across repos (or hit a forbidden version) are surfaced — the anti-Maven bit. - transitive-pin detection (⛔ "held") — a
behindrepo whoseCargo.lockALREADY resolves the target major next to the old one (a dual-major tree) is held by a transitive dependency; a manifest bump alone won't take. The hint splits into💡 bump → …(free) vs⛔ blocked → …(runcargo tree -i X). - cycle-break advisor — when the publish graph has a cycle, Tarjan SCC finds it and suggests the cheapest edge to cut (the intra-cycle edge on the fewest crates).
- publish order — topological, dependencies first.
- blast radius — transitive dependents of each dirty repo (who to re-validate).
--json for machine output.
Real output (run 2026-06-20, nordisk constellation via the workspace_holger/release/nornir.toml recipe — znippy/holger/nornir on disk):
External dependency skew:
arrow (target 58.3.0)
· nornir 58
✓ znippy 58.3.0
💡 bump → 58.3.0: nornir
…
toml (target 1.1.2+spec-1.1.0)
✓ holger 1.1.2+spec-1.1.0
⛔ nornir 0.8 (held: lock already resolves 1.1.2+spec-1.1.0, a transitive dep pins 0.8)
⛔ blocked → nornir: a transitive dep pins toml (run `cargo tree -i toml` to find it)
Publish order (dependencies first):
znippy → holger → nornir
That ⛔ is the transitive-pin tell working on a real repo: nornir declares toml 0.8 but its lock carries 1.1.2 too (pulled in transitively), so the bump won't take until the pinning dep moves. (After the arrow-58 migration landed, arrow is a plain 💡 bump for nornir, no longer held.)
1b. nornir release gate edition <repo> — the Rust 2024 gate
Separate from doctor, the edition gate (src/release/edition.rs) answers "is this crate on the current Rust/Cargo standard?": static (every publishable package on edition = "2024", rust-version >= 1.85, workspace resolver = "3") plus dynamic (cargo check forcing rust-2024-compatibility to warn). Real output (2026-06-20, on nornir):
$ nornir release gate edition nornir
✗ nornir-saga — edition = "2021" — want "2024"
✗ nornir-saga — no rust-version (MSRV) — edition 2024 needs ≥ 1.85
✗ 2024-incompat — edition_2024_expr_fragment_specifier: …
…
Error: edition gate: 2 static + 55 lint finding(s)
publish = false crates are skipped (they never ship). The viz Gate pane runs the static layer on load (cheap); the dynamic lint pass is the heavy, CLI-driven bit.
2. nornir release stage — rehearse
plan_stage is pure and testable: topo order + per-repo verify set (direct dependents). Plain stage prints the plan; --execute runs it (src/release/stage.rs::execute):
- bring up embedded
/sparring(wait until the gateway serves); - per repo in order: cut the ephemeral release branch, then
cargo publish --workspace --no-verify --allow-dirty --registry sparring; - per dependent:
cargo buildagainst/sparring— proof the just-published crates resolve from a registry, not from path deps.
Per-step failures are collected (not fatal) so the outcome shows how far it got. holger is torn down on return; /sparring is ephemeral. --registry / --branch override the defaults; the embedded server is brought up on whatever host:port the --registry URL advertises, so plan and execution never disagree.
3. nornir release promote
Replays the same dependency order against real crates.io once the sparring run is green.
Bridge: doctor --rehearse
nornir release doctor --rehearse [--registry … --branch …]
Prints the advisory report, then runs the same stage engine (run_stage_rehearsal in src/bin/nornir.rs — one shared call path with stage --execute, no duplicated logic). Blocks if the plan has a cycle. doctor without --rehearse stays strictly read-only.
The release surfaces in the viz
You don't have to drive any of this from the shell. The 🧬 nornir release dashboard (the 🧬 nornir tab in nornir viz) paints the publish graph with the doctor verdict overlaid on every node — ⛔ held / ✗ edition-fail (RED) · ▼ behind / 🟡 dirty (AMBER) · clean (GREEN) — and each cycle as a dashed ✂ cut · … edge. Click a node to scope the release and light its blast radius. The 🚀 Release button (enabled only on a go) opens the 🧙 release wizard: a gated Advise → Fix → Rehearse → Promote walk where you can't advance past a red step without an explicit override, Rehearse runs stage --execute into /sparring, and Promote demands you type PROMOTE. See design.md → Viz — the release surfaces.
Test coverage
| Test | Kind | Proves |
release::doctor::tests::* |
unit | skew classify, topo order, blast, cycle, transitive pins |
release::edition::tests::* |
unit | static edition/MSRV/resolver findings, 2024-lint parse |
release::stage::tests::* |
unit | plan order + verify set, cargo config, cycle block |
tests/holger_embed.rs |
integration | embedded pair boots; /cache + /sparring serve |
tests/release_stage_execute.rs |
#[ignore] heavy |
full cascade: publish into /sparring + build back out |
tests/robot_viz.rs |
robot (viz) | dashboard gate overlay/badges/cut-edges, wizard gating, saga replay → matrix |
tests/saga_roundtrip.rs |
integration | record → replay plan → robot scenario → matrix rows |
tests/workspace_holger.rs |
integration | real-sibling cross-repo graph + build order |
(All green 2026-06-20: doctor/stage/edition 23/23, robot_viz 11/11, saga_roundtrip 3/3.)
Run the heavy one explicitly:
cargo test --test release_stage_execute -- --ignored --nocapture
It uses uniquely-named fixture crates (nornir-stage-fixture-{lib,app}) precisely because /sparring mirrors crates.io — a real-world name (acme-lib etc.) would collide on publish and resolve the real upstream crate (and its deps) instead.
Go-live runbook — releasing workspace_nordisk
Status as of this writing: ready to rehearse. The plan is a clean, executable DAG for the locally-resolvable members.
The monitored workspace_nordisk/nornir-workspace.toml lists every member by git URL; to run doctor/stage fat you need the members on disk. The verified way (used for the real output above) is the constellation release recipe, with the sibling repos checked out next to it:
cd <git-root> # the dir holding workspace_holger/, holger/, znippy/, nornir/
# 1. advise — REAL output (2026-06-20), trimmed:
nornir --config workspace_holger/release/nornir.toml release doctor
# Working trees: ✅ all clean
# arrow (target 58.3.0) · nornir 58 ✓ znippy 58.3.0 💡 bump → 58.3.0: nornir
# toml (target 1.1.2…) ⛔ nornir 0.8 (held: a transitive dep pins 0.8)
# Publish order: znippy → holger → nornir
# 2. rehearse — REAL plan (green):
nornir --config workspace_holger/release/nornir.toml release stage
# Publish order (dependencies first):
# 1. cargo publish znippy → /sparring verify (build from /sparring): holger, nornir
# 2. cargo publish holger → /sparring
# 3. cargo publish nornir → /sparring
# → green plan; run with --execute to publish into /sparring, then promote.
nornir release stage --execute # publishes into /sparring + verifies
# 3. when green, ship
nornir release promote
(From inside a workspace dir with --server, the same verbs run remote — the server resolves the members from its monitored clones.)
Preconditions / caveats for the FULL constellation
- Members must be present on disk. The plan above covers the members nornir can resolve locally (
znippy,holger,nornir). The full nordisk descriptor also listsfacett · skade · knut · korp · ordning. To release the whole set, every member must be checked out where nornir resolves it — either via the monitored server sync (it clones each member's git URL under~/.nornir/workspaces/nordisk/git/), or a fat descriptor with localpath = …members. Members not present are simply absent from the plan (no error), so confirm the publish order lists everyone you mean to ship. - Resolve the skew first — but read the ⛔.
doctorsplits its hints: a plain💡 bumpis a free manifest edit (e.g.arrow → 58.3.0for nornir, now that the arrow-58 migration landed); a⛔ blocked(e.g.toml 0.8on nornir today) is held by a transitive pin — bumping the manifest won't take until the pinning dep moves, socargo tree -i tomlto find it first. Bump the laggards (or accept the divergence) before promote; rehearsal catches a cascade that resolves, but you still want one coherent version line. - Run the edition gate too.
release gate edition <repo>is a separate check (Rust 2024 / MSRV / resolver-3 + the 2024-compat lint pass) the dashboard wizard's Advise step folds in. It currently flagsnornir-saga(edition 2021) and missing MSRVs across znippy — fixes worth landing before a real promote. - Clean trees for promote.
--executetolerates a dirty tree (--allow-dirty);promoteto real crates.io should run on a committed, tagged state. - First
--executeis slower — the embedded/cachewarms from crates.io on first miss, then subsequent rehearsals are offline-fast.
What "done" means
You can start using nornir for nordisk releases the moment release stage prints a green plan (it does) and release stage --execute comes back with ✅ no errors. promote is the only step that touches the outside world; gate it behind a green rehearsal every time.
The wired end-to-end flow (task #31)
The pieces above are now wired into ONE coherent, validated path — they reuse the existing engines, no new release logic:
- Wizard Fix APPLIES the bump. The 🧙 wizard's Fix step gained a
🔧 apply fixesaction: it runsrelease::cargo::apply_skew_bumps(which feeds eachCrateSkew→plan_version_bump+apply_bump_plan) over the checkout, then re-runs Advise so the gates re-evaluate against the edits. The bumps it applied surface in the Fix body and instate_json["release_wizard"]["machine"]["fix_applied"]. - Promote safety —
[patch]can never leak. Both promote paths (the vizspawn_promoteand the CLIrelease publishon a real, non-dry-run publish) callrelease::cargo::ensure_no_patch_crates_iofirst: it strips every[patch.crates-io]block (the arrow-58iceberg = { path = "../iceberg-arrow58" }fork) and asserts none remain, bailing rather than publishing a leaked tree. The typedPROMOTEconfirm stays. - Release self-registers as a job. The fat
release runopens ajobs::kind::RELEASE_RUNledger row (running → done|failed via theJobHandleDrop guard), so it shows live in the 🧬 ledger — parity with the server'sOps.RunRelease, which already registered the same kind. (Single-writer: the fat process or the server ownsjobs.redb.) - Validated end-to-end.
tests/release_bump_rehearse_promote.rs(#[ignore], heavy) bumps a member version, rehearses into embedded/sparring— GREEN, because the version is new — then strips the[patch]fork and dry-run promotes. Proves the bump → rehearse-green → promote loop closes:cargo test --features viz --test release_bump_rehearse_promote -- --ignored --nocapture.