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

nordisk/dwarves

.nornir/shooting-ramp-design.md

Shooting-ramp release-branch model — design

Class: design · Function axis: f0 the ramp as the single source of truth · f1 prepare_release_ramp (build the ramp) · f2 Gate-B rehearses the ramp · f3 publish shoots from the ramp · f4 atomic rollback / push-on-success · f5 convergence-viz feed Strict: this file states only what the shooting-ramp model IS — its parts and why they are shaped this way. It is the round-3 structural fix that supersedes the round-2 working-tree hoist patch (commit a8040a6). Task: workspace_nordisk PLAN part-2 track #5 ("RELEASE SHOOTING-RAMP BRANCH MODEL"). Convergence-viz tie-in: track #1/#6 (ReleaseConvergence, local→sparring→crates.io).

The release opens ONE prepared git branch per repo, applies every fix on that branch FIRST (feature-aware version bumps incl. drift-hoist, a single up-front cross-repo lock reconcile to the ship-version graph, [patch.crates-io] strip, dirty resolution, optional fresh-lock), and commits it. That branch — the shooting ramp — is the ONE artifact both Gate B and the real publish consume: Gate B rehearses exactly the ramp head, and the crates.io publish shoots from exactly the ramp head. Rehearse == ship by construction: there is no second version graph and no second lockfile for the two to disagree about.

Why this replaces the round-2 patch

Today (a8040a6) the ship graph is computed in one place and re-applied transiently in another, so the two can drift:

The ramp removes both seams: one commit, read twice. The ReconcileWatermark (rehearse.rs:385), the transient ManifestGuard/LockGuard hoist, and the per-step BranchGuard ephemerals (stage.rs:26) all collapse into "the ramp already carries the reconciled, bumped, patch-stripped state."

The system as one — the function map (f0)

