The release function — design
Class:
design· Function axis:f0(the pipeline as one thing) +f1gate set ·f2doctor ·f3rehearsal ·f4publish ·f5history/events ·f6graph math. Strict: this file states only the design nornir runs, every claim source-backed by afile:symbol/file:linepointer. Every why, trade-off, and rejected alternative lives inrelease-reasoning.md; the end-to-end command workflow is inrelease-guide.md. Siblings:release-guide.md·release-reasoning.md· doctor sub-part →release-doctor-design.md/release-doctor-guide.md/release-doctor-for-idiots.md·release-for-idiots.md·release-history.md.
⚠ Ownership moved (doctrine 2026-07-14, PRE-PROMPT §L5a — release is a build op). Release orchestration now belongs to dwarves —
dwarves release <workspace-path> [--execute]is the deps-first crates.io cascade. nornir's release ACT CLI verbs (the gate setf1, doctorf2, rehearsalf3, publishf4, plus bump/strip/stage/yank/mirror/wait-for-index) are retained as still-working deprecation shims: each printsrelease::release_moved_banner(steers the operator todwarves release) then runs the existing code unchanged — additive, no regression. Classification lives inrelease_op_is_act(src/bin/nornir.rs). The read-only warehouse VIEW / lineage verbs STAY in nornir unchanged and do NOT warn —release trace(regression bisect,f5),release events(op-DAG view,f5),release changelog,release impacted-crates, andrelease undo(lineage reversal off therelease_changesledger) — because nornir owns the warehouse + release lineage. Thef0–f6design below documents that retained mechanism.
f0 — the release pipeline as one thing
release is a single capability with one shape run in three trust tiers: advise → rehearse → promote, each running over the component dependency DAG (dependencies first), gated at every repo. The tiers are a strict escalation — you never promote a tree that hasn't advised clean and (ideally) rehearsed green.
| tier | verb | engine | mutates | source |
| advise | release doctor |
doctor::run(repos, policy) -> DoctorReport |
no (read-only, local checkouts) | src/release/doctor.rs |
| rehearse | release stage --execute / doctor --rehearse |
stage::plan_stage + stage::execute |
ephemeral release branch per repo, self-restored | src/release/stage.rs |
| promote | release doctor --publish / release promote |
promote::run |
crates.io (irreversible) + bump commits | src/release/promote.rs |
Underneath, one orchestrator walks the graph for the CI-style gate sweep: pipeline::run_pipeline(wh, desc, graph, opts) (src/release/pipeline.rs:160). It derives the build order from the dep DAG (graph.build_order(), f6), then for each repo in that order runs the gates (f1), records lineage into release_lineage, and emits a row-level op DAG into release_events (f5). A single success predicate — pipeline::gate_succeeded(status) (pipeline.rs:118) — drives both the per-repo loop-abort and the final rollup, so a failing gate stops the walk before any dependent is validated against a dependency that never shipped.
The parts and how they wire:
f6graph math derives the order everything else obeys —build_order()(deps-first topological sort) is the spine bothrun_pipelineand the doctor/promote cascade walk.f1gate set is what runs at each node of that walk — a repo passes only if every configured gate returnsOk(()).f2doctor is the advisory + self-heal + one-command cascade front end that reasons over the same graph (its own deep design →release-doctor-design.md).f3rehearsal proves the cascade against an embedded throwaway crates.io (/sparring) before the real one.f4publish is the crates.io mechanics:cargo publishsubprocess + pure-gixtag/commit, deps-first with index-wait between a crate and its dependents.f5history/events sources the whole run into the warehouse (release_lineage= what shipped;release_events= the op DAG shape), whichrelease trace/release eventsand the viz read back.
Reuse map (what release uses): the warehouse function (Iceberg tables release_lineage, release_events, dep_graph_edges, bench_runs); the dep_graph (WorkspaceGraph); bench (the regression baseline); docs (render_and_stage_docs freshness backstop); guard (guard_intact); and the embedded holger registry for f3. The self-registers as a jobs::kind::RELEASE_RUN ledger row so it shows live in the viz.
f1 — the gate set (what runs at each node)
Each gate is a free function returning Result<()> — Ok(()) = pass, Err = the release aborts at that repo. Header: src/release/gate.rs:1. Two families: per-repo config gates (toggled under [repo.<name>.gates], config::Gates at src/config.rs:137) and standalone CLI gates (release gate <name> <repo>).
| gate | flag / verb | function | fails when |
no_path_patches |
[gates].no_path_patches / gate path_patches |
no_path_patches (gate.rs:23) |
a [patch.crates-io] points at a local path source |
path_dep_versions |
gate path_dep_versions |
path_dep_audit (gate.rs:420) |
a publishable crate's path dep lacks a version = |
crate_metadata |
gate crate_metadata |
crate_metadata_check (gate.rs:540) |
a publishable [package] misses readme/license/repository/description |
links_conflicts |
gate links_conflicts |
links_declarations_scan + detect_links_conflicts (gate.rs:630,:649) |
two crates declare the same links = key |
edition |
gate edition |
edition::* (src/release/edition.rs) |
a publishable pkg is off edition = "2024" / rust-version < 1.85 / resolver ≠ "3", or a rust-2024-compatibility lint fires |
nexus_floor |
[gates].nexus_floor / gate nexus_floor |
nexus_floor (gate.rs:128) |
the last bench run is below its declared throughput floor |
no_regression |
[gates].no_regression (max_regression_pct) |
no_regression_against (gate.rs:166), windowed: no_regression_windowed (gate.rs:301) |
a metric dropped > max_drop_pct vs the machine-matched baseline |
integration_roundtrip |
[gates].integration_roundtrip / gate roundtrip |
integration_roundtrip_via_cargo_test (gate.rs:380) |
a configured roundtrip_<kind> test fails |
docs_fresh |
[gates].docs_fresh / gate docs_fresh |
render_and_stage_docs (gate.rs:960) then layout::check_all |
a managed artifact drifted from its .nornir/ source after a render |
guard_intact |
[gates].guard_intact |
guard::intact (via run_pipeline, pipeline.rs:391) |
a [guard].forbidden path drifted from the recorded manifest |
tests |
[gates].tests |
run_tests_gate → tests_gate (gate.rs:912,:847) |
the newest test-matrix run is red, or a test regressed vs the last green run |
coverage |
[gates].coverage / gate coverage |
coverage_gate (gate.rs:681) |
a discovered surface has no covering test row and no allowlist excuse |
The sweep release gate all <repo> runs every gate enabled in nornir.toml and reports pass/fail per gate. gate_all_outcome (gate.rs:789) enforces empty-is-not-green: a repo with zero configured gates ran nothing and is NOT a pass (all_pass = failed.is_empty() && gate_count > 0).
Inside run_pipeline the gates collapse into the per-repo gate_status string — one of succeeded / succeeded_dry_run (pass) or failed_test / failed_bench / failed_bench_exec / failed_regression / failed_guard_intact (fatal). Only the two succeeded* values pass gate_succeeded (pipeline.rs:118); any other aborts the remaining build order (pipeline.rs:496). A bench run is persisted as a future regression baseline only if it clears every bench gate first, so a red run never poisons the history (pipeline.rs:338).
f2 — the doctor (advise + self-heal + one-command cascade)
The doctor is the release function's front end and its own subsystem — full design in release-doctor-design.md. It answers can I release, what's off, and in what order? over the whole constellation, in a four-tier escalation (assess → --fix → --rehearse → --publish), each a superset of the trust the last earned. Engine: src/release/doctor.rs; CLI: ReleaseOp::Doctor (src/bin/nornir.rs:1611).
Signals it composes (all from local checkouts): dirty trees; the publish pre-flight (scan_path_dep_version_gaps — path deps need version=); external-dependency skew (analyze_skew, target = the semver semilattice join = highest declared version) with transitive-pin detection (⛔ held); the promote gate (compute_promote_block — foreign fork riders held); crate-level topo publish order + cycles; and blast radius. Its graph-theory core — Tarjan SCC, condensation → publish waves, MFAS cycle-cut ranking, and the invertive STRIPS-operator planner over the outcome space Ω — lives in src/release/graph_math.rs (f6). The semantic blast pre-link check (semantic_blast::*, src/release/semantic_blast.rs) resolves a version bump's changed symbols to the exact call sites that touch them via the warehouse SCIP call-graph — "what actually breaks", not merely "who depends on it".
f3 — rehearsal (prove against embedded /sparring)
Before touching real crates.io, the cascade is rehearsed against an embedded, throwaway crates.io: holger's /sparring writable fake registry, brought up in-process via the embed-holger cargo feature (holger_embed::EmbeddedHolger::start). stage::plan_stage is pure and testable (topo order + per-repo verify set = direct dependents); stage::execute (src/release/stage.rs) then, per repo in order: cuts an ephemeral release branch, cargo publish --registry sparring, and builds each dependent from /sparring — proving the just-published crates resolve from a registry, not from path deps. The cargo config it writes (stage::cargo_config_toml) source-replaces crates.io with /sparring so a dependent's build resolves the fresh workspace crates there and everything else falls through /sparring's crates.io upstream. Every repo is restored to its main branch on return via stage::BranchGuard; /sparring is ephemeral. doctor --rehearse runs this same engine (run_stage_rehearsal in bin/nornir.rs, one shared call path with stage --execute).
f4 — publish (the crates.io mechanics)
The real registry cascade. promote::run (src/release/promote.rs:114) is symmetric with f3 but for crates.io: bump (patch/minor/major/keep, applied + committed on the working branch) → strip [patch.crates-io] on an ephemeral branch deleted on return (dev tree keeps its fork) → publish deps-first with wait_for_index between a crate and its dependents → record every mutating step to the release_changes ledger for release undo. Before any real publish both paths call cargo::ensure_no_patch_crates_io (asserts no [patch] leaks) and the CLI demands a typed PROMOTE confirm.
Publish primitives (src/release/publish.rs):
| primitive | function | note |
| publish order | derive_publish_order → topo_phases (publish.rs:82,:135) |
Vec<Vec<String>>: outer = sequential phases, inner = parallel-safe |
| the upload | run_cargo_publish / run_cargo_publish_with_retry (publish.rs:321,:335) |
cargo subprocess (cargo owns packaging); retry honours the 429 rate-limit wait (parse_rate_limit_wait, publish.rs:201) |
| index-wait | wait_for_index (publish.rs:653) |
polls crates.io until <crate>@<version> is the advertised max — avoids propagation-lag resolve failures |
| tag / commit | tag / commit_release / push (publish.rs:591,:423,:578) |
pure-Rust gix — the commit is built in-process; gix 0.84 has no send-pack, so push prints the exact git push for the operator/CI |
| tarball audit | tarball_stats (publish.rs:706) |
cargo package then walk the .crate — size + entry count + largest file |
PublishOutcome (publish.rs:25) treats crates.io's "already uploaded" as a skip, not an error, which is what makes a --bump keep cascade idempotent (finish/resume a fail-stopped run). release undo (src/release/undo.rs) reverses a run newest-first from release_changes: un-bump, restore a stripped [patch], delete/reset the release branch, and --yank an immutable publish.
f5 — release history & events (sourcing the run)
Two warehouse tables, two questions:
release_lineage — what shipped. One row per (release, repo): git SHA, dirty flag, gate_status, published versions, Urðr time-machine pins, and the dep_graph_snapshot_id that pinned the plan. Written by pipeline::persist_lineage (pipeline.rs:767) from both the library orchestrator and the fat-CLI path, so the table is written identically regardless of driver. Read back chronologically (keyed (ts_micros, uuid)) by pipeline::query_release_history (pipeline.rs:869) — the contract the regression bisect relies on. It is the rollback story: a release_id yields the per-repo SHAs to git checkout.
release::regression::trace_gate (src/release/regression.rs:72) is the inverse of no_regression: a scan + boundary-find over that history for "this is red now — when was it last green, and what changed?", bounding the suspect commit range (rank_suspects, regression.rs:141) via dep_graph::affected_by_change ∩ git_heat. Surfaced by release trace <repo>.
release_events — the op DAG shape. An append-only event log, the structural twin of the funnel DAG: one row per component × op × phase boundary carrying depends_on edges from the dep-graph. Schema warehouse::iceberg_schema::release_events; both paths drive one ReleaseEventEmitter (src/warehouse/release_events.rs) holding the run id + a shared atomic seq (the durable ordering key — Iceberg scan order is unspecified). Emits are best-effort + non-fatal: a logging-row write error is logged and swallowed so it can never abort a green release. depends_on is the load-bearing column — for repo R it is { e.to | e.from == R } (a root records Some([]), an envelope row None). Read back sorted (run_id, seq) by query_release_events; release events <selector> prints a topo view, --json the raw rows. Full detail: release-events.md.
f6 — the graph math (build_order and the doctor's engine)
The order every tier obeys. The constellation is a directed graph G = (V, E): V = crates/repos, E = publish-gating edges (A → B ⇔ A has an in-workspace Normal/Build path dep on B ⇒ B publishes first). Dev-deps, pure version-pins and publish = false crates are excluded from E.
- Build order = topological sort, deps-first.
WorkspaceGraph::build_order()(src/warehouse/dep_graph.rs:255): petgraphtoposortreturns in-degree-0 (top consumers) first; since edges point consumer→producer it reverses to yield deps-first. A graph rebuilt from a recorded snapshot (emptyfacts, realedges) takes the edge-based pathtopo_order_from_edges. A cycle is a hard error naming the node. - The doctor's deeper graph engine (
src/release/graph_math.rs) adds Tarjan SCC cycle detection (cycles()= SCCs with >1 node or a self-loop), condensation →topo_order()/waves()(antichains = crates publishable concurrently) /must_break(), Minimum Feedback Arc Set ranked byEdgeClasscut-cost (dev = 0, optional = 1, normal/crate-split = 10+, eachCutSolutioncarrying a residual topo order that proves the cut yields a DAG), and the invertive--autoplanner — a fixpoint over STRIPS-style remedy operators held in a data table (adding a remedy = adding a row, not anifarm). Deep design: release-graph-math.md.
f7 — the MCP surface (four read-only lenses + one guarded act tool)
dwarves-server mcp (stdio) exposes the release cascade to an AGENT, not just to a human at a shell. Source: crates/dwarves/src/mcp.rs, feature release (turned on by mcp-release and by embed-all). Every body is a spawn_blocking call into dwarves::release_read, which composes the same core the CLI verbs call — no release logic is reimplemented in this layer (L5).
| tool | network | mutates | answers |
release_plan |
offline (manifests only) | no | members · wave order · cycles · publish = false holds · the exact cargo publish argv per crate |
release_publishable |
offline | no | what ships, split root-workspace vs detached workspaces, with each crate's local version |
release_drift |
LIVE crates.io | no | per crate: skip (published AND byte-identical) · drifted · first-publish · republish · unknown (probe FAILED — not a verdict) |
release_gates |
LIVE crates.io | no | the preflight verdict plus every blocker with its fix and its blame, no build |
release_verification |
LIVE | no | the constructive rehearsal question release_gates does not answer |
release_publish |
LIVE | YES, irreversibly | the act tool — see the three locks below |
release_publish is guarded three deep and each lock is a different KIND of thing, on purpose: (1) it defaults to a dry run — execute omitted ⇒ the script comes back as data and nothing runs; (2) the server must carry DWARVES_MCP_ALLOW_PUBLISH=1, which lives in the MCP server config a human edits, so arming is the operator's act and not reachable from any tool argument; (3) even armed, confirm must equal PUBLISH_CONFIRM — PUBLISH-TO-CRATES-IO — verbatim, deliberately not a boolean, because a model pattern-completing a field named confirm emits true without having decided anything. When refused it returns the exact CLI invocation (dwarves workspace-release <root> --execute) so the handoff to a human is one paste.
Gate B — the /sparring rehearsal gates every crates.io upload path
All three dwarves entrypoints that can upload to crates.io now run the same served-/sparring publish rehearsal — release_doctor::gate_b::run, the one writer — before the irreversible cascade: dwarves workspace-release --execute, the MCP release_publish tool, and the plain dwarves release --execute (the last-closed door — it previously ran the deps-first cargo publish cascade with no rehearsal behind it). The gate is scoped to the crates.io target by gate_b::crates_io_execute_needs_rehearsal: a private --registry <alias> --execute is NOT rehearsed (rehearsing INTO the alias to publish INTO it is circular), and a dry-run uploads nothing. Built without --features rehearse the rehearsal is unavailable, and a crates.io --execute then REFUSES — with the message that names the fix (rebuild --features rehearse) — rather than uploading a version it cannot prove builds. No ungated door remains, fixed by construction rather than by a guard watching the paths agree.
⚠ The router defect — measured 2026-08-30, lane AR
The five release tools were compiled in, registered, asserted green by a test, and absent from tools/list for 18 days (9382ec2, 2026-08-12 → dfc3136, 2026-08-30).
#[tool_router(router = release_tool_router)] puts the release tools in their own cfg-gated impl block and DwarvesMcp::new() sums that router onto the INSTANCE field. But rmcp 1.8.0's DEFAULT router expression for #[tool_handler] is Self::tool_router() — the static base router (rmcp-macros-1.8.0/src/tool_handler.rs:22) — so list_tools/call_tool answered from a router built fresh at every call and the constructor's sum was computed, stored, and never read. The field carried #[allow(dead_code)] with a note claiming the default was self.tool_router; between them, the attribute and the note suppressed the one diagnostic that disagreed.
MEASURED on a dwarves-server built through the declared door at origin/main dfc3136 (--features embed-all,read-fat,nornir/embed-ort,build-image,appliance-iso,container-oci): tools/list returned exactly autonom_kick · funnel_decompose · funnel_submit_idea, and tools/call release_plan returned -32602 tool not found. Two older installed binaries (/usr/local/bin/dwarves-server 2026-08-27 and target/release/dwarves-server 2026-08-27) answered identically, so no agent on this box had ever reached the surface.
The fix is #[tool_handler(router = self.tool_router)] and the removal of the allow. The lasting part is the guard: tests::the_wire_advertises_the_release_tools serves DwarvesMcp over a tokio::io::duplex pair and asks an rmcp client for tools/list. The pre-existing release_tools_register asserts s.tool_router.has_route(..) — the field — and was GREEN in the same run that the wire test was RED. A router is not a surface; a tools/list reply is. Assert through a different door than the one under test.