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

nordisk/nornir

.nornir/build-thing.md

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 release machine. Adjacent docs: release-design.md (the why), the per-topic files release-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 --publish cascade orchestration) — moved to dwarves, where it is the single implementation the dwarves tool, the nornir CLI and nornir-server all share. It now lives in dwarves::release_doctor (dwarves/crates/dwarves/src/release_doctor/) and is driven by dwarves release <workspace-path> [--execute]. The nornir release <op> ACT verbs described here are retained only as still-working shims that print a deprecation banner (release::release_moved_banner); prefer dwarves 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. Every src/release/<engine-module>.rs path cited below is now dwarves::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:

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:

--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


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:

  1. membership — every planned -p target is a member of its repo-root workspace (catches the detached-[workspace] bug).
  2. metadata — required crates.io fields present per crate (description, license/license-file, repository), resolving field.workspace = true inheritance against [workspace.package].
  3. dep-API skew (verify) — cargo publish -p <crate> --dry-run per crate; a compile error citing an already-published sibling is caught up front. See §3.1 — this check is now bump-aware.
  4. detached workspaces — listed as notes, so intentional exclusions are visible.
  5. 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.
  6. 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):

What still blocks (real blockers preserved):

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:

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:

  1. doctor — read-only assessment. Fix any genuine metadata/version-skew blockers (--fix, --auto, or by hand). This is where you add a missing repository field or a version = on a path dep.
  2. stage then stage --execute — rehearse the whole cascade against holger's /sparring registry. Nothing becomes immutable; this is the last dress rehearsal that exercises real cargo publish + real dependent builds.
  3. doctor --publish --bump patch --publish-dry-run — the plan + cargo publish --dry-run end-to-end against the real preflight (now bump-aware), no upload.
  4. doctor --publish --bump patch — the real cascade. Preflight runs first (and, with the bump-aware fix, should be clean without --force on 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)


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.

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