Shooting-ramp release-branch model — design
Class:
design· Function axis:f0the ramp as the single source of truth ·f1prepare_release_ramp(build the ramp) ·f2Gate-B rehearses the ramp ·f3publish shoots from the ramp ·f4atomic rollback / push-on-success ·f5convergence-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 (commita8040a6). 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:
- C1 false-green (version desync).
main.rs::compute_ship_versions(line 2485) computes the ship graph;rehearse::rehearse_cascade(rehearse.rs:409) then transiently hoists each manifest viaplan_version_bump/apply_bump_planunder astage::ManifestGuard, restoring onDrop; the real publish (promote::run,promote.rs:459) independently re-bumps. Two independent applications of "the same" plan is exactly the seam a drift slips through (the frozen-dwarves@0.1.3-rehearsed-while-0.1.4-shipped fault the comment atmain.rs:2039documents). - Round-4 lock desync. The rehearsal rewrites each consumer
Cargo.lockto/sparringchecksums, guarded bystage::LockGuard+iso_sandbox::IsoSandboxrestore-on-Drop. The real publish then reconciles the lock again (promote.rs:499,:558) and re-verifies withensure_cross_repo_resolved(:573). Every "restore then re-derive" boundary is a place the lock the rehearsal proved and the lock the publish uploads can differ.
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):
- Cut. Record
base_branch+base_head, thengit checkout -B release/ramp/<run-id>. One branch per repo; the wholeRampSetshares<run-id>. - Bump-hoist (feature-aware, ALL drifted/taken crates). For every publishable crate whose
ship_versionsentry differs from its manifest version,cargo::plan_version_bump(root, krate, ship, /*bump_consumers*/ false)thenapply_bump_plan. This is the entire hoist — the drift/taken decision already happened incompute_ship_versions(main.rs:2485, resume-aware viaplan_skip_and_drift+registry::classify). A unified-version workspace (facett) bumps[workspace.package].versiononce, exactly aspromote.rs:466. - Strip
[patch.crates-io](whenopts.strip_patch) viacargo::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 insidepromote, so Gate B never sees it — a fidelity gap the ramp closes). - 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. - 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-crateReconcileWatermarkre-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. - Commit the reconciled state with
stage_release_files-style scoped staging (promote.rs:326— only*Cargo.toml+Cargo.lock, NEVERgit 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 plaingit checkout <ramp_head> -- Cargo.lock, retiring the byte-snapshotLockGuardin favour of git itself. The force-add never pollutesmain(the ramp branch is discarded or pushed as its own ref). - Record
ramp_head = git rev-parse HEADand 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):
- the
ManifestGuardtransient hoist (rehearse.rs:480,:522–536) — the ramp is already at ship versions; - the
ReconcileWatermark+ per-cratereconcile_lockfile_cross_repo(rehearse.rs:460,:513) — the ramp lock is already reconciled to the ship-version graph; - the
LockGuardbyte-snapshot (rehearse.rs:471) — the ramp's committed lock IS the snapshot; the rehearsal's checksum churn is undone bygit checkout <ramp_head> -- Cargo.lockafter the cascade.
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:
- step 4 bump apply + commit (
promote.rs:459–510) → done inprepare_release_rampstep 2; - step 5 branch-cut +
[patch]strip viaBranchGuard(promote.rs:514–529) → ramp is already the branch, already stripped; - step 5a fresh-lock (
promote.rs:540) → ramp step 4; - step 5b cross-repo reconcile (
promote.rs:558) → ramp step 5.
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:
- Gate B fails / verify fails / any
?-bail / panic beforecommit_and_push⇒Dropdiscards every ramp:git checkout <base_branch>thengit branch -D release/ramp/<run-id>per repo. Since nothing was published (Gate B is upstream of the shoot) and the ramp is an unmerged branch, the constellation returns byte-for-byte tobase_head. This is the atomic-rollback guarantee: Gate B red ⇒ discard the branch, nothing published. - Success ⇒
commit_and_push()marks the guard finalized (Drop becomes a no-op), keeps the branches, pushes them + tags. Publishing is irreversible, so a push/tag failure is a surfaced non-fatalreport.errorsentry (as today,promote.rs:660), never a rollback of live crates.
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)
- Add
ramp.rsalongside the old path (no behaviour change). IntroduceRampBranch/RampSet/RampGuard/RampOpts+prepare_release_ramp, built by reusingcargo::*+ thestage/promotegit helpers. Ship it with unit tests (below) but do NOT wire it intoworkspace_release_cmdyet. Engine still green on the round-2 path. - Wire the ramp as the source Gate B reads, keeping the old hoist as a no-op fallback. In
workspace_release_cmd, aftercompute_ship_versions, build theRampSetunder aRampGuard; pointrun_holger_rehearsal'srootsat the ramp checkouts. Leaverehearse_cascade's hoist code in place — on a ramp checkout the ship-version manifests already match, soplan_version_bumpis a no-op andReconcileWatermark.takefinds nothing to move. Verifies the ramp is internally consistent without deleting anything. (RED-when-broken: assertmanifest_guard/lock_guardcaptured zero entries when reading a ramp.) - Point the shoot at the ramp; demote promote's bump/reconcile. Make
release_one_repo/promote::runpublish off the ramp head and skip steps 4/5/5a/5b when aRampBranchis supplied (feature-flag or anOption<&RampBranch>param).ensure_cross_repo_resolvedstays as the assertion. Now rehearse == ship reads the same head end-to-end. - Delete the now-dead round-2 code. Remove the transient
ManifestGuardhoist,ReconcileWatermark, andLockGuarduse fromrehearse_cascade(drop itsship_versionsparam); shrinkIsoSandboxto itsTreeGuard; retireBranchGuardper-step ephemerals in favour ofRampGuard. Each deletion is guarded by a test asserting the ramp already carried that state. - Move push + rollback into
RampGuard. Relocate thepromote.rs:629–661tag/push block toRampGuard::commit_and_push; wireDrop= discard. Retire the startup stranded-branch reconciler's need (keep it one more release as a belt-and-suspenders, then remove). - Expose
RampSetto 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)
prepare_release_ramp_commits_bumped_reconciled_state— a two-repo fixture with a cross-repo pin; assert the ramp head's manifests carry ship versions, the lock pins the ship version,[patch.crates-io]is gone, andbase_headis untouched. RED if any fix is missing from the commit.ramp_guard_discards_on_drop— cut a ramp, drop the guard withoutcommit_and_push; assert every repo is back onbase_branchatbase_headand the ramp branch is deleted (atomic rollback). Mirrorsbranch_guard_finalize_is_crash_safe(stage.rs:361).ramp_guard_keeps_and_would_push_on_success—commit_and_pushmarks finalized; Drop is a no-op; the ramp branch survives with the shipped tags planned.rehearse_on_ramp_hoists_nothing— read a ramp checkout through the old code path (step 2 of migration) and assert the manifest/lock guards captured zero mutations — the structural proof that the ramp already IS the ship state.git_restore_undoes_sparring_lock_churn— force-add a lock on a ramp, poison it (/sparringchecksum),git checkout <ramp_head> -- Cargo.lock, assert byte-identical to the committed reconciled lock (retiresLockGuard).
Risks
- Cross-repo checksum materialisation at ramp time.
reconcile_lockfile_cross_repomoves version pins to the ship graph, but a cross-repo dep that publishes later in the deps-first shoot is not yet on crates.io, so its registry checksum can only be filled when it goes live. The ramp fixes the version graph (killing C1 + version-desync) and Gate B proves resolution against/sparring(where all ship versions ARE served); on the real shoot, deps-first ordering means each dep is live-with-checksum before its consumer publishes, socargo publish's own resolution fills it. The retainedensure_cross_repo_resolvedassertion is the guard that this held. If it ever trips, it is a real ordering/index-lag fault — surface loud, do not silently re-mutate the ramp. - Gitignored locks. Consumer locks are often gitignored (memory: "Test mail"/errors-book notes). The
git add -fonto the ephemeral ramp is the mitigation; a lock that still cannot be tracked falls back to a byte-snapshot restored against the ramp head (a thin residual ofLockGuard, kept only for that edge case). - Push semantics for SSH origins.
push_release_originalready prints an out-of-band command for network origins without an in-process send-pack (promote.rs:627). The ramp branch push inherits that; document that a ramp is pushed as its own ref (SSH-only remotes per the ⛔ SSH-ONLY law). - Merge-to-default policy. The ramp head must reach the repo's default branch eventually (FF or PR). Out of scope for round-3 (which guarantees publish correctness); flagged as the immediate follow-up so ramps don't accumulate.
- Resume identity. A stable
<run-id>is required for a fail-stopped--executeto re-find its ramp; derive it deterministically (constellation + bump + date) so a resume lands on the same branch family.
Source map
- ramp core → new
release_doctor/ramp.rs(prepare_release_ramp,RampBranch,RampSet,RampGuard,RampOpts) - ship graph (unchanged feed) →
main.rs(compute_ship_versions,ship_version_graph_core) - reused mutators →
release_doctor/cargo.rs(plan_version_bump,apply_bump_plan,reconcile_lockfile_cross_repo,refresh_lock_before_publish,ensure_no_patch_crates_io) - deps-first order →
release_doctor/doctor.rs(global_crate_publish_order,plan_publish_steps) - Gate B (thinned) →
release_doctor/rehearse.rs(rehearse_cascade,cascade_crate_order); registry hermeticityrelease_doctor/iso_sandbox.rs(TreeGuard) - shoot (thinned) →
release_doctor/promote.rs(runstep 6,ensure_cross_repo_resolved, tag/push block) - retired →
stage::BranchGuard/LockGuard/ManifestGuard(release_doctor/stage.rs),ReconcileWatermark(release_doctor/rehearse.rs) - convergence feed (#28) →
release_doctor/graph_diff.rs(GraphDiffArtifact) + facettReleaseConvergence
See also: design.md, the round-2 rejection-coverage note release-doctor-rejection-coverage.md.