The release function — guide
Class:
guide· audience: a techie drivingnornir release …across a multi-repo Rust constellation. The what (architecture, source pointers) is inrelease-design.md; the why + the publish-trap lessons inrelease-reasoning.md. This file is CLI + worked examples. Siblings:release-design.md·release-reasoning.md· doctor →release-doctor-guide.md/release-doctor-design.md/release-doctor-for-idiots.md·release-for-idiots.md·release-history.md.
f0 — the end-to-end nornir release … workflow
Release runs in three trust tiers over the dependency DAG, dependencies first. Escalate; never skip ahead to promote on a tree that hasn't advised clean and rehearsed green.
# From the git-root holding the sibling repos (or a --config release recipe):
# 1. ADVISE — read-only. What's dirty, what's skewed, the publish order, any cycle.
nornir --config workspace_holger/release/nornir.toml release doctor
# 2. FIX the mechanical blockers it found (path-dep version= gaps), then re-advise.
nornir release doctor --fix
# 3. REHEARSE the whole cascade against the embedded throwaway crates.io (/sparring).
nornir release stage # dry: prints topo order + per-repo verify targets
nornir release stage --execute # publishes into /sparring, builds each dependent back out
# 4. PROMOTE — the only step that touches the outside world. Gate it behind a green rehearsal.
nornir release promote # or: release doctor --publish (the one-command cascade)
Everything walks the same build order (deps-first topological sort of the workspace dep graph). Private repos (private = true) are staged + verified but never pushed to crates.io.
f1 — gates: nornir release gate …
Run one gate by name against a repo, or sweep every gate enabled in nornir.toml.
nornir release gate all holger # every configured gate; per-gate ✓/✗ report
nornir release gate all holger --json # same, as CommandOutcome JSON
# individual gates (NAME ∈ path_patches | path_dep_versions | crate_metadata |
# links_conflicts | edition | nexus_floor | no_regression | docs_fresh |
# roundtrip | coverage):
nornir release gate path_patches holger # no [patch.crates-io] path sources
nornir release gate path_dep_versions holger # every path-dep carries version=
nornir release gate edition nornir # Rust 2024 / MSRV ≥ 1.85 / resolver "3" + 2024-lint
nornir release gate no_regression holger # newest bench vs machine-matched baseline
nornir release gate docs_fresh nornir # README/CHANGELOG in sync with .nornir/ source
Worked example — the edition gate on a repo that isn't fully on 2024:
$ 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). Note: a repo with zero configured gates is NOT green — gate all requires at least one gate to have run and none to have failed.
Enable per-repo gates under [repo.<name>.gates] in nornir.toml:
[repo.holger.gates]
no_path_patches = true
no_regression = true
max_regression_pct = 10.0
docs_fresh = true
guard_intact = true
tests = true # HEAVY — runs the test matrix at release time
integration_roundtrip = ["blob", "gc"]
f2 — advise: nornir release doctor
Read-only by default; every mutating stage is an opt-in flag. Full doctor guide: release-doctor-guide.md.
nornir release doctor # assess: dirty trees, skew, promote gate, topo order, cycles, blast
nornir release doctor --forbid arrow=56 # ban a major version in the skew analysis
nornir release doctor --json # machine output
nornir release doctor --fix # write version= on every path dep that lacks one (idempotent)
nornir release doctor --auto # invertive self-heal to a fixpoint; then the minimal decision asks
nornir release doctor --auto --dry # print the ordered remedy PLAN, apply nothing
nornir release doctor --explain # every held crate + its per-crate reason (fork rider, patched dep)
Worked example — a real advisory (nordisk constellation, trimmed):
External dependency skew:
arrow (target 58.3.0)
· nornir 58 💡 bump → 58.3.0: nornir
toml (target 1.1.2+spec-1.1.0)
⛔ nornir 0.8 (held: lock already resolves 1.1.2, a transitive dep pins 0.8)
⛔ blocked → nornir: run `cargo tree -i toml` to find the pinning dep
Publish order (dependencies first):
znippy → holger → nornir
💡 bump is a free manifest edit; ⛔ blocked is held by a transitive pin — bumping won't take until the pinning dep moves.
f3 — rehearse: nornir release stage
Rehearse the cascade against the embedded holger /sparring fake crates.io — no real registry touched.
nornir release stage # dry: topo publish order + per-repo verify (direct dependents)
nornir release stage --execute # spawn holger → publish into /sparring → build each dependent from it
nornir release stage --registry sparse+http://127.0.0.1:18464/sparring/index/ --branch release/staging
nornir release stage --execute --holger-bin /path/to/holger-server # lean client build (no embed-holger)
nornir release doctor --rehearse # advisory report THEN the same stage engine in one go
Worked example — a green plan:
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.
The heavy end-to-end integration test is opt-in:
cargo test --test release_stage_execute -- --ignored --nocapture # full cascade into /sparring + build back out
Full rehearsal reference + go-live runbook: release-rehearsal.md.
f4 — promote: nornir release promote / doctor --publish
The irreversible tier. promote replays the build order against real crates.io; doctor --publish is the one-command whole-constellation cascade.
nornir release doctor --publish # bump patch, deps-first, fail-stop, each recorded for undo
nornir release doctor --publish --bump minor # bump level: patch | minor | major
nornir release doctor --publish --bump keep # publish CURRENT versions unchanged (idempotent finish/resume)
nornir release doctor --publish --publish-dry-run # plan + `cargo publish --dry-run` — no upload, no mutation
nornir release doctor --publish --bump keep --publish-from skade-katalog # resume a fail-stopped run
# lower-level publish primitives:
nornir release publish holger # walk the publish_order from nornir.toml
nornir release wait-for-index nornir 0.4.54 # poll crates.io until it indexes (replaces a fixed sleep)
nornir release strip-patch-blocks nornir # strip every [patch.crates-io] before publish
nornir release tarball-stats nornir nornir # cargo package + walk the .crate: size / entries / largest
--publish refuses while any pre-flight blocker remains (path-dep version gaps, held fork riders, cycles) — run --fix first. A real publish demands a typed PROMOTE confirm and strips every [patch.crates-io] before uploading. "Already uploaded" is a skip, not a failure, so --bump keep re-runs are safe.
The escape hatch when the gate spuriously over-blocks the nornir crate itself (bump by hand, keep the self-protecting default --verify):
# bump [package] version in nornir/Cargo.toml, commit, then:
cargo publish -p nornir --dry-run # full verify-build vs crates.io, no upload
cargo publish -p nornir # real; --verify refuses upload if deps don't resolve
git tag -a v0.4.54 -m "…" && git push origin HEAD && git push origin v0.4.54
Why that hold happens (and never --no-verify): see the publish-trap lessons in release-reasoning.md.
f5 — undo, history & the op DAG
nornir release undo # reverse the MOST RECENT run: un-bump, restore [patch], reset branch
nornir release undo <release-id> --yank # also `cargo yank` the immutable crates.io uploads
nornir release undo --dry-run # report what would be reversed, touch nothing
nornir release trace holger # regression time-bisect: last green → first red + suspect commit range
nornir release trace holger --json
nornir release events holger # the recorded op DAG (topo timeline + depends_on edges) for a repo…
nornir release events <run_id> # …or one specific run
nornir release events holger --json # raw event rows for tooling / the viz
release run drives the CI-style gate sweep and records both release_lineage and the release_events DAG:
nornir release run # all repos, topo order: test → bench(persist) → gate-all; stops on first fail
nornir release run --repo holger # one repo
f6 — the viz surface
You don't have to drive any of this from the shell. The 🧬 nornir release dashboard (nornir viz) paints the publish graph with the doctor verdict on every node (⛔ held · ✗ edition-fail · ▼ behind · 🟡 dirty · 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 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.
f7 — driving the cascade from an AGENT (the MCP tools)
The same cascade, over stdio MCP, for an agent that has no shell. Wire it up once:
// ~/.claude.json → mcpServers
"dwarves": {
"type": "stdio",
"command": "dwarves-server", "args": ["mcp"],
"env": { "NORNIR_SERVER": "http://127.0.0.1:7878", "NORNIR_WORKSPACE": "nordisk" }
// DWARVES_MCP_ALLOW_PUBLISH is deliberately ABSENT — see the warning below.
}
⚠ The binary must be built with the release feature or the tools are not there. --features mcp-release, or embed-all, which is what the declared dwarves-server atom in .nornir/build-variants.toml carries. A server built without it still starts, still serves the three funnel/autonom tools, and simply does not list the release ones — there is no error to notice. Check before debugging anything else:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | dwarves-server mcp
# 9 tools = release surface live · 3 tools = built without `release`, or the router
# defect in f7 of release-design.md (fixed 2026-08-30 — rebuild).
Then the order that answers a release, cheapest first:
release_plan {"repo_root": "/abs/path"} # offline, sub-second: waves + argv
release_publishable {"repo_root": "/abs/path"} # offline: what ships, and at what version
release_drift {"repo_root": "/abs/path"} # LIVE: skip / drifted / first-publish
release_gates {"repo_root": "/abs/path"} # LIVE: every blocker + its fix
release_publish {"repo_root": "/abs/path"} # DRY RUN by default — returns the script
Read the verdicts exactly as written. skip and drifted are positive content proofs (the .crate tarball was fetched and compared file by file). unknown is a FAILED probe and is not a decision in either direction — re-probe after clearing the stated reason. drifted is the dangerous class: crates.io bytes are immutable, so a crate whose local source differs from its published copy at the same version can never be skipped; it must be bumped.
A bump is not free — it re-drifts every sibling whose manifest you touched to raise a floor. Measured 2026-08-30 in this repo: raising nornir-funnel-embed's floor on nornir-warehouse-embed moved nornir-funnel-embed itself from skip to drifted at an unchanged 0.1.2, and it needed its own bump. Re-run release_drift after every manifest edit; the cascade terminates, but only if you follow it.
The real upload — and why an agent cannot do it alone
release_publish {"repo_root": "/abs", "execute": true, "confirm": "PUBLISH-TO-CRATES-IO"}
refused unless the server process carries DWARVES_MCP_ALLOW_PUBLISH=1. That variable is not a tool argument and cannot be set by the caller: arming is a configuration act by the operator who wired the server up. A crates.io upload is irreversible and outward-facing — a bad version can be yanked but never unpublished, and every consumer that already resolved it keeps resolving it — so this is the one capability in the constellation reserved for a human's decision.
⚠ Arming it in ~/.claude.json arms it for EVERY agent session on the box. Before 2026-08-30 that was inert, because the tool it guards was not reachable (release-design f7). It is reachable now. If the variable is set globally and an agent knows the confirm phrase — it is in the tool's own description — the only remaining lock is Gate B's rehearsal. Keep the variable OUT of the shared config and set it on a one-off server for a deliberate release instead.