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

nordisk/nornir

.nornir/faithful-dry-run-spec.md

Faithful dry-run — making release doctor a complete rehearsal of cargo publish

The release doctor's DRY-RUN must be a FAITHFUL, COMPLETE rehearsal of a real cargo publish: a green dry-run has to guarantee a green real publish. It recently did not. The doctor's --publish-dry-run printed an "all clear" for nornir 0.5.3, but the real publish fail-stopped on a correctness blocker — a dependency (draupnir) carried new API that was not yet published to crates.io, so nornir's verify build compiled against local source the registry copy did not have. A dry-run that misses what the real run hits is worse than no dry-run: it manufactures false confidence before an irreversible cascade.

This is the authoritative spec. It (1) enumerates every step a real cargo publish performs and every way it can fail, with cargo source citations; (2) maps each to a gap table — does cargo publish --dry-run cover it, and if not exactly what the doctor must simulate itself, cross-referenced to what the doctor already has (preflight.rs, registry.rs, source_drift.rs) vs what is missing; and (3) gives the implement agent an executable plan for a FAT-client up-front rehearsal that early-fails before any mutation.

Companions: release-doctor-design.md (the four-stage model), release-doctor-rejection-coverage.md (the live rejection matrix), release-publish-traps.md (field-caught traps). Engine: dwarves::release_doctor::preflight, dwarves::release_doctor::registry, dwarves::release_doctor::source_drift; CLI wiring src/bin/nornir.rs (ReleaseOp::Doctor --publish).

⚠ MOVED (doctrine L5a — release is a build op). The release-doctor ENGINE this spec covers moved from nornir/src/release/ to dwarves::release_doctor (dwarves/crates/dwarves/src/release_doctor/). Every bare preflight.rs, registry.rs, source_drift.rs, publish.rs, doctor.rs filename + line citation below now resolves under that dwarves path (dwarves/crates/dwarves/src/ release_doctor/<file>), not nornir/src/release/. Line numbers are historical and may have drifted since the move; treat them as landmarks, not exact offsets. The nornir release doctor verbs are now still-working shims over dwarves release.

The core insight

cargo publish --dry-run is a LOCAL validator, not a registry rehearsal. It runs everything cargo can decide from the working tree — packaging, manifest normalization, the verify build — but it provably skips every check that lives on the registry: the actual upload, token/credential acquisition, the authoritative version-already-exists check, crate-name ownership, rate limits, the tarball size cap, the wildcard-dependency ban, and — the one that bit us — it never confirms that each dependency's required version is actually live on the registry. It resolves the dependency graph against the LOCAL Cargo.lock and a workspace where path-deps mask their registry twins, so an un-published upstream version is invisible.

The doctor's job is therefore not to re-run cargo publish --dry-run and trust it. It is to run the dry-run plus a battery of registry-side simulations that reproduce, up front and offline-of-mutation, exactly the checks cargo defers to the server — and to early-fail before the first bump commit if any would fail.

Part 1 — the full ordered checklist of a real cargo publish

Line numbers are rust-lang/cargo master. Files: src/cargo/ops/registry/publish.rs (driver), src/cargo/ops/cargo_package/{mod,vcs,verify}.rs (packaging), src/cargo/util/toml/mod.rs (manifest normalization), src/cargo/ops/mod.rs (check_dep_has_version). Entry point: publish() at publish.rs:72.

