Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

.nornir/release-guide.md

The release function — guide

Class: guide · audience: a techie driving nornir release … across a multi-repo Rust constellation. The what (architecture, source pointers) is in release-design.md; the why + the publish-trap lessons in release-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.