chore: new xtask `bump-check`

This is a rewrite of old `ci/validate-version-bump.sh` in Rust.
This commit is contained in:
Weihang Lo 2023-07-26 00:40:14 +01:00
parent c91a693e79
commit 9cca5721e4
No known key found for this signature in database
GPG Key ID: D7DBF189825E82E7
4 changed files with 457 additions and 0 deletions

13
Cargo.lock generated
View File

@ -3767,6 +3767,19 @@ dependencies = [
name = "xtask-build-man"
version = "0.0.0"
[[package]]
name = "xtask-bump-check"
version = "0.0.0"
dependencies = [
"anyhow",
"cargo",
"cargo-util",
"clap",
"env_logger 0.10.0",
"git2",
"log",
]
[[package]]
name = "xtask-stale-label"
version = "0.0.0"

View File

@ -0,0 +1,14 @@
[package]
name = "xtask-bump-check"
version = "0.0.0"
edition.workspace = true
publish = false
[dependencies]
anyhow.workspace = true
cargo.workspace = true
cargo-util.workspace = true
clap.workspace = true
env_logger.workspace = true
git2.workspace = true
log.workspace = true

View File

@ -0,0 +1,15 @@
mod xtask;
fn main() {
env_logger::init_from_env("CARGO_LOG");
let cli = xtask::cli();
let matches = cli.get_matches();
let mut config = cargo::util::config::Config::default().unwrap_or_else(|e| {
let mut eval = cargo::core::shell::Shell::new();
cargo::exit_with_error(e.into(), &mut eval)
});
if let Err(e) = xtask::exec(&matches, &mut config) {
cargo::exit_with_error(e, &mut config.shell())
}
}

View File

@ -0,0 +1,415 @@
//! ```text
//! NAME
//! xtask-bump-check
//!
//! SYNOPSIS
//! xtask-bump-check --base-rev <REV> --head-rev <REV>
//!
//! DESCRIPTION
//! Checks if there is any member got changed since a base commit
//! but forgot to bump its version.
//! ```
use std::collections::HashSet;
use std::fmt::Write;
use std::fs;
use std::task;
use cargo::core::dependency::Dependency;
use cargo::core::registry::PackageRegistry;
use cargo::core::Package;
use cargo::core::QueryKind;
use cargo::core::Registry;
use cargo::core::SourceId;
use cargo::core::Workspace;
use cargo::util::command_prelude::*;
use cargo::util::ToSemver;
use cargo::CargoResult;
use cargo_util::ProcessBuilder;
const UPSTREAM_BRANCH: &str = "master";
const STATUS: &str = "BumpCheck";
pub fn cli() -> clap::Command {
clap::Command::new("xtask-bump-check")
.arg(
opt(
"verbose",
"Use verbose output (-vv very verbose/build.rs output)",
)
.short('v')
.action(ArgAction::Count)
.global(true),
)
.arg_quiet()
.arg(
opt("color", "Coloring: auto, always, never")
.value_name("WHEN")
.global(true),
)
.arg(opt("base-rev", "Git revision to lookup for a baseline"))
.arg(opt("head-rev", "Git revision with changes"))
.arg(flag("frozen", "Require Cargo.lock and cache are up to date").global(true))
.arg(flag("locked", "Require Cargo.lock is up to date").global(true))
.arg(flag("offline", "Run without accessing the network").global(true))
.arg(multi_opt("config", "KEY=VALUE", "Override a configuration value").global(true))
.arg(
Arg::new("unstable-features")
.help("Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details")
.short('Z')
.value_name("FLAG")
.action(ArgAction::Append)
.global(true),
)
}
pub fn exec(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> cargo::CliResult {
config_configure(config, args)?;
bump_check(args, config)?;
Ok(())
}
fn config_configure(config: &mut Config, args: &ArgMatches) -> CliResult {
let verbose = args.verbose();
// quiet is unusual because it is redefined in some subcommands in order
// to provide custom help text.
let quiet = args.flag("quiet");
let color = args.get_one::<String>("color").map(String::as_str);
let frozen = args.flag("frozen");
let locked = args.flag("locked");
let offline = args.flag("offline");
let mut unstable_flags = vec![];
if let Some(values) = args.get_many::<String>("unstable-features") {
unstable_flags.extend(values.cloned());
}
let mut config_args = vec![];
if let Some(values) = args.get_many::<String>("config") {
config_args.extend(values.cloned());
}
config.configure(
verbose,
quiet,
color,
frozen,
locked,
offline,
&None,
&unstable_flags,
&config_args,
)?;
Ok(())
}
/// Main entry of `xtask-bump-check`.
///
/// Assumption: version number are incremental. We never have point release for old versions.
fn bump_check(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> CargoResult<()> {
let ws = args.workspace(config)?;
let repo = git2::Repository::open(ws.root())?;
let base_commit = get_base_commit(config, args, &repo)?;
let head_commit = get_head_commit(args, &repo)?;
let referenced_commit = get_referenced_commit(&repo, &base_commit)?;
let changed_members = changed(&ws, &repo, &base_commit, &head_commit)?;
let status = |msg: &str| config.shell().status(STATUS, msg);
status(&format!("base commit `{}`", base_commit.id()))?;
status(&format!("head commit `{}`", head_commit.id()))?;
let mut needs_bump = Vec::new();
check_crates_io(config, &changed_members, &mut needs_bump)?;
if let Some(referenced_commit) = referenced_commit.as_ref() {
status(&format!("compare against `{}`", referenced_commit.id()))?;
for referenced_member in checkout_ws(&ws, &repo, referenced_commit)?.members() {
let Some(changed_member) = changed_members.get(referenced_member) else {
let name = referenced_member.name().as_str();
log::trace!("skipping {name}, may be removed or not published");
continue;
};
if changed_member.version() <= referenced_member.version() {
needs_bump.push(*changed_member);
}
}
}
if !needs_bump.is_empty() {
needs_bump.sort();
needs_bump.dedup();
let mut msg = String::new();
msg.push_str("Detected changes in these crates but no version bump found:\n");
for pkg in needs_bump {
writeln!(&mut msg, " {}@{}", pkg.name(), pkg.version())?;
}
msg.push_str("\nPlease bump at least one patch version in each corresponding Cargo.toml.");
anyhow::bail!(msg)
}
// Tracked by https://github.com/obi1kenobi/cargo-semver-checks/issues/511
let exclude_args = [
"--exclude",
"cargo-credential-1password",
"--exclude",
"cargo-credential-gnome-secret",
"--exclude",
"cargo-credential-macos-keychain",
"--exclude",
"cargo-credential-wincred",
];
// Even when we test against baseline-rev, we still need to make sure a
// change doesn't violate SemVer rules aginst crates.io releases. The
// possibility of this happening is nearly zero but no harm to check twice.
let mut cmd = ProcessBuilder::new("cargo");
cmd.arg("semver-checks")
.arg("check-release")
.arg("--workspace")
.args(&exclude_args);
config.shell().status("Running", &cmd)?;
cmd.exec()?;
if let Some(referenced_commit) = referenced_commit.as_ref() {
let mut cmd = ProcessBuilder::new("cargo");
cmd.arg("semver-checks")
.arg("--workspace")
.arg("--baseline-rev")
.arg(referenced_commit.id().to_string())
.args(&exclude_args);
config.shell().status("Running", &cmd)?;
cmd.exec()?;
}
status("no version bump needed for member crates.")?;
return Ok(());
}
/// Returns the commit of upstream `master` branch if `base-rev` is missing.
fn get_base_commit<'a>(
config: &Config,
args: &clap::ArgMatches,
repo: &'a git2::Repository,
) -> CargoResult<git2::Commit<'a>> {
let base_commit = match args.get_one::<String>("base-rev") {
Some(sha) => {
let obj = repo.revparse_single(sha)?;
obj.peel_to_commit()?
}
None => {
let upstream_branches = repo
.branches(Some(git2::BranchType::Remote))?
.filter_map(|r| r.ok())
.filter(|(b, _)| {
b.name()
.ok()
.flatten()
.unwrap_or_default()
.ends_with(&format!("/{UPSTREAM_BRANCH}"))
})
.map(|(b, _)| b)
.collect::<Vec<_>>();
if upstream_branches.is_empty() {
anyhow::bail!(
"could not find `base-sha` for `{UPSTREAM_BRANCH}`, pass it in directly"
);
}
let upstream_ref = upstream_branches[0].get();
if upstream_branches.len() > 1 {
let name = upstream_ref.name().expect("name is valid UTF-8");
let _ = config.shell().warn(format!(
"multiple `{UPSTREAM_BRANCH}` found, picking {name}"
));
}
upstream_ref.peel_to_commit()?
}
};
Ok(base_commit)
}
/// Returns `HEAD` of the Git repository if `head-rev` is missing.
fn get_head_commit<'a>(
args: &clap::ArgMatches,
repo: &'a git2::Repository,
) -> CargoResult<git2::Commit<'a>> {
let head_commit = match args.get_one::<String>("head-rev") {
Some(sha) => {
let head_obj = repo.revparse_single(sha)?;
head_obj.peel_to_commit()?
}
None => {
let head_ref = repo.head()?;
head_ref.peel_to_commit()?
}
};
Ok(head_commit)
}
/// Gets the referenced commit to compare if version bump needed.
///
/// * When merging into nightly, check the version with beta branch
/// * When merging into beta, check the version with stable branch
/// * When merging into stable, check against crates.io registry directly
fn get_referenced_commit<'a>(
repo: &'a git2::Repository,
base: &git2::Commit<'a>,
) -> CargoResult<Option<git2::Commit<'a>>> {
let [beta, stable] = beta_and_stable_branch(&repo)?;
let rev_id = base.id();
let stable_commit = stable.get().peel_to_commit()?;
let beta_commit = beta.get().peel_to_commit()?;
let referenced_commit = if rev_id == stable_commit.id() {
None
} else if rev_id == beta_commit.id() {
log::trace!("stable branch from `{}`", stable.name().unwrap().unwrap());
Some(stable_commit)
} else {
log::trace!("beta branch from `{}`", beta.name().unwrap().unwrap());
Some(beta_commit)
};
Ok(referenced_commit)
}
/// Get the current beta and stable branch in cargo repository.
///
/// Assumptions:
///
/// * The repository contains the full history of `<remote>/rust-1.*.0` branches.
/// * The version part of `<remote>/rust-1.*.0` always ends with a zero.
/// * The maximum version is for beta channel, and the second one is for stable.
fn beta_and_stable_branch(repo: &git2::Repository) -> CargoResult<[git2::Branch<'_>; 2]> {
let mut release_branches = Vec::new();
for branch in repo.branches(Some(git2::BranchType::Remote))? {
let (branch, _) = branch?;
let name = branch.name()?.unwrap();
let Some((_, version)) = name.split_once("/rust-") else {
log::trace!("branch `{name}` is not in the format of `<remote>/rust-<semver>`");
continue;
};
let Ok(version) = version.to_semver() else {
log::trace!("branch `{name}` is not a valid semver: `{version}`");
continue;
};
release_branches.push((version, branch));
}
release_branches.sort_unstable_by(|a, b| a.0.cmp(&b.0));
release_branches.dedup_by(|a, b| a.0 == b.0);
let beta = release_branches.pop().unwrap();
let stable = release_branches.pop().unwrap();
assert_eq!(beta.0.major, 1);
assert_eq!(beta.0.patch, 0);
assert_eq!(stable.0.major, 1);
assert_eq!(stable.0.patch, 0);
assert_ne!(beta.0.minor, stable.0.minor);
Ok([beta.1, stable.1])
}
/// Lists all changed workspace members between two commits.
fn changed<'r, 'ws>(
ws: &'ws Workspace<'_>,
repo: &'r git2::Repository,
base_commit: &git2::Commit<'r>,
head: &git2::Commit<'r>,
) -> CargoResult<HashSet<&'ws Package>> {
let root_pkg_name = ws.current()?.name(); // `cargo` crate.
let ws_members = ws
.members()
.filter(|pkg| pkg.name() != root_pkg_name) // Only take care of sub crates here.
.filter(|pkg| pkg.publish() != &Some(vec![])) // filter out `publish = false`
.map(|pkg| {
// Having relative package root path so that we can compare with
// paths of changed files to determine which package has changed.
let relative_pkg_root = pkg.root().strip_prefix(ws.root()).unwrap();
(relative_pkg_root, pkg)
})
.collect::<Vec<_>>();
let base_tree = base_commit.as_object().peel_to_tree()?;
let head_tree = head.as_object().peel_to_tree()?;
let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Default::default())?;
let mut changed_members = HashSet::new();
for delta in diff.deltas() {
let old = delta.old_file().path().unwrap();
let new = delta.new_file().path().unwrap();
for (ref pkg_root, pkg) in ws_members.iter() {
if old.starts_with(pkg_root) || new.starts_with(pkg_root) {
changed_members.insert(*pkg);
break;
}
}
}
Ok(changed_members)
}
/// Compares version against published crates on crates.io.
///
/// Assumption: We always release a version larger than all existing versions.
fn check_crates_io<'a>(
config: &Config,
changed_members: &HashSet<&'a Package>,
needs_bump: &mut Vec<&'a Package>,
) -> CargoResult<()> {
let source_id = SourceId::crates_io(config)?;
let mut registry = PackageRegistry::new(config)?;
let _lock = config.acquire_package_cache_lock()?;
registry.lock_patches();
config.shell().status(
STATUS,
format_args!("compare against `{}`", source_id.display_registry_name()),
)?;
for member in changed_members {
let (name, current) = (member.name(), member.version());
let version_req = format!(">={current}");
let query = Dependency::parse(name, Some(&version_req), source_id)?;
let possibilities = loop {
// Exact to avoid returning all for path/git
match registry.query_vec(&query, QueryKind::Exact) {
task::Poll::Ready(res) => {
break res?;
}
task::Poll::Pending => registry.block_until_ready()?,
}
};
if possibilities.is_empty() {
log::trace!("dep `{name}` has no version greater than or equal to `{current}`");
} else {
needs_bump.push(member);
}
}
Ok(())
}
/// Checkouts a temporary workspace to do further version comparsions.
fn checkout_ws<'cfg, 'a>(
ws: &Workspace<'cfg>,
repo: &'a git2::Repository,
referenced_commit: &git2::Commit<'a>,
) -> CargoResult<Workspace<'cfg>> {
let repo_path = repo.path().as_os_str().to_str().unwrap();
// Put it under `target/cargo-<short-id>`
let short_id = &referenced_commit.id().to_string()[..7];
let checkout_path = ws.target_dir().join(format!("cargo-{short_id}"));
let checkout_path = checkout_path.as_path_unlocked();
let _ = fs::remove_dir_all(checkout_path);
let new_repo = git2::build::RepoBuilder::new()
.clone_local(git2::build::CloneLocal::Local)
.clone(repo_path, checkout_path)?;
let obj = new_repo.find_object(referenced_commit.id(), None)?;
new_repo.reset(&obj, git2::ResetType::Hard, None)?;
Workspace::new(&checkout_path.join("Cargo.toml"), ws.config())
}
#[test]
fn verify_cli() {
cli().debug_assert();
}