part lives in what it is
f1 prepare_release_ramp new release_doctor/ramp.rs cut one ephemeral branch per repo, apply the whole bump-hoist + one cross-repo lock reconcile + patch-strip, commit → return RampBranch
f1 RampBranch / RampSet release_doctor/ramp.rs the immutable descriptor of a prepared branch (repo, root, branch, base, head sha, applied ship-versions); RampSet = the run's whole map repo → RampBranch
f1 RampGuard release_doctor/ramp.rs run-level RAII: discard every ramp branch on failure/panic (atomic rollback); keep + push on explicit commit_and_push()
f2 Gate B on the ramp rehearse::rehearse_cascade (thinned) cargo publish --registry sparring straight off each ramp head; no hoist, no watermark — the ramp already has the reconciled locks
f3 publish shoots the ramp promote::run (thinned) cargo publish deps-first off the same ramp head; no bump, no reconcile, no branch-cut
f4 push / rollback RampGuard::commit_and_push / Drop Gate B green ⇒ shoot then push ramp branch + tags; Gate B red ⇒ discard, nothing uploaded
f5 convergence feed graph_diff + ReleaseConvergence the ramp head is the local depth-plane of the local→sparring→crates.io viz (#28)

Place in the nordisk constellation

ramp.rs sits inside crates/dwarves/src/release_doctor/, upstream of both rehearse and promote. It reuses (LAW #5, never twins): cargo::plan_version_bump / apply_bump_plan / reconcile_lockfile_cross_repo / refresh_lock_before_publish / ensure_no_patch_crates_io (cargo.rs), the doctor::global_crate_publish_order deps-first order (doctor.rs:1440), and the same ReconcileCtx shape (cargo.rs:836). It shells git via the same pattern stage.rs/promote.rs use today (L9-TODO: repoint at the pure-Rust nornir_git leaf, zero-shell). Acyclicity: ramp depends on cargo+doctor; rehearse and promote depend on ramp; nothing depends back up.

f1 — prepare_release_ramp : building the ramp

/// One prepared, internally-consistent release branch for `repo`. Cut from the repo's
/// current branch, carrying EVERY release fix committed, so both Gate B and the real
/// publish read one identical head. Ephemeral: discarded on rollback, pushed on success.
pub struct RampBranch {
    pub repo: String,
    pub root: PathBuf,
    pub branch: String,        // e.g. "release/ramp/<run-id>"
    pub base_branch: String,   // the branch we cut from (rollback target)
    pub base_head: String,     // its sha at cut time (rollback anchor)
    pub ramp_head: String,     // the committed ramp sha — what rehearse AND publish read
    pub applied: BTreeMap<String, String>, // crate → ship version committed here
}

pub struct RampOpts {
    pub run_id: String,        // shared across the run's repos (one branch name family)
    pub allow_dirty: bool,     // --allow-dirty: proceed over deliberately dirty source
    pub fresh_lock: bool,      // --fresh-lock: from-scratch Cargo.lock on the ramp
    pub strip_patch: bool,     // strip [patch.crates-io] on the ramp (publish needs it gone)
}

/// Cut the ramp for `repo`, apply the whole fix-set, commit, and return the descriptor.
/// `ship_versions` is the GLOBAL crate→ship-version graph (from compute_ship_versions):
///   used both to HOIST this repo's own crates and, as the cross-repo map, to reconcile
///   this repo's lock pins. `lock_reconcile` is the ReconcileCtx (registry probe for the
///   SKIP-IF-LIVE rule + any cargo --config/env). Returns Err only if the branch can't be
///   cut / committed — the caller then aborts the run before Gate B (nothing published).
pub fn prepare_release_ramp(
    repo: &str,
    root: &Path,
    ship_versions: &BTreeMap<String, String>,
    lock_reconcile: &ReconcileCtx,
    opts: &RampOpts,
) -> Result<RampBranch>;

Steps, all committed on the ramp branch (order load-bearing):

  1. Cut. Record base_branch + base_head, then git checkout -B release/ramp/<run-id>. One branch per repo; the whole RampSet shares <run-id>.
  2. Bump-hoist (feature-aware, ALL drifted/taken crates). For every publishable crate whose ship_versions entry differs from its manifest version, cargo::plan_version_bump(root, krate, ship, /*bump_consumers*/ false) then apply_bump_plan. This is the entire hoist — the drift/taken decision already happened in compute_ship_versions (main.rs:2485, resume-aware via plan_skip_and_drift + registry::classify). A unified-version workspace (facett) bumps [workspace.package].version once, exactly as promote.rs:466.
  3. Strip [patch.crates-io] (when opts.strip_patch) via cargo::ensure_no_patch_crates_io (cargo.rs:1996) — the publish needs the fork patches gone; doing it on the ramp means Gate B rehearses the stripped tree too (today the strip happens only inside promote, so Gate B never sees it — a fidelity gap the ramp closes).
  4. Fresh-lock (opt-in). When opts.fresh_lock, cargo::refresh_lock_before_publish (cargo.rs:822) regenerates the lock from scratch so a stale committed lock can't box a feature/dep out.
  5. ONE cross-repo lock reconcile to the ship-version graph: cargo::reconcile_lockfile_cross_repo(root, ship_versions, lock_reconcile) (cargo.rs:903). This is the single place the lock is moved — replacing today's per-crate ReconcileWatermark re-fires. Because the whole ship graph is passed at once (not interleaved crate-by-crate), the watermark's raison d'être (a shared leaf landing between two of a consumer's crates) evaporates: the consumer's pins are all moved before any rehearsal begins.
  6. Commit the reconciled state with stage_release_files-style scoped staging (promote.rs:326 — only *Cargo.toml + Cargo.lock, NEVER git add -A). Force-add the lock (git add -f -- Cargo.lock) so it is tracked on the ramp branch even when gitignored on main — this is what lets Gate B's /sparring-checksum churn be undone by a plain git checkout <ramp_head> -- Cargo.lock, retiring the byte-snapshot LockGuard in favour of git itself. The force-add never pollutes main (the ramp branch is discarded or pushed as its own ref).
  7. Record ramp_head = git rev-parse HEAD and return.

Run driver. workspace_release_cmd builds the whole RampSet in one pass over cst.members after compute_ship_versions (main.rs:2042) and before Gate B (main.rs:2050), wrapping it in a RampGuard. Cross-repo correctness holds because ship_versions is global: every repo's reconcile sees every other repo's ship version.

f2 — Gate B rehearses the ramp (the watermark collapses)

run_holger_rehearsal (main.rs:1197) changes its roots map to point at the ramp checkouts (each repo is already on its ramp branch — same working dir, ramp head checked out). rehearse::cascade_crate_order + SparringRegistry + the IsoSandbox registry TreeGuard (for a cold-start /sparring) stay unchanged. What is deleted from rehearse_cascade (rehearse.rs:409):

rehearse_cascade shrinks to its essential loop: for each (repo, crate) in the deps-first cascade_crate_order, publish_one_to_sparring off the ramp head, wait_for_sparring_index, record outcome. The ship_versions parameter is dropped (the manifests already carry them). The RehearsalReport verdict semantics are unchanged. The IsoSandbox keeps only its TreeGuard half (registry hermeticity); its lock half is subsumed by the git-restore of the ramp lock.

Net: Gate B is now a pure read of the ramp — it publishes exactly what is committed, mutating only /sparring (restored by TreeGuard) and the ramp's own gitignored lock (restored by git checkout).

f3 — the real publish shoots from the same ramp head

promote::run (promote.rs:358) is thinned to a publisher only. Deleted, because the ramp already did them:

What stays: step 5c ensure_cross_repo_resolved (promote.rs:573) is retained but demoted to a read-only assertion — it verifies each just-published sibling resolves at its ship version with the required feature; on the ramp model a mismatch is a Gate-B escape that should be structurally impossible, so it fails loud rather than mutate-retrying (the retry loop's reason to exist — an unreconciled lock — is gone). Step 6 (cargo publish deps-first + wait_for_index) is unchanged and is the whole job. The recorder/ledger wiring (undo::record_*) still fires for reversibility.

Push on success (round-2 wiring, relocated). On a clean shoot, RampGuard::commit_and_push runs the existing promote.rs:629–661 logic: annotated tags per shipped crate@version (publish::plan_release_tags + create_annotated_tag) and publish::push_release_origin for the ramp branch + tags. Because the ramp branch already carries the bump commit, there is no "restore working branch then push HEAD" dance (BranchGuard::finalize, promote.rs:618) — the ramp is the thing to push. Merge-to-default (fast-forward main to the ramp head, or open a PR) is a follow-up policy knob; the ephemeral-branch-rots-on-crash failure class the startup reconciler heals (main.rs:1706) is retired because a ramp is a first-class, named, resumable ref, not a transient.

f4 — atomic rollback

RampGuard holds the whole RampSet. Its contract:

A resume re-materialises the ramp for the same <run-id>: because the ramp is committed and named, a fail-stopped --execute can re-enter, detect the existing ramp branch at its head, and continue the deps-first shoot from the first unpublished crate — the natural resume point the round-2 model lacked.

#48 — the DATED persistent branch + saved changelist

By default the ramp is the ephemeral per-run release/ramp/<run-id> (discarded each shoot). When RampOpts::dated carries a YYYY-MM-DD, prepare_release_ramp instead targets the DATED persistent branch release/YYYY-MM-DD and marks the ramp [persistent] (ramp.rs dated_release_branch/is_dated_release_branch): unique per release-day, kept and re-used across rehearsals instead of discarded, so a release day's committed fixes accumulate on one named, resumable ref. RampGuard skips the discard for a persistent ramp; the startup reconciler recognises both release/ramp/* and release/YYYY-MM-DD when computing a crashed run's rollback base.

Its companion is the saved changelist (release_doctor/changelist.rs): the dated, reloadable record a dated release writes so a resumed run is never blind. Changelist carries date, root, bump, branch (defaulting to release/<date>), the deps-first order, and the per-crate ship_versions; render emits a stable, grep-friendly Markdown block and parse/load read it back (tolerant of surrounding prose). The conventional path is <root>/.nornir/release-<date>-changelist.md — the human-readable twin of the branch: the branch carries the committed fixes, the file records what shipped. The core is PURE (no I/O beyond the thin write/load file helpers).

What each old part becomes (subsumption map)

old (round-2) round-3 ramp
stage::BranchGuard per-step ephemerals (stage.rs:26); per-step branch release/workspace-release/<repo>/step-N (main.rs:2147) ONE RampBranch per repo for the whole run; RampGuard owns discard/keep
promote::run steps 4/5/5a/5b (bump+commit, branch-cut, strip, fresh-lock, reconcile) prepare_release_ramp steps 2–6
rehearse transient ManifestGuard hoist + ReconcileWatermark + per-crate reconcile ramp already bumped + reconciled once; rehearse reads it
stage::LockGuard byte-snapshot git checkout <ramp_head> -- Cargo.lock (lock force-added to the ramp)
iso_sandbox::IsoSandbox lock half subsumed by the git-restore; TreeGuard registry half kept
compute_ship_versions (main.rs:2485) unchanged — it feeds prepare_release_ramp (ship_versions param)
ensure_cross_repo_resolved mutate-retry (promote.rs:573) read-only assertion (mismatch = Gate-B escape, fail loud)

Preserved semantics: --bump (incl. keep ⇒ empty ship_versions ⇒ ramp bumps nothing, publishes manifest versions as-is), --allow-dirty (honoured at ramp-cut: dirty source without the flag refuses before cutting), --fresh-lock (ramp step 4), --members/--include-held/--only (scope ship_versions + the publish set exactly as now). Deps-first order stays doctor::global_crate_publish_order / plan_publish_steps; SCC crate-level interleaving is unchanged (the ramp is a state preparation, orthogonal to order).

f5 — convergence-viz tie-in (#28)

The ramp gives the convergence viz its local depth-plane cleanly: local = the ramp head's version graph (committed, inspectable), sparring = the Gate-B /sparring state, crates.io = the target. The three planes snap together as the deps-first shoot lands each crate. Because local is now a single committed artifact (not a transiently-mutated working tree), the graph_diff::GraphDiffArtifact the run already emits (main.rs:2251) is a faithful, reproducible feed for the engine-agnostic ReleaseConvergence model — red (needs-release/lock-desync) → amber (bump-drift) → green (converged) is read straight off ramp-vs-crates.io. No extra plumbing beyond exposing RampSet to the emitter.

Migration steps (incremental, each keeps the engine green)

  1. Add ramp.rs alongside the old path (no behaviour change). Introduce RampBranch/RampSet/RampGuard/RampOpts + prepare_release_ramp, built by reusing cargo::* + the stage/promote git helpers. Ship it with unit tests (below) but do NOT wire it into workspace_release_cmd yet. Engine still green on the round-2 path.
  2. Wire the ramp as the source Gate B reads, keeping the old hoist as a no-op fallback. In workspace_release_cmd, after compute_ship_versions, build the RampSet under a RampGuard; point run_holger_rehearsal's roots at the ramp checkouts. Leave rehearse_cascade's hoist code in place — on a ramp checkout the ship-version manifests already match, so plan_version_bump is a no-op and ReconcileWatermark.take finds nothing to move. Verifies the ramp is internally consistent without deleting anything. (RED-when-broken: assert manifest_guard/lock_guard captured zero entries when reading a ramp.)
  3. Point the shoot at the ramp; demote promote's bump/reconcile. Make release_one_repo/promote::run publish off the ramp head and skip steps 4/5/5a/5b when a RampBranch is supplied (feature-flag or an Option<&RampBranch> param). ensure_cross_repo_resolved stays as the assertion. Now rehearse == ship reads the same head end-to-end.
  4. Delete the now-dead round-2 code. Remove the transient ManifestGuard hoist, ReconcileWatermark, and LockGuard use from rehearse_cascade (drop its ship_versions param); shrink IsoSandbox to its TreeGuard; retire BranchGuard per-step ephemerals in favour of RampGuard. Each deletion is guarded by a test asserting the ramp already carried that state.
  5. Move push + rollback into RampGuard. Relocate the promote.rs:629–661 tag/push block to RampGuard::commit_and_push; wire Drop = discard. Retire the startup stranded-branch reconciler's need (keep it one more release as a belt-and-suspenders, then remove).
  6. Expose RampSet to the convergence emitter (#28) — additive, advisory, never gates.

Each step compiles + passes cargo test -p dwarves (and, at steps 3–4, an --ignored /sparring round-trip proving a ramp rehearses then shoots identically).

Tests (inject-assert, LAW 1)

Risks

Source map

See also: design.md, the round-2 rejection-coverage note release-doctor-rejection-coverage.md.