ci/verbs/src/deps.rs
//! `ci-verbs deps-update [--checkout <dir>] [--out <dir>] --author "<name> <email>"
//! [--date <yyyymmdd>]` (OSSF-NEXT): what a Renovate or Dependabot bot does, done by the
//! dwarves forge, which has no such bot (git.gunnar.rs runs no Actions and no app).
//!
//! * Every workspace lock of the checkout ([`lockfiles`]: no test fixture, no vendored
//! crate, no target) gets a plain `cargo update` (semver-compatible moves only: a major
//! bump is a person's change).
//! * A lock cargo cannot update fails the step and nothing is proposed (every lock is put
//! back as it was).
//! * If any lock moved, the moved locks are committed in the checkout (the author is
//! `--author`, the message lists every move and carries NO trailer: git.gunnar.rs
//! refuses AI trailers, and a bot is not a co-author), and `propose.json` names the
//! commit and a NEW branch `deps/update-<date>-<sha8>`.
//! * The pipeline's next step is the repository's own CI on that very tree. The forge
//! pushes the proposal ([`crate::deps`] writes it; dwarves' forge_ci pushes it) only
//! when the whole run went green, as a new branch, never forced, never merged: a person
//! merges it. A red run is the ordinary red line, and the page lists what moved.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Path, PathBuf};
/// Directories that hold no workspace lock of the repository's own.
const SKIP_DIRS: &[&str] = &["target", ".git", "fixtures", "testdata", "vendor", "node_modules"];
/// **The workspace locks** of a checkout, sorted.
pub fn lockfiles(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else { continue };
for e in rd.flatten() {
let p = e.path();
let n = e.file_name().to_string_lossy().into_owned();
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if !SKIP_DIRS.contains(&n.as_str()) {
stack.push(p);
}
} else if n == "Cargo.lock" {
out.push(p);
}
}
}
out.sort();
out
}
/// Every crate of a lock and its versions.
pub fn lock_versions(lock: &str) -> BTreeMap<String, BTreeSet<String>> {
let mut m: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut name: Option<String> = None;
for line in lock.lines() {
let l = line.trim();
if l == "[[package]]" {
name = None;
} else if let Some(v) = l.strip_prefix("name = ") {
name = Some(v.trim_matches('"').to_string());
} else if let Some(v) = l.strip_prefix("version = ") {
if let Some(n) = name.take() {
m.entry(n).or_default().insert(v.trim_matches('"').to_string());
}
}
}
m
}
/// One crate that moved.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Bump {
/// The crate.
pub name: String,
/// The versions it left.
pub from: Vec<String>,
/// The versions it took.
pub to: Vec<String>,
}
impl fmt::Display for Bump {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let side = |v: &[String], none: &str| if v.is_empty() { none.to_string() } else { v.join(", ") };
write!(f, "{} {} → {}", self.name, side(&self.from, "(new)"), side(&self.to, "(gone)"))
}
}
/// **What moved** between two locks, by crate name.
pub fn bumps(before: &BTreeMap<String, BTreeSet<String>>, after: &BTreeMap<String, BTreeSet<String>>) -> Vec<Bump> {
let empty = BTreeSet::new();
let names: BTreeSet<&String> = before.keys().chain(after.keys()).collect();
names
.into_iter()
.filter_map(|n| {
let b = before.get(n).unwrap_or(&empty);
let a = after.get(n).unwrap_or(&empty);
let from: Vec<String> = b.difference(a).cloned().collect();
let to: Vec<String> = a.difference(b).cloned().collect();
(!from.is_empty() || !to.is_empty()).then(|| Bump { name: n.clone(), from, to })
})
.collect()
}
/// The proposal's branch: new every time (the date and the commit).
pub fn branch_name(date: &str, sha: &str) -> String {
format!("deps/update-{date}-{}", sha.get(..8).unwrap_or(sha))
}
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// **The commit message**: a count line, then every move per lock. No trailer.
pub fn commit_message(per_lock: &[(String, Vec<Bump>)]) -> String {
let moved: Vec<&(String, Vec<Bump>)> = per_lock.iter().filter(|(_, b)| !b.is_empty()).collect();
let crates: usize = moved.iter().map(|(_, b)| b.len()).sum();
let mut m = format!(
"deps: cargo update ({} {} in {})\n\nThe dwarves forge's weekly dependency update: `cargo update` in every workspace lock\n(semver-compatible moves only), tested by the repository's own CI in the same run.\n",
plural(crates, "crate", "crates"),
if crates == 1 { "moves" } else { "move" },
plural(moved.len(), "lock", "locks")
);
for (lock, b) in moved {
m.push_str(&format!("\n{lock}:\n"));
for x in b {
m.push_str(&format!(" {x}\n"));
}
}
m
}
/// Today as yyyymmdd (UTC), from the clock alone.
pub fn today() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
let z = secs.div_euclid(86_400) + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}{m:02}{d:02}")
}
fn env_dir(k: &str) -> Option<PathBuf> {
std::env::var(k).ok().filter(|v| !v.is_empty()).map(PathBuf::from)
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let o = std::process::Command::new("git").arg("-C").arg(dir).args(args).stdin(std::process::Stdio::null()).output().map_err(|e| format!("git: {e}"))?;
if o.status.success() {
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&o.stderr).trim()))
}
}
/// The verb.
pub fn verb(args: &[String]) -> i32 {
let v = |f: &str| crate::value(args, f);
let (Some(checkout), Some(author)) = (v("--checkout").map(PathBuf::from).or_else(|| env_dir("DWARVES_CI_CHECKOUT")), v("--author")) else {
eprintln!("usage: ci-verbs deps-update --checkout <dir> --author \"<name> <email>\" [--out <dir>] [--date <yyyymmdd>]");
return 2;
};
let Some((name, email)) = author.rsplit_once(" <").map(|(n, e)| (n.trim().to_string(), e.trim_end_matches('>').to_string())) else {
eprintln!("ci-verbs deps-update: --author is \"<name> <email>\", not `{author}`");
return 2;
};
let checkout = checkout.canonicalize().unwrap_or(checkout);
let repo = checkout.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let out = v("--out").map(PathBuf::from).or_else(|| env_dir("NORNIR_CI_OUT")).unwrap_or_else(|| PathBuf::from("."));
if let Err(e) = std::fs::create_dir_all(&out) {
eprintln!("ci-verbs deps-update: {}: {e}", out.display());
return 1;
}
let date = v("--date").unwrap_or_else(today);
let base = git(&checkout, &["rev-parse", "HEAD"]).unwrap_or_default();
let mut per_lock: Vec<(String, Vec<Bump>)> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for lock in lockfiles(&checkout) {
let rel = lock.strip_prefix(&checkout).unwrap_or(&lock).display().to_string();
let before = std::fs::read_to_string(&lock).unwrap_or_default();
let dir = lock.parent().unwrap_or(&checkout);
println!("ci-verbs deps-update: cargo update in {}", dir.display());
let why = match std::process::Command::new("cargo").arg("update").current_dir(dir).stdin(std::process::Stdio::null()).status() {
Ok(s) if s.success() => None,
Ok(s) => Some(format!("{rel}: cargo update exited {s}")),
Err(e) => Some(format!("{rel}: {e}")),
};
if let Some(why) = why {
// a lock cargo could not update is put back as it was and proposes nothing
let _ = std::fs::write(&lock, &before);
failed.push(why);
continue;
}
let after = std::fs::read_to_string(&lock).unwrap_or_default();
per_lock.push((rel, bumps(&lock_versions(&before), &lock_versions(&after))));
}
let moved: Vec<&(String, Vec<Bump>)> = per_lock.iter().filter(|(_, b)| !b.is_empty()).collect();
let mut md = format!("Dependency update (cargo update, every workspace lock) on `{repo}` at `{}`: ", base.get(..8).unwrap_or(&base));
if !failed.is_empty() {
md.push_str("**no proposal**: a lock could not be updated (below); every lock is as it was.\n");
for (lock, _) in &moved {
let _ = git(&checkout, &["checkout", "--", lock]);
}
} else if moved.is_empty() {
md.push_str("**nothing to propose**: every lock is current.\n");
} else {
let files: Vec<&str> = moved.iter().map(|(l, _)| l.as_str()).collect();
let mut add = vec!["add", "--"];
add.extend(files.iter().copied());
let msg = commit_message(&per_lock);
let commit = git(&checkout, &add).and_then(|_| {
let un = format!("user.name={name}");
let ue = format!("user.email={email}");
git(&checkout, &["-c", &un, "-c", &ue, "commit", "-q", "--no-verify", "-m", &msg]).and_then(|_| git(&checkout, &["rev-parse", "HEAD"]))
});
match commit {
Ok(sha) => {
let branch = branch_name(&date, &sha);
let p = serde_json::json!({ "branch": branch, "commit": sha, "base": base });
let _ = std::fs::write(out.join("propose.json"), serde_json::to_vec_pretty(&p).unwrap_or_default());
md.push_str(&format!(
"**{} proposed** as `{branch}` (commit `{}`), pushed only if this run's CI step is green.\n",
plural(moved.iter().map(|(_, b)| b.len()).sum(), "move", "moves"),
sha.get(..8).unwrap_or(&sha)
));
}
Err(e) => failed.push(format!("commit: {e}")),
}
for (lock, b) in &moved {
md.push_str(&format!("\n`{lock}`:\n\n"));
for x in b {
md.push_str(&format!("- {x}\n"));
}
}
}
for f in &failed {
md.push_str(&format!("\nFAILED: {f}\n"));
}
let _ = std::fs::write(out.join("summary.md"), &md);
print!("{md}");
if failed.is_empty() {
0
} else {
1
}
}