use std::path::Path;
use std::process::Command;
use anyhow::{anyhow, Context, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublishOutcome {
Published,
AlreadyPublished,
DryRun,
}
pub fn publish_all(
repo_root: &Path,
order: &[Vec<String>],
dry_run: bool,
) -> Result<Vec<(String, PublishOutcome)>> {
let derived;
let order: &[Vec<String>] = if order.iter().all(|phase| phase.is_empty()) {
derived = derive_publish_order(repo_root).with_context(|| {
"no `publish_order` configured and could not derive one from `cargo metadata`"
})?;
if derived.iter().all(|p| p.is_empty()) {
anyhow::bail!(
"nothing to publish: no publishable crates in the workspace at {} \
(all `publish = false`?). Set `publish_order` in nornir.toml to override.",
repo_root.display()
);
}
&derived
} else {
order
};
let mut out: Vec<(String, PublishOutcome)> = Vec::new();
for phase in order {
for krate in phase {
let outcome = run_cargo_publish(repo_root, krate, dry_run)
.with_context(|| format!("cargo publish -p {krate}"))?;
out.push((krate.clone(), outcome));
}
}
Ok(out)
}
pub fn derive_publish_order(repo_root: &Path) -> Result<Vec<Vec<String>>> {
let meta = cargo_metadata::MetadataCommand::new()
.current_dir(repo_root)
.exec()
.with_context(|| format!("cargo metadata in {}", repo_root.display()))?;
let ws: std::collections::BTreeSet<String> =
meta.workspace_packages().iter().map(|p| p.name.to_string()).collect();
let mut members: Vec<(String, Vec<String>)> = Vec::new();
for p in meta.workspace_packages() {
if matches!(&p.publish, Some(v) if v.is_empty()) {
continue;
}
let deps: Vec<String> = p
.dependencies
.iter()
.map(|d| d.name.clone())
.filter(|n| ws.contains(n))
.collect();
members.push((p.name.to_string(), deps));
}
Ok(topo_phases(members))
}
pub(crate) fn topo_phases(members: Vec<(String, Vec<String>)>) -> Vec<Vec<String>> {
use std::collections::{BTreeMap, BTreeSet};
let names: BTreeSet<String> = members.iter().map(|(n, _)| n.clone()).collect();
let mut indeg: BTreeMap<String, usize> = BTreeMap::new();
let mut dependents: BTreeMap<String, Vec<String>> = BTreeMap::new(); for (n, deps) in &members {
let real: Vec<&String> =
deps.iter().filter(|d| names.contains(*d) && d.as_str() != n).collect();
indeg.insert(n.clone(), real.len());
for d in real {
dependents.entry(d.clone()).or_default().push(n.clone());
}
}
let mut phases: Vec<Vec<String>> = Vec::new();
let mut remaining: BTreeSet<String> = names;
while !remaining.is_empty() {
let ready: Vec<String> = remaining
.iter()
.filter(|n| indeg.get(*n).copied().unwrap_or(0) == 0)
.cloned()
.collect();
if ready.is_empty() {
phases.push(remaining.iter().cloned().collect()); break;
}
for n in &ready {
remaining.remove(n);
if let Some(deps) = dependents.get(n) {
for dep in deps {
if let Some(e) = indeg.get_mut(dep) {
*e = e.saturating_sub(1);
}
}
}
}
phases.push(ready); }
phases
}
fn run_cargo_publish(repo_root: &Path, krate: &str, dry_run: bool) -> Result<PublishOutcome> {
let mut cmd = Command::new("cargo");
cmd.arg("publish").arg("-p").arg(krate);
if dry_run {
cmd.arg("--dry-run");
}
cmd.current_dir(repo_root);
let output = cmd.output().context("spawn cargo publish")?;
if output.status.success() {
return Ok(if dry_run {
PublishOutcome::DryRun
} else {
PublishOutcome::Published
});
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already uploaded")
|| stderr.contains("already exists on crates.io")
|| stderr.contains("crate version") && stderr.contains("is already uploaded")
{
eprintln!(" (skip: {krate} already on registry)");
return Ok(PublishOutcome::AlreadyPublished);
}
eprintln!("{stderr}");
Err(anyhow!(
"cargo publish -p {krate} exited {}",
output.status
))
}
pub fn commit_release(repo_root: &Path, message: &str) -> Result<Option<String>> {
use gix::bstr::BString;
use gix::dir::entry::{Kind as DiskKind, Status as DiskStatus};
use gix::status::index_worktree::Item as IwItem;
use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
let repo =
gix::open(repo_root).with_context(|| format!("gix::open {}", repo_root.display()))?;
let head_commit = repo
.head_commit()
.context("resolve HEAD commit (unborn branch is unsupported here)")?;
let head_id = head_commit.id;
let head_tree_id = head_commit.tree().context("resolve HEAD tree")?.id;
let iter = repo
.status(gix::progress::Discard)
.context("init status")?
.head_tree(head_tree_id)
.untracked_files(gix::status::UntrackedFiles::Files)
.into_iter(Vec::<BString>::new())
.context("start status walk")?;
let mut editor = repo
.edit_tree(head_tree_id)
.context("seed tree editor from HEAD")?;
let mut changed = false;
for item in iter {
match item.context("status item")? {
gix::status::Item::TreeIndex(change) => {
return Err(anyhow!(
"refusing to commit: index has a staged change at `{}`; \
commit or reset it before releasing",
change.location()
));
}
gix::status::Item::IndexWorktree(iw) => match iw {
IwItem::Modification {
rela_path, status, ..
} => match status {
EntryStatus::Change(Change::Removed) => {
let rp: &gix::bstr::BStr = rela_path.as_ref();
editor
.remove(rp)
.with_context(|| format!("tree remove {rela_path}"))?;
changed = true;
}
EntryStatus::Conflict { .. } => {
return Err(anyhow!(
"refusing to commit: unresolved merge conflict at `{rela_path}`"
));
}
_ => {
upsert_from_worktree(&repo, &mut editor, repo_root, rela_path.as_ref())?;
changed = true;
}
},
IwItem::DirectoryContents { entry, .. } => {
if matches!(entry.status, DiskStatus::Untracked)
&& matches!(
entry.disk_kind,
Some(DiskKind::File) | Some(DiskKind::Symlink)
)
{
upsert_from_worktree(
&repo,
&mut editor,
repo_root,
entry.rela_path.as_ref(),
)?;
changed = true;
}
}
IwItem::Rewrite { .. } => {}
},
}
}
if !changed {
return Ok(None);
}
let new_tree = editor.write().context("write release tree")?.detach();
if new_tree == head_tree_id {
return Ok(None);
}
let commit = repo
.commit("HEAD", message, new_tree, Some(head_id))
.context("create release commit")?;
let mut index = repo
.index_from_tree(&new_tree)
.context("rebuild index from release tree")?;
index
.write(gix::index::write::Options::default())
.context("persist refreshed index")?;
Ok(Some(commit.to_string()))
}
fn upsert_from_worktree(
repo: &gix::Repository,
editor: &mut gix::object::tree::Editor<'_>,
repo_root: &Path,
rela_path: &gix::bstr::BStr,
) -> Result<()> {
use gix::object::tree::EntryKind;
let disk_path = repo_root.join(gix::path::from_bstr(rela_path).as_ref());
let meta = std::fs::symlink_metadata(&disk_path)
.with_context(|| format!("stat {}", disk_path.display()))?;
let (bytes, kind): (Vec<u8>, EntryKind) = if meta.file_type().is_symlink() {
let target = std::fs::read_link(&disk_path)
.with_context(|| format!("readlink {}", disk_path.display()))?;
let target = gix::path::into_bstr(target).into_owned();
(target.into(), EntryKind::Link)
} else {
let bytes = std::fs::read(&disk_path)
.with_context(|| format!("read {}", disk_path.display()))?;
#[cfg(unix)]
let kind = {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o111 != 0 {
EntryKind::BlobExecutable
} else {
EntryKind::Blob
}
};
#[cfg(not(unix))]
let kind = EntryKind::Blob;
(bytes, kind)
};
let blob = repo.write_blob(&bytes).context("write blob")?;
editor
.upsert(rela_path, kind, blob.detach())
.with_context(|| format!("tree upsert {rela_path}"))?;
Ok(())
}
pub fn push(repo_root: &Path, push_tags: bool) -> Result<bool> {
let branch = crate::gitio::head_branch(repo_root).unwrap_or_else(|_| "HEAD".to_string());
eprintln!(" ⏭ push skipped (pure-Rust build has no in-process push).");
eprintln!(" run: git -C {} push origin {branch}", repo_root.display());
if push_tags {
eprintln!(" run: git -C {} push origin --tags", repo_root.display());
}
Ok(false)
}
pub fn tag(repo_root: &Path, version: &str) -> Result<()> {
let tag = format!("v{version}");
let repo = gix::open(repo_root)
.with_context(|| format!("gix::open {}", repo_root.display()))?;
let head_commit = repo.head_commit().context("resolve HEAD commit")?;
let message = format!("Release {tag}\n");
let signature = repo
.author()
.ok_or_else(|| anyhow!("git author not configured (user.name / user.email)"))?
.map_err(|e| anyhow!("read git author: {e}"))?;
repo.tag(
&tag,
head_commit.id,
gix::objs::Kind::Commit,
Some(signature),
&message,
gix::refs::transaction::PreviousValue::MustNotExist,
)
.with_context(|| format!("create tag {tag}"))?;
eprintln!(
"created local tag {tag} at {}. Push with: git push origin {tag}",
head_commit.id
);
Ok(())
}
pub fn wait_for_index(
crate_name: &str,
version: &str,
timeout: std::time::Duration,
) -> Result<u64> {
use std::time::{Duration, Instant};
let url = format!("https://crates.io/api/v1/crates/{crate_name}");
let agent = "nornir-release-bot/0.1 (cargo-pipeline; +https://crates.io)";
let started = Instant::now();
loop {
let out = Command::new("curl")
.args(["-sSL", "-A", agent, "--max-time", "10", &url])
.output()
.context("spawn curl for crates.io poll")?;
if out.status.success() {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
let max = json.get("crate")
.and_then(|c| c.get("max_version"))
.and_then(|v| v.as_str());
if max == Some(version) {
return Ok(started.elapsed().as_millis() as u64);
}
}
}
if started.elapsed() >= timeout {
return Err(anyhow!(
"timed out waiting for crates.io index: {crate_name}@{version} after {:?}",
timeout
));
}
std::thread::sleep(Duration::from_secs(3));
}
}
#[derive(Debug, Clone)]
pub struct TarballStats {
pub crate_name: String,
pub version: String,
pub tarball_bytes: u64,
pub file_count: usize,
pub largest_file: Option<String>,
pub largest_file_bytes: Option<u64>,
}
pub fn tarball_stats(repo_root: &Path, krate: &str) -> Result<TarballStats> {
use std::process::Command;
let pkg = Command::new("cargo")
.args(["package", "-p", krate, "--allow-dirty", "--no-verify"])
.current_dir(repo_root)
.output()
.context("spawn cargo package")?;
if !pkg.status.success() {
return Err(anyhow!(
"cargo package -p {krate} exited {}: {}",
pkg.status,
String::from_utf8_lossy(&pkg.stderr)
));
}
let target = repo_root.join("target").join("package");
let mut newest: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
if let Ok(rd) = std::fs::read_dir(&target) {
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("crate") { continue }
let fname = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if !fname.starts_with(&format!("{krate}-")) { continue }
let mtime = e.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH);
if newest.as_ref().map(|(_, t)| mtime > *t).unwrap_or(true) {
newest = Some((p, mtime));
}
}
}
let (path, _) = newest.ok_or_else(|| anyhow!("no .crate tarball found in {}", target.display()))?;
let tarball_bytes = std::fs::metadata(&path)?.len();
let fname = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
let version = fname
.strip_prefix(&format!("{krate}-"))
.and_then(|s| s.strip_suffix(".crate"))
.unwrap_or("?")
.to_string();
use flate2::read::GzDecoder;
use tar::Archive;
let f = std::fs::File::open(&path)?;
let dec = GzDecoder::new(f);
let mut ar = Archive::new(dec);
let mut file_count = 0usize;
let mut largest: Option<(String, u64)> = None;
for entry in ar.entries()? {
let entry = entry?;
let size = entry.header().size().unwrap_or(0);
let p = entry.path()?.into_owned();
file_count += 1;
if largest.as_ref().map(|(_, s)| size > *s).unwrap_or(true) {
largest = Some((p.display().to_string(), size));
}
}
Ok(TarballStats {
crate_name: krate.to_string(),
version,
tarball_bytes,
file_count,
largest_file: largest.as_ref().map(|(n, _)| n.clone()),
largest_file_bytes: largest.map(|(_, s)| s),
})
}
pub const DEFAULT_TARBALL_BYTES_THRESHOLD: u64 = 5 * 1024 * 1024;
#[cfg(test)]
mod tests {
use super::*;
fn m(n: &str, deps: &[&str]) -> (String, Vec<String>) {
(n.to_string(), deps.iter().map(|s| s.to_string()).collect())
}
#[test]
fn topo_phases_orders_deps_before_dependents_deterministically() {
let members = vec![
m("nornir", &["nornir-testmatrix", "nornir-robotui", "nornir-airgap"]),
m("nornir-robotui", &["nornir-testmatrix"]),
m("nornir-testmatrix", &[]),
m("nornir-airgap", &[]),
];
let phases = topo_phases(members);
let flat: Vec<&str> = phases.iter().flatten().map(String::as_str).collect();
let pos = |c: &str| flat.iter().position(|x| *x == c).unwrap();
assert!(pos("nornir-testmatrix") < pos("nornir-robotui"));
assert!(pos("nornir-robotui") < pos("nornir"));
assert!(pos("nornir-airgap") < pos("nornir"));
assert!(pos("nornir-testmatrix") < pos("nornir"));
assert_eq!(phases[0], vec!["nornir-airgap", "nornir-testmatrix"]);
assert_eq!(phases.last().unwrap(), &vec!["nornir".to_string()]);
}
#[test]
fn topo_phases_cycle_drops_nothing() {
let members = vec![m("a", &["b"]), m("b", &["a"])]; let phases = topo_phases(members);
let flat: std::collections::BTreeSet<&str> =
phases.iter().flatten().map(String::as_str).collect();
assert!(flat.contains("a") && flat.contains("b"), "cycle members not dropped");
}
#[test]
fn derive_publish_order_from_this_real_workspace() {
let order = derive_publish_order(std::path::Path::new(".")).expect("derive");
let flat: Vec<&str> = order.iter().flatten().map(String::as_str).collect();
let pos = |c: &str| flat.iter().position(|x| *x == c);
assert!(flat.contains(&"nornir"), "nornir is publishable: {flat:?}");
assert!(flat.contains(&"nornir-testmatrix"), "testmatrix is publishable: {flat:?}");
assert!(
pos("nornir-testmatrix") < pos("nornir"),
"testmatrix publishes before nornir: {flat:?}"
);
}
}