# Step What it checks / does Fails with Citation
S1 Resolve -p specs to workspace members each spec must match a member spec.query error ("did not match any packages") publish.rs:73-83
S2 Partition publishable vs publish=false publish()==Some(vec![]) ⇒ unpublishable "{pkg} cannot be published. package.publish must be set to true or a non-empty list" publish.rs:85-106 (msg :97-105); --workspace/virtual skips via allow_unpublishable :91-96
S3 Resolve target registry + publish allow-list registry must be in package.publish list "the registry {reg} is not listed in the package.publish value" publish.rs:129, resolve_registry_or_index :838, validate_registry :878 (msg :888-893)
S4 Acquire credentials / registry client reads token (Operation::Read) token/credential-provider errors publish.rs:134-141 — **`Some(Operation::Read).filter(
S5 verify_unpublished() — version not already live index query for exact =version "crate {name}@{vers} already exists on {registry}" publish.rs:149, defn :450 (msg :475-480) — downgraded to WARNING under --dry-run :467-473
S6 verify_dependencies() — deps publishable each dep has a version req; no cross-registry deps to crates.io ops/mod.rs:100-108 "all dependencies must have a version requirement specified when publishing"; publish.rs:510-517 "crates cannot be published to crates.io with dependencies sourced from other registries. {} needs to be published to crates.io before..." publish.rs:150, defn :487; check_dep_has_version ops/mod.rs:86
S7 Package + build dep graph check_metadata:true, include_lockfile:true, verify:opts.verify, dry_run:opts.dry_run see S7a–S7g package_with_dep_graph publish.rs:162 → do_package cargo_package/mod.rs:241,253
S7a Lockfile freshness validates existing Cargo.lock (internal dry_run=false) resolve error mod.rs:258-270
S7b check_metadata() — required fields crates.io targets only; WARNING-only for missing description, license/license-file, documentation/homepage/repository (warns, never errors) prepare_archive mod.rs:469; check_metadata :804 (:822-834). name/version are structurally required at manifest parse ("missing field package.name" toml/mod.rs:684)
S7c VCS dirty check working tree clean unless --allow-dirty; no git ⇒ assumed clean "{n} files in the working directory contain changes... pass the --allow-dirty flag" vcs::check_repo_state mod.rs:491, vcs.rs:48 (msg :264-270); symlink-as-plaintext warn :116/:151
S7d check_filename() no non-unicode names; no special chars / \ < > : " | ? *; Windows-reserved names warn "invalid inclusion of reserved file name..." (mod.rs:514-517) build_ar_list mod.rs:497; check_filename :1091 (:1104-1116)
S7e Cargo.lock inclusion excluded from source scan, re-added as generated file — mod.rs:513, :559-571
S7f Generate normalized Cargo.toml real toml stored as Cargo.toml.orig; a normalized one is generated; strips path/git/branch/tag/rev/base, elaborates registry→index URL, Simple string dep → {version} "cannot publish with open-namespaces" (toml/mod.rs:2993) prepare_for_publish/prepare_toml_for_publish toml/mod.rs:2938/2979; map_dependency :3246 (:3254-3271)
S7g tar() — build the .crate gzip best, deterministic headers (fixed mtime 1153704088, mode 0644); symlinks followed and written as regular file contents; returns uncompressed size but NO client-side size threshold (no client size error) mod.rs:860, generated toml :918, generated lock build_lock :741/:920
S8 run_verify() — the verify build unless --no-verify: unpack .crate, build in ephemeral workspace against the registry copies of deps, then check build.rs didn't mutate sources "Source directory was modified by build.rs during cargo publish... pass the --no-verify flag" mod.rs:347-352, verify.rs:35 (build :60-117, msg :123-129)
S9 Cycle detection publish plan must be acyclic "circular dependency detected while publishing... help: to break a cycle between dev-dependencies..." PublishPlan::has_cycles publish.rs:185-203 (msg :195-202)
S10 Upload loop (topological) per crate: seek tarball, SHA256, obtain Operation::Publish token, transmit()→registry.publish(new_crate, tarball) "failed to publish {} v{} to registry at {}..."; server warnings (bad categories/badges) surfaced publish.rs:205-376; token :227-247 gated if !opts.dry_run; transmit :262/:649, registry.publish :667
S11 Wait for registry availability poll index every 1s for exact =version, default 60s timeout (publish.timeout) "unable to publish {...} due to a timeout while waiting for published dependencies to be available" wait_for_any_publish_confirmation :321/:385; poll_one_package :438; timeout :288-294 (msg :367-369)
S12 Mark confirmed, iterate loop until plan empty — mark_confirmed :375/:805

Failure modes that live ONLY on the crates.io server

The cargo CLIENT does not enforce these; they are returned by registry.publish() at publish.rs:667 and thus surface only at real upload time:

Part 2 — gap table: cargo publish --dry-run coverage, and what the doctor must fill

Legend for "dry-run": ✅ performed identically to a real run · ⚠ performed but DOWNGRADED (warning, not error) · ❌ SKIPPED entirely. "Doctor today" cites the existing check; MISSING flags a real gap the implement agent must close.

Real step / failure cargo publish --dry-run? Why (citation) Doctor must simulate Doctor today
S1 non-member -p ✅ spec resolves locally — ✅ IssueKind::NonMember, run() preflight.rs:463 (matrix #1)
S2 publish=false target ✅ local partition — ✅ UnpublishableDep / versioned-dep-on-publish=false sibling, preflight.rs:666 (#8)
S3 registry allow-list ✅ local manifest — (implicit; low-risk for crates.io)
S4 token / credential validity ❌ Operation::Read filtered off :140; publish token if !opts.dry_run :227-247 fetch token (CARGO_REGISTRY_TOKEN or ~/.cargo/credentials.toml), resolve login via GET /me; if publishing and no token → block/warn ✅ token read + login: registry::read_crates_io_token, crates_io_login (registry.rs:264,282), wired at nornir.rs:11563 (#34)
S5 version already live ⚠ warning only downgraded :467-473 query the registry for exact krate@version; if live and not --bump keep, block (immutable clash) ✅ RegistrySkew / registry_skew_issue, RegistryProbe::version_live (preflight.rs:1586, :217) (#4)
S6 dep has version req ✅ local, check_dep_has_version — ✅ path-dep-no-version UnpublishableDep (gate::path_dep_audit, preflight.rs:367); git-dep-no-version (#7,#15)
S6 cross-registry dep ban ✅ local — ✅ BadManifest cross-registry block (#29)
S6′ dep version actually PUBLISHED ❌ (THE draupnir GAP) verify_dependencies only checks a version req EXISTS, never that it resolves on the registry; graph resolves against local Cargo.lock; a workspace path-dep masks its un-published registry twin for EVERY dependency edge (cross-repo constellation member AND external), confirm the registry serves a non-yanked version satisfying the requirement PARTIAL. Verify build catches it when it triggers a compile error (VerifyFailed, verify_one preflight.rs:1726, blames the dep) and RegistryProbe::req_satisfied exists (preflight.rs:227) but is used for ordering, not as a standalone per-edge "is this req satisfiable on the registry" gate. MISSING: a direct, compile-independent per-dependency published-req check.
S7b required metadata (description/license) ⚠ warning only crates.io rejects, cargo only warns :822-834 assert description + (license|license-file) present; name/version present ✅ MissingMetadata / missing_metadata_fields (preflight.rs:1542) (#2)
S7c VCS dirty ✅ (errors w/o --allow-dirty) vcs.rs:264 doctor runs verify with --allow-dirty; separately report dirty trees ✅ dirty-tree signal (check_dirty in doctor.rs); doctor's verify passes --allow-dirty (preflight.rs:1734)
S7d filename / reserved / symlink ✅ local — (covered by verify build; low-risk)
S7f manifest normalization / open-namespaces ✅ local — ✅ many BadManifest checks: keywords, categories, readme/license-file exists+UTF-8, feature dep: refs, URL scheme, semver, edition, rust-version, description length, SPDX license (#9–#29)
S8 verify build (dep-API skew) ✅ (unless --no-verify) verify.rs — but builds against the LOCAL workspace where path-deps mask the registry, so it only catches skew that reaches a compile error run cargo publish -p <crate> --dry-run --allow-dirty per crate; classify a compile failure that cites an already-published dep as a correctness blocker ✅ VerifyFailed, verify_one + classify_publish_failure (preflight.rs:1726; publish.rs PublishFailure::DepApiSkew) (#3). Bump-aware: cascade-resolvable skew → note
S8′ edited-but-unbumped source (same-version drift) ❌ verify never diffs local vs published bytes at an unchanged version for each planned crate already live at its target version, diff local source against the published .crate; a mismatch means a dependent verifies the STALE copy → block ✅ StaleSourceDrift / source_drift_issues + PublishedSourceOracle (preflight.rs:790, source_drift.rs) (#12)
S9 cycle detection ✅ local plan — ✅ topo order + Tarjan SCC detect_cycles (doctor.rs)
S10 actual upload ❌ "aborting upload due to dry run" :662-665 (not simulated — that IS the point of a dry-run) n/a
S10 crate-name ownership (squat) ❌ server-side inside registry.publish for each name to publish, query crates.io owners; block a name owned by someone other than our login (the gatling/rotary squats) ✅ ownership_blocks / crate_owner_logins (registry.rs:297,340), wired nornir.rs:11566 (#13)
S10 tarball >10 MiB ❌ no client threshold (mod.rs:888-942) inspect the .crate the dry-run verify wrote to target/package/; block if > 10 MiB ✅ OversizedTarball / oversized_tarball_issue (preflight.rs:650) (#27)
S10 wildcard * dep ❌ server-side scan manifests for a * version req on any published dep ✅ BadManifest wildcard block (for_each_published_dep, preflight.rs:687) (#14)
S10 rate limit (429) ❌ server-side not deterministically preflightable; handled at publish with retry 🔵 runtime-only, retry in publish.rs (#33)
S11 index-propagation wait ❌ skipped in dry-run (confirmed = to_confirm.clone() :285-286) between publishing a crate and its dependents, poll the index (wait_for_index) ✅ wait_for_index in the real cascade (publish.rs); N/A to dry-run
server field-length/slug/too-similar/reserved ❌ server-side length caps preflightable (description ≤1000, keywords ≤5, categories ≤5); slug/too-similar/dynamic-reserved are backend-only ✅ length/count caps (#10,#18,#25); 🔵 slug/too-similar/reserved-table (#30–#32)

The one gap that caused the incident, stated precisely

The draupnir failure is row S6′. Two mechanisms let an un-published dependency version pass the dry-run:

  1. Path-dep masking. In the constellation, a consumer declares a member dep as dep = { version = "X", path = "../dep" }. cargo publish strips the path (S7f, toml/mod.rs:3254-3260) so the PUBLISHED manifest needs X live on crates.io — but the LOCAL verify build and lockfile resolution use the path, so they never touch the registry. If X was bumped locally but not yet published, the local build is green and the real publish's consumers break.
  2. AlreadyPublished-but-edited upstream. The dep's version is unchanged and IS on crates.io, but its local source added new API a to-publish consumer uses. The verify build compiles against local source (path-masked) and passes; the published consumer resolves the older registry bytes and fails.

cargo publish --dry-run catches (2) ONLY when it happens to be built without the path (rare) and never reliably catches (1). The doctor must therefore assert, independently of any compile, that every dependency requirement is satisfied by a live non-yanked registry version — or, if the dep is a constellation member the cascade will publish forward, that the cascade publishes it first.

Part 3 — implementation plan for the FAT-client faithful dry-run

Goal: nornir release doctor --publish --publish-dry-run (and the real --publish) run the FULL registry-side rehearsal up front and early-fail before any mutation — no bump, no bump-commit, no release/staging branch, no cargo package, no cargo publish, no push has run when a blocker is reported. The dry-run and the real run share ONE code path and produce the SAME verdict; the only difference is the dry-run stops at the gate.

The architecture already exists and is mostly wired at nornir.rs:11553-11717. The remaining work is closing S6′ and hardening parity. Do NOT edit doctor.rs for the checks — they belong in preflight.rs / registry.rs (pure + unit-tested); doctor.rs only orchestrates.

3.1 Parity invariant (already landed — guard it)

preflight_verify_mode(_publish_dry_run) -> VerifyMode::On (preflight.rs:307) is the fix that closed the original false-clear: the verify build and source-drift pass run in BOTH modes. Keep the parameter ignored; the unit test that pins this must stay. gate_verdict (preflight.rs:336) is pure over the report and used identically by both modes — correctness blockers (is_correctness_blocker: VerifyFailed | StaleSourceDrift) are NEVER forceable; soft blockers abort unless --force.

3.2 Close S6′ — the missing per-dependency published-req check

Add a pure function in preflight.rs, e.g.:

pub fn unpublished_dep_reqs(
    plans: &[RepoPlan],
    probe: &dyn RegistryProbe,               // req_satisfied already exists
    cascade_publishes: &BTreeMap<String,String>,
) -> Vec<Issue>

For every planned crate, walk its NORMAL + BUILD dependencies (reuse for_each_published_dep / manifest_all_dep_names, and read the version req via dep_version_req). For each dep with a version requirement:

  1. If the dep is a member the cascade will publish forward at a version satisfying the req (cascade_publishes, hyphen/underscore-canonicalized like skew_is_cascade_resolvable) → OK (order covers it) → NOTE.
  2. Else call probe.req_satisfied(dep, req):

    • Some(true) → OK.
    • Some(false) → blocker IssueKind::UnpublishableDep (or a new UnpublishedDepVersion): "dep {d} requires {req} but crates.io serves no non-yanked version satisfying it — publish {d} first". This is the draupnir catch, compile-independent. Classify it as a correctness blocker (un-forceable) since forcing ships a crate that cannot resolve.
    • None (network/unparsable) → NOTE (degrade-safe, never a false block).

This makes the doctor prove S6′ directly instead of hoping the verify build's compiler stumbles on it. It also fires for path-masked deps because it reads the declared version req, not the resolved path. Unit-test with an injected probe (no network), mirroring registry.rs's test style.

3.3 Up-front rehearsal ordering (the early-fail battery)

Run in this order inside the --publish handler's preflight block, BEFORE the cascade loop (this is the existing structure at nornir.rs:11553+; slot 3.2 in):

  1. Selection — compute plans (per-repo publish_set, honoring --only/--exclude/--publish-from, private-repo skip, drift-skip).
  2. Ownership (S10) — registry::ownership_blocks over all names; bail on any foreign-owned name. Warn (don't bail) when no token/login.
  3. Token presence (S4) — if real publish (not dry-run) and no token → bail early with the "create a token / cargo login" guidance (lane ③).
  4. preflight::run (S1–S7, S8, S9-adjacent) with VerifyMode::On, CratesIoProbe, the registry backend, and the bump-aware cascade_publishes map — produces metadata, name, manifest, path-dep, registry-skew, registry-desync, versioned-path-dev-dep, oversized-tarball, and verify-build (dep-API-skew) issues.
  5. unpublished_dep_reqs (S6′, NEW) — append its issues/notes.
  6. source_drift_issues (S8′) — append (verify-On only).
  7. Topological root-cause — root_cause::analyze over the flat issue list so the operator sees the ONE root, not N downstream symptoms.
  8. gate_verdict — Proceed (dry-run stops here and reports "would publish"; real run continues) / BlockCorrectness (never forceable) / BlockSoft (forceable). The dry-run and real run print the SAME verdict.

Every network probe is best-effort: an unreachable registry degrades a check to a NOTE, never a false block (the established doctrine in registry.rs).

3.4 What "faithful" now means, testably

A green dry-run guarantees a green real publish iff, for the planned set, all of: version-not-already-live (S5) · required metadata (S7b) · valid name/semver/SPDX/ manifest (S7f) · every dep has a version req and none cross-registry (S6) · every dep req is satisfied by a live registry version or published forward by the cascade (S6′) · no dep-API skew in the verify build (S8) · no same-version source drift (S8′) · name owned by us or free (S10) · tarball ≤ 10 MiB (S10) · no wildcard dep (S10) · no cycle (S9) · token present (S4). The only real-publish outcomes a green dry-run cannot prove are rate-limit (S10, retry-handled) and index-propagation timing (S11, wait_for_index-handled) — both transient, not correctness, and both handled in the cascade itself.

Add a parity regression test: a fixture where a consumer path-masks an un-published member version must produce IDENTICAL blocking verdicts under --publish-dry-run and --publish (both BlockCorrectness). That fixture is the draupnir incident, frozen.

Summary of the gaps cargo publish --dry-run leaves (and their owner)

  1. Dependency published-ness (S6′) — the draupnir gap. Dry-run never confirms each dep req resolves to a LIVE registry version; path-deps mask it. → NEW unpublished_dep_reqs per-edge check (3.2), correctness-class.
  2. Version already live (S5) — downgraded to a warning. → RegistrySkew (have).
  3. Local behind registry / desync — not a cargo concept at all. → RegistryDesync (have).
  4. Crate-name ownership (S10) — server-only. → ownership_blocks (have).
  5. Token/credential validity (S4) — skipped in dry-run. → token+login probe (have; ensure it BAILS on real publish with no token).
  6. Tarball >10 MiB (S10) — no client check. → OversizedTarball (have).
  7. Wildcard * dep (S10) — server-only. → manifest scan (have).
  8. Same-version source drift (S8′) — verify never diffs bytes. → StaleSourceDrift (have).
  9. Rate limit (S10) / index propagation (S11) — transient, runtime-only; retry + wait_for_index in the cascade.

Nine deferred registry checks; the doctor already covers eight. Closing #1 (S6′) with a direct per-dependency published-req assertion is the single remaining change that makes a green dry-run a real guarantee.