Skip to content

Commit 5417eac

Browse files
committed
chore: new xtask bump-check
This is a rewrite of old `ci/validate-version-bump.sh` in Rust.
1 parent e2fbcd9 commit 5417eac

File tree

3 files changed

+299
-0
lines changed

3 files changed

+299
-0
lines changed

crates/xtask-bump-check/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "xtask-bump-check"
3+
version = "0.0.0"
4+
edition.workspace = true
5+
publish = false
6+
7+
[dependencies]
8+
anyhow.workspace = true
9+
cargo.workspace = true
10+
clap.workspace = true
11+
env_logger.workspace = true
12+
git2.workspace = true
13+
log.workspace = true

crates/xtask-bump-check/src/main.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
mod xtask;
2+
3+
fn main() {
4+
env_logger::init_from_env("CARGO_LOG");
5+
let cli = xtask::cli();
6+
let matches = cli.get_matches();
7+
8+
let mut config = cargo::util::config::Config::default().unwrap_or_else(|e| {
9+
let mut eval = cargo::core::shell::Shell::new();
10+
cargo::exit_with_error(e.into(), &mut eval)
11+
});
12+
if let Err(e) = xtask::exec(&matches, &mut config) {
13+
cargo::exit_with_error(e, &mut config.shell())
14+
}
15+
}

