<!-- nornir:class: design -->
# 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](../../dwarves/.nornir/release-doctor-design.md) (the four-stage
model), [release-doctor-rejection-coverage.md](../../dwarves/.nornir/release-doctor-rejection-coverage.md)
(the live rejection matrix), [release-publish-traps.md](../../dwarves/.nornir/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(|_| !opts.dry_run)`** :140 |
| 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**:
- **Tarball size cap (10 MiB default).** No client threshold exists — grep of
`publish.rs`, `cargo_package/mod.rs`, `registry/mod.rs` finds only
`uncompressed_size` bookkeeping (mod.rs:888-942). Server: "max upload size is: N".
- **Wildcard (`*`) dependency ban.** Enforced server-side ("wildcard (`*`)
dependency constraints are not allowed on crates.io"). The only wildcard checks
in `toml/mod.rs:2110/2130` are for `[replace]`, unrelated.
- **Crate-name ownership / squat** — "this crate exists but you don't seem to be
an owner" (403).
- **Rate limits** — new versions per 24h, new crates per week (429).
- **Verified-email / token validity** — 403 without a verified email or valid token.
- **Field length / count caps, category-slug validity, too-similar-name, reserved
names** — validated in `crates.io` `src/controllers/krate/publish.rs`.
## 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.