Gunnar

nordisk/nornir

.nornir/build-thing.md

<!-- nornir:class: guide -->
# 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`](../../dwarves/.nornir/release-design.md) (the why), the per-topic files
> [`release-doctor-design.md`](../../dwarves/.nornir/release-doctor-design.md),
> [`release-graph-math.md`](../../dwarves/.nornir/release-graph-math.md),
> [`release-publish-traps.md`](../../dwarves/.nornir/release-publish-traps.md),
> [`release-rehearsal.md`](../../dwarves/.nornir/release-rehearsal.md), and the running log of imperfections
> found in the field: [`nornir-release-improvements.md`](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**:

- 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 publish` each — is a Rust function with unit tests.
- Network I/O uses the vendored pure-Rust `ureq` client (no reqwest / openssl /
  native-tls), so the release path has no C toolchain or system-TLS dependency.
- `cargo` itself is the only external process spawned, and it is resolved robustly
  (item #1): prefer `$CARGO`, then `rustup 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](#4-the-config-releasenornirtoml), 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` — write `version = "…"` 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. `--dry` prints 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`, the `patched_dep` it 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 for `release 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 is `max_version` (item #3's fix; replaces a fixed sleep).
- `undo [release-id] [--dry-run] [--yank]` — reverse a run's mutations from the
  `release_changes` ledger 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:

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

- `src/bin/nornir.rs` — `let cascade_publishes: BTreeMap<String,String> = …` built from
  `plans.iter().flat_map(|p| p.publish_set.iter().cloned())` (empty for `--bump keep`).
- `dwarves::release_doctor::preflight::run` takes this map as its final `cascade_publishes` param
  and threads it into the verify loop.
- `dwarves::release_doctor::preflight::skew_is_cascade_resolvable(dep, cascade_publishes)` is the
  pure decision: it returns `Some(planned_version)` when the crate the skew BLAMES is in
  the planned map (⇒ the cascade republishes it forward ⇒ NOT a blocker), and `None`
  otherwise (⇒ a real blocker). An unnamed dep is conservatively a blocker.
- `dwarves::release_doctor::preflight::verify_one` returns a `VerifyOutcome`: `Clean`, `Blocker`,
  or `CascadeResolvable { dep, planned }`. A `CascadeResolvable` skew 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 < published` DESYNC from the registry-state check (check 6, §5);
- `--bump keep|none` passes an EMPTY planned map, so the strict behavior is preserved:
  an edited-but-unbumped / `AlreadyPublished` sibling 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.

```toml
# 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-yanked `vers`; falls back to the JSON API
  (`https://crates.io/api/v1/crates/<crate>`). A descriptive `User-Agent` is baked in —
  crates.io returns HTTP 403 to a UA-less request, which is why a bare `curl` fails.
- `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:

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)

- **`[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 keep` to 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. If `facett_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
  it `publish = false` or publish it from its own workspace directory.
- **Missing catalog entry** — a publishable crate/repo that has no `[repo.<name>]` block
  in `nornir.toml` is never scheduled into `repo_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 in `stage`'s printed plan. The AUT5
  `coverage` gate is the analogous "surface with no catalog/allowlist entry" check for
  test coverage (e.g. an `autonom` surface uncovered and un-allowlisted fails the gate).
- **Unpushed bumps** — after a green publish, the per-repo bumped `Cargo.toml`/`Cargo.lock`
  edits 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/cargo` and 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 exact
  `cargo build -p … --bin … --features … --profile …` argv). `--list-variants`
  lists them; `--dry-run` prints the argv without running; `--json` emits 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 — `--all` reports 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 honoring
  `CARGO_TARGET_DIR` and a repo-local `.cargo/config.toml` `[build].target-dir`
  (so a repo like `znippy` that redirects cargo to `~/.cache/znippy-target` still
  lands its `dist/` 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`](../../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`](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`](../../dwarves/.nornir/release-design.md), [`release-doctor-design.md`](../../dwarves/.nornir/release-doctor-design.md),
  [`release-graph-math.md`](../../dwarves/.nornir/release-graph-math.md),
  [`release-publish-traps.md`](../../dwarves/.nornir/release-publish-traps.md),
  [`release-rehearsal.md`](../../dwarves/.nornir/release-rehearsal.md) — the design-level companions.