crates/xtask-bump-check/src/xtask.rs

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
//! ```text
2+
//! NAME
3+
//! xtask-bump-check
4+
//!
5+
//! SYNOPSIS
6+
//! xtask-bump-check --baseline-rev <REV>
7+
//!
8+
//! DESCRIPTION
9+
//! Checks if there is any member got changed since a base commit
10+
//! but forgot to bump its version.
11+
//! ```
12+
13+
use std::collections::HashMap;
14+
use std::fmt::Write;
15+
use std::fs;
16+
use std::task;
17+
18+
use cargo::core::dependency::Dependency;
19+
use cargo::core::registry::PackageRegistry;
20+
use cargo::core::Package;
21+
use cargo::core::QueryKind;
22+
use cargo::core::Registry;
23+
use cargo::core::SourceId;
24+
use cargo::core::Workspace;
25+
use cargo::util::command_prelude::*;
26+
use cargo::util::ToSemver;
27+
use cargo::CargoResult;
28+
29+
pub fn cli() -> clap::Command {
30+
clap::Command::new("xtask-bump-check")
31+
.arg(
32+
opt(
33+
"verbose",
34+
"Use verbose output (-vv very verbose/build.rs output)",
35+
)
36+
.short('v')
37+
.action(ArgAction::Count)
38+
.global(true),
39+
)
40+
.arg_quiet()
41+
.arg(
42+
opt("color", "Coloring: auto, always, never")
43+
.value_name("WHEN")
44+
.global(true),
45+
)
46+
.arg(
47+
opt("baseline-rev", "Git revision to lookup for a baseline")
48+
.action(ArgAction::Set)
49+
.required(true),
50+
)
51+
.arg(flag("frozen", "Require Cargo.lock and cache are up to date").global(true))
52+
.arg(flag("locked", "Require Cargo.lock is up to date").global(true))
53+
.arg(flag("offline", "Run without accessing the network").global(true))
54+
.arg(multi_opt("config", "KEY=VALUE", "Override a configuration value").global(true))
55+
.arg(
56+
Arg::new("unstable-features")
57+
.help("Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details")
58+
.short('Z')
59+
.value_name("FLAG")
60+
.action(ArgAction::Append)
61+
.global(true),
62+
)
63+
}
64+
65+
pub fn exec(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> cargo::CliResult {
66+
config_configure(config, args)?;
67+
68+
bump_check(args, config)?;
69+
70+
Ok(())
71+
}
72+
73+
fn config_configure(config: &mut Config, args: &ArgMatches) -> CliResult {
74+
let verbose = args.verbose();
75+
// quiet is unusual because it is redefined in some subcommands in order
76+
// to provide custom help text.
77+
let quiet = args.flag("quiet");
78+
let color = args.get_one::<String>("color").map(String::as_str);
79+
let frozen = args.flag("frozen");
80+
let locked = args.flag("locked");
81+
let offline = args.flag("offline");
82+
let mut unstable_flags = vec![];
83+
if let Some(values) = args.get_many::<String>("unstable-features") {
84+
unstable_flags.extend(values.cloned());
85+
}
86+
let mut config_args = vec![];
87+
if let Some(values) = args.get_many::<String>("config") {
88+
config_args.extend(values.cloned());
89+
}
90+
config.configure(
91+
verbose,
92+
quiet,
93+
color,
94+
frozen,
95+
locked,
96+
offline,
97+
&None,
98+
&unstable_flags,
99+
&config_args,
100+
)?;
101+
Ok(())
102+
}
103+
104+
/// Turns an arg into a commit object.
105+
fn arg_to_commit<'a>(
106+
args: &clap::ArgMatches,
107+
repo: &'a git2::Repository,
108+
name: &str,
109+
) -> CargoResult<git2::Commit<'a>> {
110+
let arg = args.get_one::<String>(name).map(String::as_str).unwrap();
111+
Ok(repo.revparse_single(arg)?.peel_to_commit()?)
112+
}
113+
114+
/// Checkouts a temporary workspace to do further version comparsions.
115+
fn checkout_ws<'cfg, 'a>(
116+
ws: &Workspace<'cfg>,
117+
repo: &'a git2::Repository,
118+
referenced_commit: &git2::Commit<'a>,
119+
) -> CargoResult<Workspace<'cfg>> {
120+
let repo_path = repo.path().as_os_str().to_str().unwrap();
121+
// Put it under `target/cargo-<short-id>`
122+
let short_id = &referenced_commit.id().to_string()[..7];
123+
let checkout_path = ws.target_dir().join(format!("cargo-{short_id}"));
124+
let checkout_path = checkout_path.as_path_unlocked();
125+
let _ = fs::remove_dir_all(checkout_path);
126+
let new_repo = git2::build::RepoBuilder::new()
127+
.clone_local(git2::build::CloneLocal::Local)
128+
.clone(repo_path, checkout_path)
129+
.unwrap();
130+
let obj = new_repo.find_object(referenced_commit.id(), None)?;
131+
new_repo.reset(&obj, git2::ResetType::Hard, None)?;
132+
Workspace::new(&checkout_path.join("Cargo.toml"), ws.config())
133+
}
134+
135+
/// Get the current beta and stable branch in cargo repository.
136+
///
137+
/// Assumptions:
138+
///
139+
/// * The repository contains the full history of `origin/rust-1.*.0` branches.
140+
/// * The version part of `origin/rust-1.*.0` always ends with a zero.
141+
/// * The maximum version is for beta channel, and the second one is for stable.
142+
fn beta_and_stable_branch(repo: &git2::Repository) -> CargoResult<[git2::Branch<'_>; 2]> {
143+
let mut release_branches = Vec::new();
144+
for branch in repo.branches(Some(git2::BranchType::Remote))? {
145+
let (branch, _) = branch?;
146+
let Some(version) = branch.name()?.and_then(|n| n.strip_prefix("origin/rust-")) else {
147+
continue;
148+
};
149+
release_branches.push((version.to_semver()?, branch));
150+
}
151+
release_branches.sort_unstable_by(|a, b| a.0.cmp(&b.0));
152+
153+
let beta = release_branches.pop().unwrap();
154+
let stable = release_branches.pop().unwrap();
155+
156+
assert_eq!(beta.0.major, 1);
157+
assert_eq!(beta.0.patch, 0);
158+
assert_eq!(stable.0.major, 1);
159+
assert_eq!(stable.0.patch, 0);
160+
assert_ne!(beta.0.minor, stable.0.minor);
161+
162+
Ok([beta.1, stable.1])
163+
}
164+
165+
/// Gets the referenced commit to compare if version bump needed.
166+
///
167+
/// * When merging into nightly, check the version with beta branch
168+
/// * When merging into beta, check the version with stable branch
169+
/// * When merging into stable, check against crates.io registry directly
170+
fn get_referenced_commit<'a>(
171+
repo: &'a git2::Repository,
172+
base: &git2::Commit<'a>,
173+
) -> CargoResult<Option<git2::Commit<'a>>> {
174+
let [beta, stable] = beta_and_stable_branch(&repo)?;
175+
let rev_id = base.id();
176+
let stable_commit = stable.get().peel_to_commit()?;
177+
let beta_commit = beta.get().peel_to_commit()?;
178+
179+
let commit = if rev_id == stable_commit.id() {
180+
None
181+
} else if rev_id == beta_commit.id() {
182+
Some(stable_commit)
183+
} else {
184+
Some(beta_commit)
185+
};
186+
187+
Ok(commit)
188+
}
189+
190+
/// Main entry of `xtask-bump-check`.
191+
///
192+
/// Assumption: version number are incremental. We never have point release for old versions.
193+
fn bump_check(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> CargoResult<()> {
194+
let ws = args.workspace(config)?;
195+
let repo = git2::Repository::open(ws.root()).unwrap();
196+
let base_commit = arg_to_commit(args, &repo, "baseline-rev")?;
197+
let referenced_commit = get_referenced_commit(&repo, &base_commit)?;
198+
let published_members = HashMap::<&str, &Package>::from_iter(
199+
ws.members()
200+
.filter(|m| m.publish() != &Some(vec![])) // package.publish = false
201+
.map(|m| (m.name().as_str(), m)),
202+
);
203+
204+
let mut needs_bump = Vec::new();
205+
206+
if let Some(commit) = referenced_commit.as_ref() {
207+
config.shell().status(
208+
"BumpCheck",
209+
format_args!("compare aginst `{}`", commit.id()),
210+
)?;
211+
for member in checkout_ws(&ws, &repo, commit)?.members() {
212+
let name = member.name().as_str();
213+
let Some(changed) = published_members.get(name) else {
214+
log::trace!("skpping {name}, may be removed or not published");
215+
continue;
216+
};
217+
218+
if changed.version() <= member.version() {
219+
needs_bump.push(*changed);
220+
}
221+
}
222+
} else {
223+
let source_id = SourceId::crates_io(config)?;
224+
let mut registry = PackageRegistry::new(config)?;
225+
let _lock = config.acquire_package_cache_lock()?;
226+
registry.lock_patches();
227+
config.shell().status(
228+
"BumpCheck",
229+
format_args!("compare against `{}`", source_id.display_registry_name()),
230+
)?;
231+
for member in published_members.values() {
232+
let name = member.name();
233+
let current = member.version();
234+
let version_req = format!("<={current}");
235+
let query = Dependency::parse(name, Some(&version_req), source_id)?;
236+
let possibilities = loop {
237+
// Exact to avoid returning all for path/git
238+
match registry.query_vec(&query, QueryKind::Exact) {
239+
task::Poll::Ready(res) => {
240+
break res?;
241+
}
242+
task::Poll::Pending => registry.block_until_ready()?,
243+
}
244+
};
245+
let max_version = possibilities.iter().map(|s| s.version()).max();
246+
if max_version >= Some(current) {
247+
needs_bump.push(member);
248+
}
249+
}
250+
}
251+
252+
if needs_bump.is_empty() {
253+
config
254+
.shell()
255+
.status("BumpCheck", "no version bump needed for member crates.")?;
256+
return Ok(());
257+
}
258+
259+
let mut msg = String::new();
260+
msg.push_str("Detected changes in these crates but no version bump found:\n");
261+
for pkg in needs_bump {
262+
writeln!(&mut msg, " {}@{}", pkg.name(), pkg.version())?;
263+
}
264+
msg.push_str("\nPlease bump at least one patch version in each corresponding Cargo.toml.");
265+
anyhow::bail!(msg)
266+
}
267+
268+
#[test]
269+
fn verify_cli() {
270+
cli().debug_assert();
271+
}

0 commit comments

Comments
 (0)