src/release/stage.rs
//! `nornir release stage` — the brave-but-safe tier: rehearse the whole release
//! cascade against the embedded holger `/sparring` registry before crates.io.
//!
//! This module owns the **plan** (pure, testable): given the cross-repo graph, the
//! topo publish order and, for each repo, the direct dependents to build-verify
//! from `/sparring` once it's published. Execution (cut a release branch →
//! `cargo publish --registry sparring` in order → `cargo build` each dependent
//! against `/sparring` → `seal`) drives this plan; `--dry-run` just prints it.
//!
//! Promote (`release promote`) replays the same order against crates.io once the
//! sparring run is green.
use serde::Serialize;
use crate::release::doctor::{RepoGraph, crate_publish_order};
#[derive(Debug, Clone, Serialize)]
pub struct StageStep {
/// Repo to package + publish into `/sparring` at this position.
pub repo: String,
/// In-constellation repos this repo directly depends on. The cascade GATES on
/// these: `repo` is only attempted once every dependency that precedes it in the
/// order has published (or was already staged). A dependency that failed DEFERS
/// `repo` and reports it blocked, rather than attempting-and-failing it.
#[serde(default)]
pub deps: Vec<String>,
/// Direct dependents to `cargo build` against `/sparring` after publishing
/// `repo` — proves the just-published crates actually resolve from a registry.
pub verify: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StagePlan {
/// Sparse registry the cascade publishes into, e.g.
/// `sparse+http://127.0.0.1:18464/sparring/index/`.
pub registry: String,
/// Ephemeral release branch the version bumps + cascade live on.
pub branch: String,
/// Publish steps in dependency order (dependencies first).
pub order: Vec<StageStep>,
/// Repos left unordered by a dependency cycle (must be empty to execute).
pub cycle: Vec<String>,
}
impl StagePlan {
/// A plan is executable only when the graph is a clean DAG.
pub fn is_executable(&self) -> bool {
self.cycle.is_empty() && !self.order.is_empty()
}
}
/// Repos that directly depend on `repo` (one hop): X depends on `repo` when X
/// declares a dependency on a crate `repo` produces.
fn direct_dependents(graphs: &[RepoGraph], repo: &str) -> Vec<String> {
let Some(target) = graphs.iter().find(|g| g.repo == repo) else {
return vec![];
};
let mut out: Vec<String> = graphs
.iter()
.filter(|g| g.repo != repo && g.deps.iter().any(|d| target.produces.contains(d)))
.map(|g| g.repo.clone())
.collect();
out.sort();
out
}
/// Repos `repo` directly depends on (one hop) — the inverse of [`direct_dependents`]:
/// `repo` depends on X when it declares a dependency on a crate X produces. These are
/// the in-constellation deps the cascade must see published before it may attempt `repo`.
fn direct_dependencies(graphs: &[RepoGraph], repo: &str) -> Vec<String> {
let Some(me) = graphs.iter().find(|g| g.repo == repo) else {
return vec![];
};
let mut out: Vec<String> = graphs
.iter()
.filter(|g| g.repo != repo && me.deps.iter().any(|d| g.produces.contains(d)))
.map(|g| g.repo.clone())
.collect();
out.sort();
out
}
/// Build the stage plan: topo publish order + per-repo verify (direct dependents).
pub fn plan_stage(graphs: &[RepoGraph], registry: &str, branch: &str) -> StagePlan {
// Order at CRATE granularity, not repo: cargo publishes crates, and the
// constellation's repo-level graph has SCCs that are clean crate-level DAGs
// (mutual repo deps carried by DIFFERENT, feature-gated crates). The naive
// repo-level `publish_order` flagged those as a false cycle; `crate_publish_order`
// (the same order the doctor trusts) orders the crate DAG and only reports a
// REAL crate-level cycle as blocking.
let topo = crate_publish_order(graphs);
let order = topo
.order
.iter()
.map(|repo| StageStep {
repo: repo.clone(),
deps: direct_dependencies(graphs, repo),
verify: direct_dependents(graphs, repo),
})
.collect();
StagePlan {
registry: registry.to_string(),
branch: branch.to_string(),
order,
cycle: topo.cycle,
}
}
/// Human-readable plan (the dry-run output).
pub fn format_plan(p: &StagePlan) -> String {
let mut s = String::new();
s.push_str("nornir release stage — plan (dry-run)\n\n");
s.push_str(&format!(" registry: {}\n", p.registry));
s.push_str(&format!(" branch: {}\n\n", p.branch));
if !p.cycle.is_empty() {
s.push_str(&format!(
" ⚠ dependency cycle — NOT executable until resolved: {}\n\n",
p.cycle.join(", ")
));
}
s.push_str(" Publish order (dependencies first):\n");
for (i, step) in p.order.iter().enumerate() {
s.push_str(&format!(
" {}. cargo publish {} → /sparring\n",
i + 1,
step.repo
));
if !step.verify.is_empty() {
s.push_str(&format!(
" verify (build from /sparring): {}\n",
step.verify.join(", ")
));
}
}
if p.is_executable() {
s.push_str(
"\n → green plan; run with --execute to publish into /sparring, then promote.\n",
);
}
s
}
// ─────────────────────── execution (steps 1–3) ───────────────────────
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
#[cfg(not(feature = "embed-holger"))]
use std::process::{Child, Stdio};
use anyhow::{Context, Result};
/// Cargo config the cascade runs under: defines the `sparring` registry to publish
/// INTO, and source-replaces crates.io with it so a dependent's build resolves the
/// just-published workspace crates from `/sparring` (everything else falls through
/// `/sparring`'s crates.io upstream).
pub fn cargo_config_toml(http_addr: &str) -> String {
format!(
r#"[registries.sparring]
index = "sparse+http://{http_addr}/sparring/index/"
[source.crates-io]
replace-with = "sparring-mirror"
[source.sparring-mirror]
registry = "sparse+http://{http_addr}/sparring/index/"
[net]
retry = 2
"#
)
}
/// The embedded holger subprocess — killed on drop. Only the lean
/// `--no-default-features` build uses this; the default build embeds holger
/// in-process (`crate::holger_embed`), so it's compiled out when `embed-holger`
/// is on.
#[cfg(not(feature = "embed-holger"))]
struct HolgerProc(Child);
#[cfg(not(feature = "embed-holger"))]
impl Drop for HolgerProc {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(not(feature = "embed-holger"))]
fn spawn_holger(holger_bin: &Path, data_dir: &Path, grpc: &str, http: &str) -> Result<HolgerProc> {
let child = Command::new(holger_bin)
.arg("dev-pair")
.arg("--data")
.arg(data_dir)
.args(["--grpc", grpc, "--http", http])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("spawn holger dev-pair via {}", holger_bin.display()))?;
Ok(HolgerProc(child))
}
/// Poll the HTTP gateway until it serves (or give up after ~15s).
#[cfg(not(feature = "embed-holger"))]
fn wait_ready(http_addr: &str) -> Result<()> {
let url = format!("http://{http_addr}/cache/index/config.json");
for _ in 0..60 {
if crate::http_agent().get(&url)
.config().timeout_global(Some(std::time::Duration::from_millis(500))).build()
.call()
.is_ok()
{
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(250));
}
anyhow::bail!("holger dev-pair never became ready at {url}")
}
#[derive(Debug, Default)]
pub struct StageOutcome {
/// Repos whose crates published into /sparring.
pub published: Vec<String>,
/// Repos already present in /sparring at this version — a benign skip (the crate
/// is staged and still build-verifies). NOT an error: re-running a rehearsal
/// without a version bump lands here, exactly as crates.io would reject a re-publish.
pub already_staged: Vec<String>,
/// Verify targets that built against /sparring.
pub verified: Vec<String>,
/// Non-fatal per-step failures — a repo that itself failed to publish (repo: reason).
pub errors: Vec<String>,
/// Repos DEFERRED and never attempted because an in-constellation dependency
/// failed (or is itself blocked). Reported distinctly from `errors`: these did not
/// fail on their own merits — an upstream did (repo: blocked by failed dependency X).
pub blocked: Vec<String>,
}
impl StageOutcome {
/// One operator-readable line per recorded repo, covering EVERY outcome
/// vector.
///
/// The viz Rehearse wizard used to hand-roll this and pushed lines for
/// `published` / `verified` / `blocked` / `errors` while silently dropping
/// [`Self::already_staged`] — so a rehearsal re-run with no version bump
/// (every repo lands in `already_staged`) reported green with nothing in the
/// log to say that nothing was actually published. The CLI printer got it
/// right, which is exactly the kind of drift a shared renderer prevents.
/// `stage_outcome_lines_cover_every_vector` fails if a new vector is added
/// to [`StageOutcome`] without being rendered here.
pub fn report_lines(&self) -> Vec<String> {
let mut out = Vec::new();
for r in &self.published {
out.push(format!("published {r} → /sparring"));
}
for r in &self.already_staged {
out.push(format!("already staged {r} (skip; bump to re-stage)"));
}
for r in &self.verified {
out.push(format!("verified {r} builds from /sparring"));
}
for b in &self.blocked {
out.push(format!("⛔ {b}"));
}
for e in &self.errors {
out.push(format!("⚠ {e}"));
}
out
}
}
#[cfg(test)]
mod stage_outcome_report_tests {
use super::StageOutcome;
/// The regression: a fully-idempotent rehearsal (nothing published, every
/// repo already staged) must still SAY so. An empty log there reads as
/// "green, nothing to do" when the truth is "nothing was published".
#[test]
fn an_idempotent_rehearsal_still_reports_its_repos() {
let out = StageOutcome {
already_staged: vec!["znippy".into(), "holger".into()],
..Default::default()
};
let lines = out.report_lines();
assert_eq!(
lines.len(),
2,
"both already-staged repos are reported: {lines:?}"
);
assert!(
lines.iter().all(|l| l.contains("already staged")),
"the line must name the skip, not look like a publish: {lines:?}"
);
assert!(lines.iter().any(|l| l.contains("znippy")));
assert!(lines.iter().any(|l| l.contains("holger")));
}
/// Every outcome vector reaches the log. Add a field to `StageOutcome` and
/// forget to render it and this fails — the drift that produced the bug.
#[test]
fn stage_outcome_lines_cover_every_vector() {
let out = StageOutcome {
published: vec!["pub-repo".into()],
already_staged: vec!["staged-repo".into()],
verified: vec!["verified-repo".into()],
errors: vec!["err-repo: boom".into()],
blocked: vec!["blocked-repo: blocked by failed dependency pub-repo".into()],
};
let lines = out.report_lines();
// Destructured so a NEW field is a compile error here, not a silent drop.
let StageOutcome {
published,
already_staged,
verified,
errors,
blocked,
} = &out;
let expected =
published.len() + already_staged.len() + verified.len() + errors.len() + blocked.len();
assert_eq!(
lines.len(),
expected,
"every vector entry got a line: {lines:?}"
);
for needle in [
"pub-repo",
"staged-repo",
"verified-repo",
"err-repo",
"blocked-repo",
] {
assert!(
lines.iter().any(|l| l.contains(needle)),
"`{needle}` missing from the rehearsal log: {lines:?}"
);
}
}
}
/// RAII cleanup for the ephemeral release branches the rehearsal cuts. Records each
/// repo's branch BEFORE `checkout -B <release>`, and on Drop — success, `?`-early-return,
/// OR panic — switches back to the original branch and deletes the staging branch, so a
/// rehearsal leaves NO trace.
///
/// CRITICAL: it must NEVER blanket-discard the working tree (`git checkout -- .`) — that
/// would eat the user's *uncommitted* edits (it once ate this very file's). The staging
/// branch is cut from the original with no commits, so switching back carries any
/// uncommitted changes cleanly; we then restore only the generated `Cargo.lock` (the one
/// file `cargo publish` churns), leaving every other change untouched.
pub(crate) struct BranchGuard {
/// `(repo_path, original_branch)` captured pre-cut, restored newest-first on Drop.
restores: Vec<(PathBuf, String)>,
/// The ephemeral branch to delete after restoring (e.g. `release/staging`).
branch: String,
}
impl BranchGuard {
pub(crate) fn new(branch: &str) -> Self {
BranchGuard {
restores: Vec::new(),
branch: branch.to_string(),
}
}
/// Capture `path`'s current branch so it can be restored later. Skips capture when
/// the repo is already on the staging branch or HEAD can't be read (best-effort).
pub(crate) fn capture(&mut self, path: &Path) {
// Pure-gix current-branch read via the `nornir-git` leaf (no `git
// rev-parse` subprocess). A detached HEAD ⇒ "(detached)"; skip it.
if let Ok(prev) = crate::gitio::head_branch(path) {
if !prev.is_empty() && prev != "(detached)" && prev != self.branch {
self.restores.push((path.to_path_buf(), prev));
}
}
}
}
impl Drop for BranchGuard {
fn drop(&mut self) {
for (path, prev) in self.restores.iter().rev() {
// Switch back — uncommitted changes (the user's work + the publish's
// Cargo.lock churn) carry across cleanly since the staging branch was cut
// from here with no commits. Then drop the ephemeral branch and restore ONLY
// Cargo.lock (the publish artifact). Never `checkout -- .`.
// Pure-gix via the `nornir-git` leaf (no `git checkout`/`branch`
// subprocess). `checkout_branch` repoints HEAD without touching the
// worktree when the branches share a commit (they do — the staging
// branch was cut here with no commits), so uncommitted changes carry
// across cleanly. Then drop the ephemeral branch and restore ONLY
// Cargo.lock (the publish artifact); a repo with no lockfile is a
// silent no-op (pathspec did not match). Never `checkout -- .`.
let _ = crate::gitops::checkout_branch(path, prev);
let _ = crate::gitops::delete_branch(path, &self.branch);
let _ = crate::gitops::restore_path_from_head(path, "Cargo.lock");
eprintln!(
"stage: restored {} → {prev} (release branch cleaned)",
path.display()
);
}
}
}
/// The result of handing one repo to the publish step — the seam the cascade drives.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PublishAttempt {
/// The repo's crates went into the registry on this attempt.
Published,
/// The version is already present — a benign idempotent skip. Counts as Done: it
/// still satisfies dependents' gate (exactly as crates.io rejects a re-publish).
AlreadyStaged,
/// This attempt failed; the payload is the reason. Retryable until `MAX_ATTEMPTS`.
Failed(String),
}
/// Bound on per-repo publish attempts: a transiently-failing repo is retried on a
/// later pass, but one that keeps failing converges to `errors` instead of spinning.
const MAX_ATTEMPTS: u32 = 2;
/// Per-repo state during the wave cascade.
#[derive(Clone, Copy, PartialEq, Eq)]
enum CascadeState {
/// Unresolved — still waiting on a dep, or has retries left.
Pending,
/// Published or already-staged: its dependents' gate on it is satisfied.
Done,
/// Attempted `MAX_ATTEMPTS` times and still failing (its OWN failure).
Failed,
/// A dependency terminally failed (or is itself blocked): never attempted.
Blocked,
}
/// DEPENDENCY-AWARE PUBLISH CASCADE — the wave sequencer that replaces the old linear
/// fail-forward walk. A repo is handed to `publish` only once every in-constellation
/// dependency that PRECEDES it in `order` has reached [`CascadeState::Done`] (published
/// or already-staged). If a dependency terminally fails, its dependents are DEFERRED
/// and reported blocked — never attempted-and-failed. A repo that fails transiently is
/// retried on a later pass, up to `MAX_ATTEMPTS`; one that keeps failing converges to
/// `out.errors`. Idempotency (already-staged ⇒ Done) is preserved.
///
/// Gating on deps that appear EARLIER in `order` (not on all deps) is deliberate: the
/// order already resolves repo-level SCCs — mutual repo deps carried by *different*,
/// feature-gated crates — into a valid crate-level sequence, so a back-edge to a
/// later repo is not a blocker. Honouring it would deadlock the very SCCs that
/// `crate_publish_order` untangled, re-reporting a clean crate DAG as blocked.
///
/// Termination is bounded: every pass either transitions a repo toward a terminal
/// state or consumes a (capped) publish attempt, so the fixpoint loop always halts.
pub(crate) fn run_cascade<F>(order: &[StageStep], mut publish: F) -> StageOutcome
where
F: FnMut(&str) -> PublishAttempt,
{
let n = order.len();
let pos: BTreeMap<&str, usize> = order
.iter()
.enumerate()
.map(|(i, s)| (s.repo.as_str(), i))
.collect();
// Blocking deps of repo i: its in-constellation deps that come BEFORE it in order.
let blocking: Vec<Vec<usize>> = order
.iter()
.enumerate()
.map(|(i, s)| {
s.deps
.iter()
.filter_map(|d| pos.get(d.as_str()).copied())
.filter(|&j| j < i)
.collect()
})
.collect();
let mut state = vec![CascadeState::Pending; n];
let mut attempts = vec![0u32; n];
let mut last_err: Vec<Option<String>> = vec![None; n];
let mut out = StageOutcome::default();
// Fixpoint over publish waves. A single ascending pass already drains a clean run
// (deps are earlier indices, so they resolve before we reach a dependent); extra
// passes exist only to retry transient failures.
loop {
let mut progressed = false;
for i in 0..n {
if state[i] != CascadeState::Pending {
continue;
}
// A terminally-failed / blocked dependency blocks this repo outright.
let dep_dead = blocking[i]
.iter()
.any(|&d| matches!(state[d], CascadeState::Failed | CascadeState::Blocked));
if dep_dead {
state[i] = CascadeState::Blocked;
progressed = true;
continue;
}
// Otherwise wait until EVERY blocking dep has published / already-staged.
if !blocking[i].iter().all(|&d| state[d] == CascadeState::Done) {
continue;
}
if attempts[i] >= MAX_ATTEMPTS {
state[i] = CascadeState::Failed;
progressed = true;
continue;
}
attempts[i] += 1;
progressed = true;
match publish(&order[i].repo) {
PublishAttempt::Published => {
out.published.push(order[i].repo.clone());
state[i] = CascadeState::Done;
}
PublishAttempt::AlreadyStaged => {
out.already_staged.push(order[i].repo.clone());
state[i] = CascadeState::Done;
}
PublishAttempt::Failed(e) => {
last_err[i] = Some(e);
if attempts[i] >= MAX_ATTEMPTS {
state[i] = CascadeState::Failed; // retries exhausted → own failure
}
// else: stays Pending, retried on a later pass.
}
}
}
if !progressed {
break;
}
}
// Emit terminal outcomes in publish order. A repo that failed on its own merits →
// `errors`; one deferred by a dead dependency → `blocked` (distinct). Any residual
// Pending can only be a stuck sub-graph (a dep that never resolved) → also blocked.
for i in 0..n {
match state[i] {
CascadeState::Failed => {
let why = last_err[i]
.clone()
.unwrap_or_else(|| "cargo publish failed".to_string());
out.errors.push(format!("{}: {}", order[i].repo, why));
}
CascadeState::Blocked | CascadeState::Pending => {
let culprit = blocking[i]
.iter()
.find(|&&d| state[d] != CascadeState::Done)
.map(|&d| order[d].repo.clone())
.unwrap_or_else(|| "upstream".to_string());
out.blocked.push(format!(
"{}: blocked by failed dependency {}",
order[i].repo, culprit
));
}
CascadeState::Done => {}
}
}
out
}
/// Steps 1–3: spawn the embedded holger, cut the release branch per repo, then in
/// plan order `cargo publish --workspace` each repo into `/sparring`, then
/// `cargo build` each verify target against `/sparring`. Stops before seal/promote.
/// holger is torn down on return. Per-repo failures are collected, not fatal, so
/// the outcome shows how far the rehearsal got.
pub fn execute(
plan: &StagePlan,
repo_paths: &BTreeMap<String, PathBuf>,
private: &std::collections::BTreeSet<String>,
_holger_bin: &Path,
data_dir: &Path,
grpc: &str,
http: &str,
) -> Result<StageOutcome> {
if !plan.is_executable() {
anyhow::bail!(
"stage plan not executable (dependency cycle: {})",
plan.cycle.join(", ")
);
}
std::fs::create_dir_all(data_dir)?;
let cfg_path = data_dir.join("cargo-config.toml");
std::fs::write(&cfg_path, cargo_config_toml(http))?;
// Bring up the /cache + /sparring dev pair. Default build: in-process (the
// nornir⇆holger marriage). Lean `--no-default-features` build: subprocess.
// `_holger` stays alive until this fn returns, then the rehearsal registry is
// torn down (runtime dropped / child killed).
#[cfg(feature = "embed-holger")]
let _holger = {
let h = crate::holger_embed::EmbeddedHolger::start(&data_dir.join("registry"), grpc, http)
.context("start embedded holger")?;
h.wait_ready(std::time::Duration::from_secs(15))
.context("embedded holger did not become ready")?;
h
};
#[cfg(not(feature = "embed-holger"))]
let _holger = {
let p = spawn_holger(_holger_bin, &data_dir.join("registry"), grpc, http)?;
wait_ready(http)?;
p
};
eprintln!("stage: holger /cache + /sparring ready on http://{http}");
// Auto-restore the working trees when the rehearsal ends (Drop runs on every exit
// path, so no repo is left stranded on `release/staging`).
let mut guard = BranchGuard::new(&plan.branch);
// 1 + 2 — cut branch, then publish repos in DEPENDENCY WAVES (not a single linear
// fail-forward walk): each repo is attempted only once its in-constellation deps
// have published, a dependent of a FAILED dep is deferred/blocked (never
// attempted-and-failed), and a transient failure is retried on a later pass.
let mut out = run_cascade(&plan.order, |repo| {
// #3: skip PRIVATE repos in the rehearsal exactly as the real `--publish`
// path does — they never reach crates.io, so a "cargo publish failed" on
// them is noise, not a rehearsal signal. Treat as satisfied (non-blocking).
if private.contains(repo) {
eprintln!("stage: skip {repo} (private — not published to crates.io)");
return PublishAttempt::AlreadyStaged;
}
let Some(path) = repo_paths.get(repo) else {
// No working tree here (a verify-only / external repo): nothing to publish,
// and it must not block its dependents — treat as already satisfied.
return PublishAttempt::AlreadyStaged;
};
guard.capture(path);
// Best-effort branch isolation — pure-gix `git checkout -B <branch>` at
// the current HEAD via the `nornir-git` leaf (no subprocess).
let _ = crate::gitops::create_or_reset_branch_at_head(path, &plan.branch);
let res = Command::new(nornir_release_core::cargo::cargo_bin())
.current_dir(path)
.args([
"publish",
"--workspace",
"--no-verify",
"--allow-dirty",
"--registry",
"sparring",
])
.arg("--config")
.arg(&cfg_path)
.env("CARGO_REGISTRIES_SPARRING_TOKEN", "stage-rehearsal")
.output();
let res = match res {
Ok(r) => r,
Err(e) => return PublishAttempt::Failed(format!("cargo publish spawn failed: {e}")),
};
if res.status.success() {
eprintln!("stage: published {repo} → /sparring");
PublishAttempt::Published
} else {
let err = String::from_utf8_lossy(&res.stderr);
// crates.io / sparring both reject re-publishing an existing version. For a
// rehearsal that's a benign "already staged" — surface it, then let the
// verify step still prove it builds. A real publish failure (compile, auth,
// missing metadata) is a genuine error.
if err.contains("already exists") || err.contains("already uploaded") {
eprintln!("stage: {repo} already staged in /sparring (skip; bump to re-stage)");
PublishAttempt::AlreadyStaged
} else {
eprint!("{err}");
PublishAttempt::Failed(format!("cargo publish failed ({})", res.status))
}
}
});
// 3 — verify each dependent builds against /sparring (unique targets). Only verify
// dependents of repos whose crates actually reached /sparring — a repo that failed
// or was blocked never published, so building against it would fail for that reason.
let done: std::collections::BTreeSet<String> = out
.published
.iter()
.chain(out.already_staged.iter())
.cloned()
.collect();
let mut seen = std::collections::BTreeSet::new();
for step in &plan.order {
if !done.contains(step.repo.as_str()) {
continue;
}
for dep in &step.verify {
if !seen.insert(dep.clone()) {
continue;
}
let Some(path) = repo_paths.get(dep) else {
continue;
};
let status = Command::new(nornir_release_core::cargo::cargo_bin())
.current_dir(path)
.args(["build", "--quiet"])
.arg("--config")
.arg(&cfg_path)
.status()
.with_context(|| format!("cargo build {dep}"))?;
if status.success() {
out.verified.push(dep.clone());
eprintln!("stage: verified {} builds from /sparring", dep);
} else {
out.errors
.push(format!("{dep}: verify build failed ({status})"));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn graph(repo: &str, produces: &[&str], deps: &[&str]) -> RepoGraph {
let dep_set = deps.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>();
RepoGraph {
repo: repo.to_string(),
produces: produces
.iter()
.map(|s| s.to_string())
.collect::<BTreeSet<_>>(),
deps: dep_set.clone(),
// Each produced crate carries the repo's deps as its crate-level deps, so
// crate_publish_order / detect_cycles see the real per-crate graph: a
// repo-level SCC that is a clean crate DAG is orderable, while a true
// crate cycle (a-crate↔b-crate) still blocks.
crate_deps: produces
.iter()
.map(|p| (p.to_string(), dep_set.clone()))
.collect::<std::collections::BTreeMap<_, _>>(),
..Default::default()
}
}
/// The plan publishes in dependency order, and each base repo lists the
/// dependents to verify: znippy & skade before nornir; both list nornir.
#[test]
fn plan_orders_topologically_with_verify_targets() {
let graphs = [
graph("znippy", &["znippy-common"], &["serde"]),
graph("skade", &["skade-katalog"], &["arrow"]),
graph("nornir", &["nornir"], &["znippy-common", "skade-katalog"]),
];
let plan = plan_stage(
&graphs,
"sparse+http://h/sparring/index/",
"release/arrow-58",
);
assert!(plan.is_executable(), "clean DAG must be executable");
assert_eq!(plan.branch, "release/arrow-58");
let order: Vec<&str> = plan.order.iter().map(|s| s.repo.as_str()).collect();
let pos = |r: &str| order.iter().position(|x| *x == r).unwrap();
assert!(pos("znippy") < pos("nornir") && pos("skade") < pos("nornir"));
let znippy = plan.order.iter().find(|s| s.repo == "znippy").unwrap();
assert_eq!(
znippy.verify,
vec!["nornir"],
"publishing znippy must verify nornir"
);
let nornir = plan.order.iter().find(|s| s.repo == "nornir").unwrap();
assert!(nornir.verify.is_empty(), "nothing depends on nornir here");
}
#[test]
fn cargo_config_targets_sparring_and_replaces_crates_io() {
let c = cargo_config_toml("127.0.0.1:18464");
assert!(
c.contains("[registries.sparring]"),
"defines the publish registry"
);
assert!(c.contains("sparse+http://127.0.0.1:18464/sparring/index/"));
assert!(
c.contains(r#"replace-with = "sparring-mirror""#),
"resolves deps via /sparring"
);
}
#[test]
fn plan_with_cycle_is_not_executable() {
let graphs = [
graph("a", &["a-crate"], &["b-crate"]),
graph("b", &["b-crate"], &["a-crate"]),
];
let plan = plan_stage(&graphs, "reg", "rel");
assert!(!plan.is_executable(), "a cycle must block execution");
assert_eq!(plan.cycle.len(), 2);
}
/// plan_stage now records each repo's in-constellation dependencies so the cascade
/// can gate on them: nornir depends on znippy + skade; the bases depend on nothing.
#[test]
fn plan_records_in_constellation_deps() {
let graphs = [
graph("znippy", &["znippy-common"], &["serde"]),
graph("skade", &["skade-katalog"], &["arrow"]),
graph("nornir", &["nornir"], &["znippy-common", "skade-katalog"]),
];
let plan = plan_stage(&graphs, "reg", "rel");
let nornir = plan.order.iter().find(|s| s.repo == "nornir").unwrap();
assert_eq!(
nornir.deps,
vec!["skade", "znippy"],
"nornir gates on both bases"
);
let znippy = plan.order.iter().find(|s| s.repo == "znippy").unwrap();
assert!(znippy.deps.is_empty(), "a base repo gates on nothing");
}
fn step(repo: &str, deps: &[&str], verify: &[&str]) -> StageStep {
StageStep {
repo: repo.to_string(),
deps: deps.iter().map(|s| s.to_string()).collect(),
verify: verify.iter().map(|s| s.to_string()).collect(),
}
}
/// B depends on A: A is attempted first, and B only AFTER A has published.
#[test]
fn cascade_defers_dependent_until_dep_published() {
let order = [step("a", &[], &["b"]), step("b", &["a"], &[])];
let mut seen: Vec<String> = Vec::new();
let out = run_cascade(&order, |r| {
seen.push(r.to_string());
PublishAttempt::Published
});
assert_eq!(seen, ["a", "b"], "A must be attempted before B");
assert_eq!(out.published, ["a", "b"]);
assert!(out.errors.is_empty() && out.blocked.is_empty());
}
/// When A fails, B is NEVER attempted — it is reported blocked-by-dep, distinct
/// from A which is reported as its own failure in `errors`.
#[test]
fn cascade_blocks_dependent_when_dep_fails() {
let order = [step("a", &[], &["b"]), step("b", &["a"], &[])];
let mut attempted: Vec<String> = Vec::new();
let out = run_cascade(&order, |r| {
attempted.push(r.to_string());
if r == "a" {
PublishAttempt::Failed("boom".to_string())
} else {
PublishAttempt::Published
}
});
assert!(
!attempted.iter().any(|r| r == "b"),
"B must not be attempted once A fails"
);
assert!(out.published.is_empty());
assert!(
out.errors.iter().any(|e| e.starts_with("a:")),
"A is its own failure"
);
assert!(
out.blocked
.iter()
.any(|e| e.starts_with("b:") && e.contains("a")),
"B is reported blocked-by-failed-dependency a, not as a failure: {:?}",
out.blocked
);
}
/// A transient failure is RETRIED on a later pass and can then succeed.
#[test]
fn cascade_retries_transient_failure_then_succeeds() {
let order = [step("a", &[], &[])];
let mut calls = 0u32;
let out = run_cascade(&order, |_| {
calls += 1;
if calls == 1 {
PublishAttempt::Failed("transient".to_string())
} else {
PublishAttempt::Published
}
});
assert!(
calls >= 2,
"a transient failure must get another attempt (got {calls})"
);
assert_eq!(out.published, ["a"]);
assert!(out.errors.is_empty() && out.blocked.is_empty());
}
/// A permanently-failing repo converges to `errors` after MAX_ATTEMPTS — it does
/// NOT spin forever, and its attempts are bounded.
#[test]
fn cascade_bounds_retries_of_permanent_failure() {
let order = [step("a", &[], &[])];
let mut calls = 0u32;
let out = run_cascade(&order, |_| {
calls += 1;
PublishAttempt::Failed("always".to_string())
});
assert_eq!(calls, MAX_ATTEMPTS, "retries are bounded to MAX_ATTEMPTS");
assert!(out.errors.iter().any(|e| e.starts_with("a:")));
assert!(out.published.is_empty() && out.blocked.is_empty());
}
/// Idempotency: an already-staged dependency still SATISFIES its dependents' gate,
/// so the dependent proceeds (exactly as a re-run without a version bump would).
#[test]
fn cascade_already_staged_satisfies_dependents() {
let order = [step("a", &[], &["b"]), step("b", &["a"], &[])];
let out = run_cascade(&order, |r| {
if r == "a" {
PublishAttempt::AlreadyStaged
} else {
PublishAttempt::Published
}
});
assert_eq!(out.already_staged, ["a"]);
assert_eq!(
out.published,
["b"],
"an already-staged dep unblocks its dependent"
);
assert!(out.blocked.is_empty() && out.errors.is_empty());
}
/// A back-edge to a LATER repo in the order (a repo-level SCC that crate ordering
/// already untangled) is NOT a blocker — both repos still publish.
#[test]
fn cascade_ignores_back_edges_within_ordered_scc() {
// Order is [a, b]; b depends on a (forward, gating) AND a "depends on" b
// (back-edge — the SCC's other direction, already resolved by the order).
let order = [step("a", &["b"], &["b"]), step("b", &["a"], &[])];
let out = run_cascade(&order, |_| PublishAttempt::Published);
assert_eq!(
out.published,
["a", "b"],
"the back-edge must not deadlock the SCC"
);
assert!(out.blocked.is_empty() && out.errors.is_empty());
}
}