The nornir build/release/introspection "thing" — technical manual
Class:
guide· audience: an operator driving a real crates.io release, or a maintainer reading the release subsystem. Deadpan and factual: what each command does, in what order, what gates it, and how it fails.This is the operations manual for the whole
nornir releasemachine. Adjacent docs:release-design.md(the why), the per-topic filesrelease-doctor-design.md,release-graph-math.md,release-publish-traps.md,release-rehearsal.md, and the running log of imperfections found in the field:nornir-release-improvements.md(referenced throughout by item number, e.g. item #7).
⚠ MOVED — read this first (doctrine L5a, 2026-07-14: release is a build op). The release ENGINE — the whole publish machine described below (cargo/depdag/ graph_math/preflight/registry/root_cause/source_drift/edition/promote/publish/undo, and the
doctor --publishcascade orchestration) — moved todwarves, where it is the single implementation thedwarvestool, thenornirCLI andnornir-serverall share. It now lives indwarves::release_doctor(dwarves/crates/dwarves/src/release_doctor/) and is driven bydwarves release <workspace-path> [--execute]. Thenornir release <op>ACT verbs described here are retained only as still-working shims that print a deprecation banner (release::release_moved_banner); preferdwarves release. What STAYS in nornir is the KNOWLEDGE / read-only lineage side, reading from the Mimir warehouse: the release HISTORY/lineage verbs (trace,events,changelog,impacted-crates, undo-ledger view) plus nornir's own quality gates —src/release/{doctor,gate,pipeline,progress, regression,stage}.rs. Everysrc/release/<engine-module>.rspath cited below is nowdwarves::release_doctor::<module>unless it names one of those six retained files.
0. The xtask model — pure Rust, no shell
There is no release.sh, no Makefile, no publish.sh. The entire build/release machine is pure Rust. The engine now lives in dwarves::release_doctor (dwarves/crates/dwarves/src/release_doctor/) and is driven by dwarves release; the nornir binary keeps a still-working release shim tree (wired from src/bin/nornir.rs) plus the read-only lineage verbs in src/release/. This is the constellation's zero-shell / pure-Rust charter:
- Every step that another project would write in bash — bump a version, strip a
[patch.crates-io]block, poll crates.io until a version indexes, walk crates in dependency order,cargo publisheach — is a Rust function with unit tests. - Network I/O uses the vendored pure-Rust
ureqclient (no reqwest / openssl / native-tls), so the release path has no C toolchain or system-TLS dependency. cargoitself is the only external process spawned, and it is resolved robustly (item #1): prefer$CARGO, thenrustup which cargo, then~/.cargo/bin/cargo, then PATH, with one clear pre-flight error if none is found instead of a cryptic per-crate spawn failure.
Module map. The engine modules moved to dwarves::release_doctor (dwarves/crates/dwarves/src/release_doctor/); the retained nornir-side modules stay under src/release/ (the KNOWLEDGE / lineage / nornir-quality half):
| module | now at | responsibility |
cargo.rs |
dwarves::release_doctor::cargo |
manifest walking, publishable_crate_versions, next_version, bump application |
graph_math.rs |
dwarves::release_doctor::graph_math |
Tarjan SCC → condensation → publish waves, MFAS cycle-cut ranking, the invertive remedy planner |
depdag.rs |
dwarves::release_doctor::depdag |
the crate publish-order graph annotated with per-crate registry state (the dep-DAG viz surface) |
preflight.rs |
dwarves::release_doctor::preflight |
pre-cascade validation — the gate that refuses to start an irreversible cascade on a red plan |
registry.rs |
dwarves::release_doctor::registry |
the registry-STATE check — a pluggable backend answering "what version is actually published?" |
root_cause.rs |
dwarves::release_doctor::root_cause |
topological root-cause: collapse N downstream verify failures to the one root |
source_drift.rs |
dwarves::release_doctor::source_drift |
edited-but-unbumped published-source drift oracle |
edition.rs |
dwarves::release_doctor::edition |
the Rust-2024 / cargo-standards edition gate |
promote.rs |
dwarves::release_doctor::promote |
the per-repo publish unit (bump → strip patches → publish → wait-for-index) |
publish.rs |
dwarves::release_doctor::publish |
cargo publish driving + failure classification (classify_publish_failure) |
undo.rs |
dwarves::release_doctor::undo |
reverse a release run from the release_changes ledger |
doctor.rs |
src/release/doctor.rs (nornir) |
nornir-only cross-check helpers over the Mimir warehouse |
gate.rs |
src/release/gate.rs (nornir) |
nornir's release gates (metadata, no-regression, core-saturation, coverage, …) |
stage.rs |
src/release/stage.rs (nornir) |
the /sparring rehearsal planner/executor |
pipeline.rs |
src/release/pipeline.rs (nornir) |
the release HISTORY/lineage half (test → bench → gate orchestration, release run) |
progress.rs, regression.rs |
src/release/{progress,regression}.rs (nornir) |
live progress; the release trace bisect over release_lineage |
1. The command surface
All commands are nornir release <op>. Config is discovered by walking up for workspace_*/release/nornir.toml (see config, and src/config.rs::discover), or pinned with --config / $NORNIR_CONFIG.
doctor — assess, self-heal, and (opt-in) publish the whole constellation
nornir release doctor is read-only by default: it prints per-repo dirty trees plus cross-workspace external-dependency version skew with bump hints. It becomes a mutating command only with explicit flags:
--fix— writeversion = "…"for every path dep of a publishable crate that lacks one (sourced from the dep crate's own manifest). Idempotent, formatting-preserving.--auto[--dry] — invertive self-heal: read its own diagnosis and close every AUTO symptom class to a fixpoint (fix path-dep versions, join semver skew, cut cheap cycles), re-assessing after each.--dryprints the ordered remedy PLAN without applying. Terminates green or at a crisp DECISION ask.--explain— surface every crate the cascade withholds (gate-held fork riders) with its per-crate reason (fork_kind, thepatched_depit rides, direct-vs-transitive, unblock advice).--publish— the ONE-COMMAND RELEASE: after assessing with zero blockers, publish the whole constellation to crates.io, every public repo in crate-topo order, deps-first, FAIL-STOP, each mutation recorded forrelease undo. Private repos are skipped. This is the path the rest of this manual is about.
--publish modifiers:
| flag | effect |
--bump patch|minor|major |
bump every publishable crate by this level before publishing (default patch) |
--bump keep|none |
publish CURRENT versions unchanged — the idempotent resume/finish path |
--publish-dry-run |
plan + cargo publish --dry-run only; no upload, no mutation |
--publish-from <repo> |
RESUME the cascade from this repo (skip everything before it in topo order) |
--force |
override the preflight gate — publish despite blocking issues (see §3) |
--registry-url <base> |
override the registry-state-check backend with a custom sparse-index base |
Real lines:
nornir release doctor # read-only advisory
nornir release doctor --auto --dry # what would self-heal do
nornir release doctor --publish --bump patch --publish-dry-run # rehearse the cascade
nornir release doctor --publish --bump patch # the real fat release
nornir release doctor --publish --bump keep --publish-from facett # resume a torn run
publish <repo> — one repo's cascade
nornir release publish <repo> [--bump patch] [--dry-run] [--no-wait-index] [--wait-secs 300] walks a single repo's publish order from nornir.toml. It drives the same per-repo promote unit the constellation cascade uses, so the two paths are byte-for-byte identical.
stage — the /sparring rehearsal
nornir release stage plans the cascade against holger's embedded /sparring sparse registry (prints the topo publish order + per-repo verify targets). stage --execute actually runs steps 1–3: spawn holger → cargo publish each crate into /sparring in topo order → build each dependent from /sparring. This rehearses the cascade end-to-end against a real registry that is NOT crates.io, so nothing becomes immutable.
nornir release stage # print the plan
nornir release stage --execute # run the rehearsal into /sparring
Known rehearsal caveats (open items): the sparring store is PERSISTENT and lives under holger's own --data dir, not git/.nornir/stage/registry/ (item #2 — a --fresh reset flag is the planned fix); private repos' publish attempts print alarming-looking failures the real publish correctly skips (item #3).
promote — the per-repo publish unit (internal)
There is no standalone nornir release promote command; promote is the shared unit (dwarves::release_doctor::promote, driven by promote_one_repo in src/bin/nornir.rs) that both publish <repo> and the doctor --publish cascade call per repo. One promote does, in order: regenerate docs → run the repo's enabled gates → bump versions (reversibly recorded) → strip [patch.crates-io] blocks → cargo publish each crate → wait-for-index → (on a real run) commit + push the bumped manifests. Understanding "promote" as the unit is what makes the cascade legible: the cascade is promote looped over repos in topo order.
Supporting ops
wait-for-index <crate> <version> [--timeout-secs 300]— poll crates.io until the version ismax_version(item #3's fix; replaces a fixed sleep).undo [release-id] [--dry-run] [--yank]— reverse a run's mutations from therelease_changesledger newest-first: un-bump (restore the exact pre-bump manifest), restore a stripped[patch], delete/reset a release branch. A crates.io publish is immutable, so it is yanked (with--yank) rather than removed.run [--repo …] [--skip-tests|--skip-bench|--skip-gates|…]— the test → bench (persist) → gate-all orchestration in topo order (no publish).trace,events,changelog,impacted-crates,tarball-stats,strip-patch-blocks,yank-cascade,mirror-to-holger,bump-version— the introspection + surgical-edit verbs.
2. The deps-first topological cascade
The release order is not hand-maintained. graph_math.rs builds the crate dependency graph, runs Tarjan SCC to find cycles, condenses each SCC to a node, and topologically sorts the condensation into publish waves: a crate is only published after every crate it depends on is already live and indexed. This is why wait-for-index exists — a dependent's cargo publish verify build resolves its dependency FROM crates.io, so the dependency must be indexed first or the dependent fails to build.
The cascade is FAIL-STOP: promote::run records per-crate publish failures in its report and returns Ok, so the cascade explicitly inspects them and halts rather than sailing on to a dependent whose dependency never shipped. A halted cascade leaves the already-shipped crates live-and-immutable and the rest unshipped — a torn release, which is exactly what preflight (§3) and the registry-state check (§5) exist to prevent, and what --publish-from + --bump keep exist to recover from.
Cycles that block the topo sort are reported; graph_math's MFAS ranking proposes the cheapest set of edges to cut, and doctor --auto can apply the cheap cuts.
3. Preflight — refuse to start a red cascade
cargo publish is irreversible. dwarves::release_doctor::preflight moves every catchable failure BEFORE the first real upload: it validates the WHOLE plan and refuses to begin unless clean (or --force). One report — preflight: N blocking issue(s) — lists every blocker with its exact fix. The checks:
- membership — every planned
-ptarget is a member of its repo-root workspace (catches the detached-[workspace]bug). - metadata — required crates.io fields present per crate (
description,license/license-file,repository), resolvingfield.workspace = trueinheritance against[workspace.package]. - dep-API skew (verify) —
cargo publish -p <crate> --dry-runper crate; a compile error citing an already-published sibling is caught up front. See §3.1 — this check is now bump-aware. - detached workspaces — listed as notes, so intentional exclusions are visible.
- version/registry clash — for a real bump, the target version must not already be live on crates.io (an immutable clash); for
--bump keep, an already-live version is a benign idempotent skip. - registry-state desync — the direct question, see §5.
3.1 Bump-aware preflight (item #7)
The trap it fixes. Under --bump patch, the cascade publishes crates in dep order at their about-to-be-bumped versions. But the naive verify (check 3) builds each crate against the CURRENTLY-published versions of its siblings. So a crate B that needs sibling A's new API fails cargo publish --dry-run — A's new API is only in the local, not-yet-published A vN+1. The real cascade publishes A vN+1 FIRST and waits for it to index, at which point B resolves the new API. The naive preflight had no way to know that, and reported ~44 false blockers. The real fat release only got through with --force.
How it now works. The --publish site in src/bin/nornir.rs builds a planned publish map — the union of every repo plan's publish_set, each crate mapped to the version the cascade WILL publish it at (current local version + bump):
src/bin/nornir.rs—let cascade_publishes: BTreeMap<String,String> = …built fromplans.iter().flat_map(|p| p.publish_set.iter().cloned())(empty for--bump keep).dwarves::release_doctor::preflight::runtakes this map as its finalcascade_publishesparam and threads it into the verify loop.dwarves::release_doctor::preflight::skew_is_cascade_resolvable(dep, cascade_publishes)is the pure decision: it returnsSome(planned_version)when the crate the skew BLAMES is in the planned map (⇒ the cascade republishes it forward ⇒ NOT a blocker), andNoneotherwise (⇒ a real blocker). An unnamed dep is conservatively a blocker.dwarves::release_doctor::preflight::verify_onereturns aVerifyOutcome:Clean,Blocker, orCascadeResolvable { dep, planned }. ACascadeResolvableskew is recorded as a NOTE (… verify skew against \dep` is cascade-resolvable (dep will be published at vN+1 earlier in the wave) — not a blocker`), never pushed as a blocking issue.
What still blocks (real blockers preserved):
- a verify skew blaming a crate NOT in the release set (external, or one the cascade won't republish) — a genuine unpublished-API failure;
- a missing-metadata gap (check 2), e.g. a crate without
repository; - a version already live on crates.io for a real bump (check 5);
- a
local < publishedDESYNC from the registry-state check (check 6, §5); --bump keep|nonepasses an EMPTY planned map, so the strict behavior is preserved: an edited-but-unbumped /AlreadyPublishedsibling still blocks (this keeps the item #9 / #10 resume semantics intact).
Net effect: nornir release doctor --publish --bump patch passes preflight WITHOUT --force on a coherent tree, while a genuinely missing external dependency still stops the cascade before any irreversible upload.
Tests (dwarves::release_doctor::preflight, #[cfg(test)] mod tests): skew_is_cascade_resolvable_only_for_in_release_deps (a skew blaming an in-release sibling like facett_core clears with its planned version, while a skew blaming an external crate like serde still blocks, and an empty planned map suppresses nothing) and cascade_resolvable_skew_becomes_a_note_not_an_issue.
4. The config: release/nornir.toml
Loaded by src/config.rs. The workspace root is the config's grandparent-of-grandparent (…/workspace_<name>/release/nornir.toml → …/), and every relative path inside is resolved against it.
# One block per repo the cascade knows about. A repo present on disk but WITHOUT a
# [repo.<name>] block is NOT in repo_order → it is silently excluded from the cascade.
[repo.facett]
path = "facett" # on-disk dir relative to workspace root (default: <name>)
remote = "…" # doc-link metadata only (README/CHANGELOG "repository")
private = false # true ⇒ NEVER published to crates.io; promote hard-refuses
test_features = "gui,testmatrix"# extra cargo features for `nornir test run` (NORNIR_TEST_FEATURES)
publish_order = [["a","b"], ["c"]] # optional explicit wave override
[repo.facett.gates] # per-repo release gates (all default false / off)
no_path_patches = true
nexus_floor = true
no_regression = true
max_regression_pct = 5.0
core_saturation = true # CORE-SATURATION LAW: fail if a parallel bench under-used cores
min_core_busy_frac = 0.9
docs_fresh = true # README sections in sync
guard_intact = true # [guard].forbidden paths unchanged
tests = true # run the repo test MATRIX at release (heavy)
coverage = true # AUT5 completeness: every surface covered or allowlisted (light)
integration_roundtrip = ["kind"]
[repo.facett.bench]
required_results = ["text_500mb", "rust_crate_mt32"] # the bench corpus schema
network_required = false
# The private nordisk repos — SSH-cloned, never public.
[repo.nornir]
private = true
[repo.korp]
private = true
# The registry-STATE-check backend (item #10). Default = crates.io; no config needed.
[registry]
backend = "crates-io" # or "custom"
url = "" # sparse-index base when backend = "custom" (private/holger mirror)
[guard]
forbidden = ["path/to/protected"] # guard_intact gate watches these
[storage]
kind = "local" # or "remote" (Flight endpoint via remote_url)
local_path = "" # empty ⇒ <home>/.nornir/workspaces/<name>/builds
Gate names (for nornir release gate <name> <repo>): path_patches, path_dep_versions, crate_metadata, links_conflicts, edition, nexus_floor, no_regression, docs_fresh, roundtrip, guard_intact, coverage, all.
5. The registry-state check (item #10)
Verify (check 3) builds against the currently-published siblings, but never asks the registry the ONE direct question: what version is actually published? So a local-vs-published DESYNC slips through — the local tree carries gatling 0.1.4 while crates.io already serves 0.1.6 (a teammate's or a prior partial cascade's upload). A dependent then verify-builds against the newer published API and breaks mid-cascade.
dwarves::release_doctor::registry adds a pluggable RegistryBackend trait (published_max_version(krate) -> Option<String>) with two impls:
CratesIoBackend(default) — the crates.io sparse index (https://index.crates.io/<ab>/<cd>/<crate>, cargo's index-path convention), parsing newline-delimited JSON for the semver-max NON-yankedvers; falls back to the JSON API (https://crates.io/api/v1/crates/<crate>). A descriptiveUser-Agentis baked in — crates.io returns HTTP 403 to a UA-less request, which is why a barecurlfails.CustomBackend { base_url }— a configurable sparse-index base, to point the check at a private registry / holger mirror.
Both use pure-Rust ureq. The pure classify(local, published) -> RegistryState maps: local > published → WillPublish (good); local == published → AlreadyPublished (the cascade skips it); local < published → Desync (the bug — a blocker); published = None → Unpublished; unparsable → Unknown (never a false block).
Wiring: preflight check 6 turns a Desync into a blocking [desync] issue. Because it's a first-class issue, it flows into the topological root-cause report (§6) — a desync crate is a node, so dependents that blame it attach as its downstream. Config: the [registry] section; CLI override --registry-url <base> (the flag wins).
6. The topological root-cause report (item #9)
A flat preflight list is often ONE problem seen from N angles: a single dependency edited-but-not-published makes every dependent fail its verify ("uses API from gatling that isn't in the PUBLISHED gatling"). dwarves::release_doctor::root_cause partitions the failure set against the publish dependency graph: a failure is a ROOT iff none of its transitive publish-deps also failed; everything else is DOWNSTREAM, attributed to the root(s) it depends on. Multiple independent roots are supported.
Two edge sources combine — the doctor's already-computed repo_edges (reused as a WorkspaceGraph) AND each failure's blames (the dep a verify error names) — so the true root is recovered even when it was SKIPPED and never became its own failure (the gatling/AlreadyPublished case). The report leads with:
ROOT CAUSE(S) — fix these, in order:
⛔ gatling — <reason> → unblocks: lbzip2, lgz, ljar, lzip-parallel, osm-katana, znippy-zoomies
(6 downstream failures explained by 1 root)
The flat per-issue list (with fixes) stays below it.
7. The end-to-end flow
The safe path from "nothing" to a published constellation:
doctor— read-only assessment. Fix any genuine metadata/version-skew blockers (--fix,--auto, or by hand). This is where you add a missingrepositoryfield or aversion =on a path dep.stagethenstage --execute— rehearse the whole cascade against holger's/sparringregistry. Nothing becomes immutable; this is the last dress rehearsal that exercises realcargo publish+ real dependent builds.doctor --publish --bump patch --publish-dry-run— the plan +cargo publish --dry-runend-to-end against the real preflight (now bump-aware), no upload.doctor --publish --bump patch— the real cascade. Preflight runs first (and, with the bump-aware fix, should be clean without--forceon a coherent tree); then the deps-first cascade publishes wave by wave, waiting for each crate to index before its dependents, fail-stopping on any real failure.
If step 4 tears: recover with doctor --publish --bump keep --publish-from <first-unshipped-repo> — idempotent, it ships only the not-yet-published crates without re-bumping the rest. release undo [--yank] reverses the mutations of a run.
8. Failure modes (what the errors mean)
[desync] local gatling 0.1.4 is BEHIND the registry (published 0.1.6)— the registry has a newer version than your tree (§5). Bump past the published version so the local tree leads the registry before starting the cascade. This is a hard blocker because a dependent would otherwise verify against the newer published API and tear the cascade.[version] gatling 0.1.2 is already live on crates.io (immutable)— the target (post-bump) version already exists. crates.io versions can't be overwritten; bump past it, or use--bump keepto skip already-published crates. (Note: under a bump this targets the POST-bump version, so a mere "current version already live" is not flagged.)- **
[verify] uses API from \facett_core` not in the published `facett_core`** — dep-API skew. Iffacett_core` is IN the release set, the bump-aware preflight now records this as a NOTE (the cascade publishes it first) — NOT a blocker. If it is NOT in the release set, it is a real blocker: bump and publish that dependency first. [metadata] missing crates.io metadata: repository— a genuine gate failure. Add the field (e.g.repository.workspace = true) to the crate's[package]. The 6 knut crates in the real fat release (graphar,graphar-falkordb,graphar-flight,knut-bifrost,knut-pipelines,knut-popviz) hit exactly this.[non-member] not a member of the workspace at …— a detached[workspace]. Mark itpublish = falseor publish it from its own workspace directory.- Missing catalog entry — a publishable crate/repo that has no
[repo.<name>]block innornir.tomlis never scheduled intorepo_order, so it silently never publishes (a member added after the first stage, e.g.facett-graphnav, is the same class — item #2). Add the block, or verify the crate appears instage's printed plan. The AUT5coveragegate is the analogous "surface with no catalog/allowlist entry" check for test coverage (e.g. anautonomsurface uncovered and un-allowlisted fails the gate). - Unpushed bumps — after a green publish, the per-repo bumped
Cargo.toml/Cargo.lockedits must be committed + pushed so origin (and the dwarf server's pull) reflect the published versions (item #5). The real cascade commits + pushes on a non-dry run; if you interrupt it, push the bumps by hand. cargo publish spawn failed: No such file or directory— cargo not on PATH (item #1).source ~/.cargo/env; the resolver now also tries$CARGO/rustup which cargo/~/.cargo/bin/cargoand pre-flights one clear error.
8.5 nornir build — the declarative build-thing (variants + the suite driver)
Distinct from the release machine above, nornir build drives the nornir-build-thing cells (.nornir/build-variants.toml) — the data that says what a repo can build.
-
nornir build --variant <name>— build one declared variant (its exactcargo build -p … --bin … --features … --profile …argv).--list-variantslists them;--dry-runprints the argv without running;--jsonemits the build-thing surface.--image/--vm/--installer <name>drive the engine targets (container / VM / bootable ISO). -
nornir build --all— the ONE suite build driver. Walks every[repo.<name>]in the loaded roster (workspace_nordisk/release/nornir.toml), discovers each repo's.nornir/build-variants.toml, VALIDATES each cell, and builds every CARGO variant (Exe / Lib / Wasm / Service) with its declared feature set. Container/VM/ISO targets are skipped (drive those per-repo with--image/--vm/--installer).--dry-run— enumerate the whole-suite cargo plan without compiling.--role <server|client|library|tool>— build just one role across the suite.--no-dist— skip the canonical-name landing (below).- A repo with no cell (a build-tooling gap) or no checkout is LOGGED and skipped, never a hard error —
--allreports coverage. Exits non-zero only if a declared variant actually fails.
-
Canonical named landing (
dist/). After a successful Exe/Service build the binary is hardlinked (else copied) into<repo>/dist/<variant.name>— the human-readable canonical artifact name. The target dir is resolved honoringCARGO_TARGET_DIRand a repo-local.cargo/config.toml[build].target-dir(so a repo likeznippythat redirects cargo to~/.cache/znippy-targetstill lands itsdist/copy). Pure-fs; the only subprocess is cargo itself.
Every repo's cell + this driver are the subject of ../../workspace_nordisk/.nornir/suite-build-inventory-and-scheme-2026-07-30.md.
8.6 The build-version LAW across nornir's binaries
Every nornir binary embeds its identity in --version, from the SAME pure-Rust, zero-shell git stamp (build.rs reads .git/HEAD + the resolved ref off disk → NORNIR_GIT_HASH; lib.rs builds VERSION_LINE = v<pkg> (<git-sha>)). Beyond the main nornir bin and nornir-mcp, the auxiliary bins now comply too: nornir-server --version and nornir-ui --version print nornir::VERSION_LINE; nornir-viz-web --version prints its own git-stamped line (that crate is not a member of the nornir crate, so it carries its own minimal pure-fs build.rs stamping NORNIR_VIZ_WEB_GIT_HASH).
9. Cross-references
nornir-release-improvements.md— the field log of imperfections and their fixes (items #1–#10). This manual documents the shipped state of #7 (bump-aware preflight), #9 (root-cause), and #10 (registry-state).release-design.md,release-doctor-design.md,release-graph-math.md,release-publish-traps.md,release-rehearsal.md— the design-level companions.