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 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)
}
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;