use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use crate::release::cargo::{self, BumpLevel};
use crate::release::doctor;
use crate::release::publish::{self, PublishOutcome};
use crate::release::stage::BranchGuard;
use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::release_changes::ChangeRecorder;
#[derive(Debug, Clone)]
pub struct PromoteOpts {
pub bump: Option<BumpLevel>,
pub wait_index: bool,
pub dry_run: bool,
pub branch: String,
pub wait_secs: u64,
}
impl Default for PromoteOpts {
fn default() -> Self {
PromoteOpts {
bump: Some(BumpLevel::Patch),
wait_index: true,
dry_run: true,
branch: "release/promote".to_string(),
wait_secs: 300,
}
}
}
#[derive(Debug, Clone)]
pub struct PlannedBump {
pub crate_name: String,
pub old_version: String,
pub new_version: String,
}
#[derive(Debug, Default)]
pub struct PromoteReport {
pub bumps: Vec<PlannedBump>,
pub held: Vec<String>,
pub published: Vec<(String, PublishOutcome)>,
pub waited: Vec<(String, u64)>,
pub errors: Vec<String>,
pub dry_run: bool,
}
fn git(path: &Path, args: &[&str]) -> Result<()> {
let st = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.with_context(|| format!("git {}", args.join(" ")))?;
if !st.success() {
anyhow::bail!("git {} failed in {}", args.join(" "), path.display());
}
Ok(())
}
fn git_head(path: &Path) -> String {
Command::new("git")
.arg("-C")
.arg(path)
.args(["rev-parse", "HEAD"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
pub fn run(
repo_root: &Path,
gate_repos: &[(String, PathBuf)],
repo_name: &str,
opts: &PromoteOpts,
recorder: Option<(&IcebergWarehouse, &ChangeRecorder)>,
) -> Result<PromoteReport> {
let mut report = PromoteReport { dry_run: opts.dry_run, ..Default::default() };
let block = doctor::compute_promote_block(
gate_repos.iter().map(|(n, p)| (n.clone(), p.as_path())),
);
report.held = block.blocked.iter().cloned().collect();
let all_pub = cargo::publishable_crate_versions(repo_root)?;
let publishable: std::collections::BTreeSet<String> = all_pub
.iter()
.map(|(n, _)| n.clone())
.filter(|n| !block.blocked.contains(n))
.collect();
let order = publish::derive_publish_order(repo_root).unwrap_or_default();
let mut filtered: Vec<Vec<String>> = order
.iter()
.map(|phase| {
phase.iter().filter(|k| publishable.contains(*k)).cloned().collect::<Vec<_>>()
})
.filter(|p: &Vec<String>| !p.is_empty())
.collect();
if filtered.is_empty() && !publishable.is_empty() {
filtered = vec![publishable.iter().cloned().collect()];
}
let mut next_ver: BTreeMap<String, String> = BTreeMap::new();
if let Some(level) = opts.bump {
for (name, cur) in &all_pub {
if !publishable.contains(name) {
continue;
}
let nv = cargo::next_version(cur, level)?;
next_ver.insert(name.clone(), nv.clone());
report.bumps.push(PlannedBump {
crate_name: name.clone(),
old_version: cur.clone(),
new_version: nv,
});
}
}
if opts.dry_run {
for phase in &filtered {
for krate in phase {
report.published.push((krate.clone(), PublishOutcome::DryRun));
}
}
return Ok(report);
}
if !report.bumps.is_empty() {
let level = opts.bump.expect("bumps non-empty ⇒ a level was set");
let ws = cargo::workspace_package_version(repo_root)?;
let unified = ws
.as_ref()
.filter(|(_, old)| report.bumps.iter().all(|b| &b.old_version == old));
if let Some((_, old_ws)) = unified {
let new_ws = cargo::next_version(old_ws, level)?;
let edited = cargo::bump_workspace_version(repo_root, old_ws, &new_ws)
.with_context(|| format!("bump workspace version {old_ws} → {new_ws}"))?;
if let Some((wh, rec)) = recorder {
crate::release::undo::record_applied_bump(
wh,
rec,
repo_name,
"[workspace.package]",
old_ws,
&new_ws,
&edited,
)
.context("record workspace bump for undo")?;
}
} else {
for b in &report.bumps {
let plan =
cargo::plan_version_bump(repo_root, &b.crate_name, &b.new_version, false)
.with_context(|| format!("plan bump {} → {}", b.crate_name, b.new_version))?;
if plan.edits.is_empty() {
continue;
}
if let Some((wh, rec)) = recorder {
crate::release::undo::record_bump(wh, rec, repo_name, &plan)
.context("record bump for undo")?;
} else {
cargo::apply_bump_plan(&plan)
.with_context(|| format!("apply bump {} → {}", b.crate_name, b.new_version))?;
}
}
}
git(repo_root, &["add", "-A"])?;
git(
repo_root,
&["commit", "-q", "-m", "chore(release): bump versions for crates.io promote"],
)?;
}
let mut guard = BranchGuard::new(&opts.branch);
guard.capture(repo_root);
let prev_head = git_head(repo_root);
git(repo_root, &["checkout", "-B", &opts.branch])?;
if let Some((wh, rec)) = recorder {
let _ = crate::release::undo::record_branch(wh, rec, repo_name, &opts.branch, &prev_head);
}
let (files, blocks) = crate::release::cargo::ensure_no_patch_crates_io(repo_root)?;
if blocks > 0 {
git(repo_root, &["add", "-A"])?;
git(
repo_root,
&["commit", "-q", "-m", "chore(release): strip [patch.crates-io] for publish (ephemeral)"],
)?;
eprintln!("promote: stripped {blocks} [patch.crates-io] block(s) across {files} Cargo.toml(s) (ephemeral branch)");
}
let registry = "crates.io";
for phase in &filtered {
for krate in phase {
match publish::run_cargo_publish(repo_root, krate, false) {
Ok(outcome) => {
report.published.push((krate.clone(), outcome.clone()));
let ver = next_ver.get(krate).cloned();
if matches!(outcome, PublishOutcome::Published) {
if let (Some((wh, rec)), Some(v)) = (recorder, ver.as_ref()) {
crate::release::undo::record_publish(
wh, rec, repo_name, krate, v, registry, true,
)
.context("record publish for undo")?;
}
if opts.wait_index {
if let Some(v) = ver.as_ref() {
match publish::wait_for_index(
krate,
v,
std::time::Duration::from_secs(opts.wait_secs),
) {
Ok(ms) => report.waited.push((krate.clone(), ms)),
Err(e) => report
.errors
.push(format!("{krate}: wait-for-index: {e}")),
}
}
}
}
}
Err(e) => report.errors.push(format!("{krate}: {e}")),
}
}
}
Ok(report)
}
pub fn format_report(r: &PromoteReport) -> String {
let mut s = String::new();
let head = if r.dry_run { "nornir release promote — PLAN (dry-run)" } else { "nornir release promote — executed" };
s.push_str(head);
s.push('\n');
if !r.bumps.is_empty() {
s.push_str("\n Version bumps:\n");
for b in &r.bumps {
s.push_str(&format!(" {} {} → {}\n", b.crate_name, b.old_version, b.new_version));
}
}
if !r.held.is_empty() {
s.push_str(&format!("\n ⏸ held from crates.io ({}): {}\n", r.held.len(), r.held.join(", ")));
}
s.push_str("\n Publish (deps-first):\n");
for (k, o) in &r.published {
s.push_str(&format!(" {k:40} {o:?}\n"));
}
if !r.waited.is_empty() {
s.push_str("\n Indexed:\n");
for (k, ms) in &r.waited {
s.push_str(&format!(" {k} visible after {ms} ms\n"));
}
}
if r.errors.is_empty() {
if r.dry_run {
s.push_str("\n → plan only; re-run without --dry-run to bump + publish to crates.io.\n");
} else {
s.push_str("\n ✅ promote complete (working tree restored; `release undo` reverses bump + yanks).\n");
}
} else {
for e in &r.errors {
s.push_str(&format!(" ⚠ {e}\n"));
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_version_levels() {
use crate::release::cargo::{next_version, BumpLevel};
assert_eq!(next_version("1.2.3", BumpLevel::Patch).unwrap(), "1.2.4");
assert_eq!(next_version("1.2.3", BumpLevel::Minor).unwrap(), "1.3.0");
assert_eq!(next_version("1.2.3", BumpLevel::Major).unwrap(), "2.0.0");
assert_eq!(next_version("0.4.52", BumpLevel::Patch).unwrap(), "0.4.53");
assert_eq!(next_version("1.0.0-rc.1", BumpLevel::Patch).unwrap(), "1.0.1");
}
#[test]
fn dry_run_plans_bumps_and_holds_without_mutating() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"solo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
let opts = PromoteOpts { dry_run: true, wait_index: false, ..Default::default() };
let gate = vec![("solo".to_string(), root.to_path_buf())];
let report = run(root, &gate, "solo", &opts, None).unwrap();
assert!(report.dry_run);
assert!(report.held.is_empty(), "no forks → nothing held");
let bump = report.bumps.iter().find(|b| b.crate_name == "solo").unwrap();
assert_eq!(bump.new_version, "0.1.1");
let txt = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert!(txt.contains("version = \"0.1.0\""), "dry-run left version unchanged");
}
}