Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 291 additions & 31 deletions xtask/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,324 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use clap::{Parser, ValueEnum};
use std::{
fs,
path::{Path, PathBuf},
process::Command,
};

use clap::{
Parser, ValueEnum,
builder::{PossibleValuesParser, TypedValueParser},
};
use regex::Regex;
use semver::{Prerelease, Version};
use std::fs;

#[derive(Parser)]
#[command(name = "xtask")]
#[command(about = "build tasks")]
enum Xtask {
#[command(about = "bump the global version number")]
#[command(about = "bump the global version number and open a pull request")]
#[command(arg_required_else_help = true)]
Bump {
#[clap(long)]
#[clap(long, help = "Allow non-main git branch or dirty tree")]
dirty: bool,
#[clap(value_parser = bump_place_parser())]
place: VersionPlace,
},
#[command(about = "tag a release and bump the global version number")]
#[command(arg_required_else_help = true)]
Release {
#[clap(long, help = "Allow non-main git branch or dirty tree")]
dirty: bool,
place: VersionPlace,
},
}

#[derive(Clone, ValueEnum)]
#[derive(Clone, PartialEq, ValueEnum)]
enum VersionPlace {
Minor,
Major,
Minor,
Patch,
Pre,
}

fn main() -> Result<(), String> {
let xtask = Xtask::parse();
fn bump_place_parser() -> impl TypedValueParser<Value = VersionPlace> {
PossibleValuesParser::new(["major", "minor", "patch"]).map(|place| match place.as_str() {
"major" => VersionPlace::Major,
"minor" => VersionPlace::Minor,
"patch" => VersionPlace::Patch,
_ => unreachable!("parser only accepts major, minor, or patch"),
})
}

fn main() {
if let Err(err) = run() {
eprintln!("error: {}", err);
std::process::exit(1);
}
}

fn run() -> Result<(), String> {
match Xtask::parse() {
Xtask::Bump { dirty, place } => bump(&place, dirty),
Xtask::Release { dirty, place } => release(&place, dirty),
}
}

fn bump(place: &VersionPlace, dirty: bool) -> Result<(), String> {
let root_path = workspace_root();
ensure_release_state(&root_path, dirty)?;

let old_version = read_workspace_version(&root_path)?;
let bump_result = bump_on_pr_branch(&root_path, &old_version, place)?;

let undo_command = [
format!("git checkout {}", shell_quote(&bump_result.original_branch)),
format!("git branch -D {}", shell_quote(&bump_result.bump_branch)),
]
.join(" && ");
let publish_command = [bump_result.push_cmd, bump_result.pr_cmd].join(" && \\\n ");

print_next_steps(&undo_command, &publish_command);

Ok(())
}

fn release(place: &VersionPlace, dirty: bool) -> Result<(), String> {
let root_path = workspace_root();
ensure_release_state(&root_path, dirty)?;

let old_version = read_workspace_version(&root_path)?;
let current_commit = git_output(&root_path, ["rev-parse", "HEAD"])?;
let current_branch = git_output(&root_path, ["branch", "--show-current"])?;
let release_tag = match place {
VersionPlace::Pre => next_alpha_tag(&root_path, &old_version)?,
_ => format!("v{}", old_version),
};
let previous_tag = previous_tag(&root_path, &release_tag)?;
let repo_url = repo_url(&root_path)?;
let compare_url = format!("{}/compare/{}...{}", repo_url, previous_tag, current_commit);

println!("Tagging {current_commit} on {current_branch} as {release_tag}");
println!(" Preview diff: {}", compare_url);
git_status(&root_path, ["tag", &release_tag])?;

let tag_undo = format!("git tag -d {}", shell_quote(&release_tag));
let tag_push = format!("git push -q origin {}", shell_quote(&release_tag));
if *place == VersionPlace::Pre {
print_next_steps(&tag_undo, &tag_push);
Ok(())
} else {
let bump_result = bump_on_pr_branch(&root_path, &old_version, place)?;
let branch_quoted = shell_quote(&bump_result.bump_branch);
let branch_undo = format!("git branch -D {branch_quoted}");
let undo_cmd = [tag_undo, branch_undo].join(" && ");
let publish_cmd = [bump_result.push_cmd, tag_push, bump_result.pr_cmd].join(" && \\\n ");
print_next_steps(&undo_cmd, &publish_cmd);
Ok(())
}
}

fn print_next_steps(undo_cmd: &str, good_cmd: &str) {
println!();
println!("If you would like to undo:");
println!(" {undo_cmd}");
println!();
println!("If this looks good:");
println!(" {good_cmd}");
println!();
}

struct BumpPrBranch {
bump_branch: String,
original_branch: String,
push_cmd: String,
pr_cmd: String,
}

fn bump_on_pr_branch(
root_path: &Path,
old_version: &Version,
place: &VersionPlace,
) -> Result<BumpPrBranch, String> {
let original_branch = git_output(root_path, ["branch", "--show-current"])?;
let new_version = old_version.clone().up(place);
let bump_branch = format!("bump_v{}", new_version);
git_status(root_path, ["checkout", "-b", &bump_branch])?;

println!("Bumping version number from {old_version} to {new_version}");
bump_package_versions(root_path, &new_version)?;

let commit_message = format!("Bump to v{}", new_version);
git_status(root_path, ["add", "Cargo.toml", "Cargo.lock"])?;
git_status(root_path, ["commit", "-m", commit_message.as_str()])?;
git_status(root_path, ["checkout", &original_branch])?;

let quoted_bump_branch = shell_quote(&bump_branch);
let quoted_commit_message = shell_quote(&commit_message);
let push_cmd = format!("git push -q -u origin {quoted_bump_branch}");
let pr_cmd = [
"gh pr create --web --base main".to_string(),
format!("--head {quoted_bump_branch}"),
format!("--title {quoted_commit_message}"),
]
.join(" ");
Ok(BumpPrBranch {
bump_branch,
original_branch,
push_cmd,
pr_cmd,
})
}

fn bump_package_versions(root_path: &Path, version: &Version) -> Result<(), String> {
update_workspace_version(root_path, version)?;

match xtask {
Xtask::Bump { place } => bump_package_versions(&place),
println!("Running cargo check to update Cargo.lock...");
let status = Command::new("cargo")
.arg("check")
.arg("-q")
.current_dir(root_path)
.status()
.map_err(|e| format!("failed to run cargo check: {}", e))?;
if !status.success() {
return Err("cargo check failed".to_string());
}

Ok(())
}

fn bump_package_versions(place: &VersionPlace) -> Result<(), String> {
let packages = vec![
"v-api",
"v-api-installer",
"v-api-param",
"v-api-permission-derive",
"v-model",
];
fn ensure_release_state(root_path: &Path, dirty: bool) -> Result<(), String> {
let branch = git_output(root_path, ["branch", "--show-current"])?;
if branch != "main" && !dirty {
return Err(format!(
"task must be run from main, currently on {}",
branch
));
}

let status = git_output(root_path, ["status", "--porcelain", "--untracked-files=no"])?;
if !status.is_empty() && !dirty {
return Err("task requires no modified tracked files".to_string());
}

git_status(
root_path,
["fetch", "origin", "main:refs/remotes/origin/main", "--tags"],
)?;

let crate_version_pattern = Regex::new(r#"(?m)^version = "(.*)"$"#).unwrap();
let local_main = git_output(root_path, ["rev-parse", "main"])?;
let origin_main = git_output(root_path, ["rev-parse", "origin/main"])?;
if local_main != origin_main {
return Err([
"Your local main does not match origin/main.".to_string(),
format!("main: {local_main:.7}"),
format!("origin/main: {origin_main:.7}"),
"Probably need to `git pull`".to_string(),
]
.join("\n"));
}

for package in packages {
let path = format!("{}/Cargo.toml", package);
let contents = fs::read_to_string(&path).unwrap();
let version_line = crate_version_pattern.captures(&contents).unwrap();
let mut version: Version = version_line.get(1).unwrap().as_str().parse().unwrap();
version = version.up(place);
Ok(())
}

let old_version_line = version_line.get(0).unwrap().as_str();
let new_version_line = format!(r#"version = "{}""#, version);
let new_contents = contents.replace(old_version_line, &new_version_line);
fn read_workspace_version(root_path: &Path) -> Result<Version, String> {
let cargo_toml = root_path.join("Cargo.toml");
let contents = fs::read_to_string(cargo_toml).map_err(|e| e.to_string())?;
let version_pattern = Regex::new(r#"(?m)^version = "(.*)"$"#).unwrap();
let version_line = version_pattern
.captures(&contents)
.ok_or("could not find workspace package version")?;
version_line
.get(1)
.unwrap()
.as_str()
.parse()
.map_err(|e| format!("failed to parse workspace version: {}", e))
}

fs::write(path, new_contents).unwrap();
fn update_workspace_version(root_path: &Path, version: &Version) -> Result<(), String> {
let cargo_toml = root_path.join("Cargo.toml");
let contents = fs::read_to_string(&cargo_toml).map_err(|e| e.to_string())?;
let version_pattern = Regex::new(r#"(?m)^version = "(.*)"$"#).unwrap();
let version_line = version_pattern
.captures(&contents)
.ok_or("could not find workspace package version")?;
let old_version_line = version_line.get(0).unwrap().as_str();
let new_version_line = format!(r#"version = "{}""#, version);
let new_contents = contents.replace(old_version_line, &new_version_line);
fs::write(cargo_toml, new_contents).map_err(|e| e.to_string())?;
println!("Updated workspace to {}", version);
Ok(())
}

println!("Updated {} to {}", package, version);
fn next_alpha_tag(root_path: &Path, version: &Version) -> Result<String, String> {
let base = format!("v{}-alpha", version);
let tags = git_output(root_path, ["tag", "--list", &format!("{}*", base)])?;
let next = tags
.lines()
.filter_map(|tag| tag.strip_prefix(&base))
.filter_map(|suffix| suffix.strip_prefix('.'))
.filter_map(|number| number.parse::<u64>().ok())
.max()
.map(|number| number + 1)
.unwrap_or(1);
Ok(format!("{}.{}", base, next))
}

fn previous_tag(root_path: &Path, release_tag: &str) -> Result<String, String> {
let tags = git_output(root_path, ["tag", "--sort=-version:refname"])?;
tags.lines()
.find(|tag| *tag != release_tag)
.map(ToString::to_string)
.ok_or("could not find previous release tag".to_string())
}

fn repo_url(root_path: &Path) -> Result<String, String> {
let url = git_output(root_path, ["remote", "get-url", "origin"])?;
if let Some(path) = url.strip_prefix("git@github.com:") {
return Ok(format!(
"https://github.com/{}",
path.strip_suffix(".git").unwrap_or(path)
));
}
Ok(url.strip_suffix(".git").unwrap_or(&url).to_string())
}

fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', r#"'\''"#))
}

fn git_output<const N: usize>(root_path: &Path, args: [&str; N]) -> Result<String, String> {
let output = Command::new("git")
.args(args)
.current_dir(root_path)
.output()
.map_err(|e| format!("failed to run git: {}", e))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn git_status<const N: usize>(root_path: &Path, args: [&str; N]) -> Result<(), String> {
let status = Command::new("git")
.args(args)
.current_dir(root_path)
.status()
.map_err(|e| format!("failed to run git: {}", e))?;
if !status.success() {
return Err(format!("git {} failed", args.join(" ")));
}
Ok(())
}

fn workspace_root() -> PathBuf {
let xtask_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
xtask_path.parent().unwrap().to_path_buf()
}

trait Bump {
fn up(self, place: &VersionPlace) -> Self;
}
Expand Down Expand Up @@ -97,7 +353,11 @@ impl Bump for Version {
let num = number.parse::<u64>().unwrap();
self.pre = Prerelease::new(&format!("{}.{}", label, num + 1)).unwrap();
}
None => panic!("Found unexpected prelease format: {}", self.pre),
None if self.pre == Prerelease::EMPTY => {
self.patch += 1;
self.pre = Prerelease::new("alpha.1").unwrap();
}
None => panic!("Found unexpected prerelease format: {}", self.pre),
},
}

Expand Down
Loading