diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 1cee297a..27f270fb 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -9,10 +9,10 @@ publish = false [dependencies] isahc = { version = "1.2.0", features = ["json"] } -itertools = "0.10.0" semver = { version = "0.11.0", features = ["serde"] } serde = { version = "1.0.118", features = ["derive"] } serde_json = "1.0.60" toml = "0.5.8" +toml_edit = "0.2.0" xflags = "0.2.1" xshell = "0.1.9" diff --git a/xtask/README.md b/xtask/README.md index f2090073..0fdecb21 100644 --- a/xtask/README.md +++ b/xtask/README.md @@ -1,6 +1,6 @@ # Ruma xtasks -This crate is a helper bin for repetitive tasks during Ruma development, based on [cargo-xtask]. +This crate is a helper bin for repetitive tasks during Ruma development, based on [cargo xtask][xtask]. To use it, run `cargo xtask [command]` anywhere in the workspace. @@ -9,7 +9,10 @@ the appropriate fields. ## Commands -- `release [crate]`: Publish `crate`, create a signed tag based on its name and version and create - a release on GitHub. **Requires all `github` fields in `config.toml`.** +- `release [crate] [version]`: Publish `crate` at given `version`, if applicable[1](#ref-1), create a + signed tag based on its name and version and create a release on GitHub. + **Requires all `github` fields in `config.toml`.** -[cargo-xtask] : https://github.com/matklad/cargo-xtask +1 if `crate` is a user-facing crate and `version` is not a pre-release. + +[xtask]: https://github.com/matklad/cargo-xtask diff --git a/xtask/src/cargo.rs b/xtask/src/cargo.rs new file mode 100644 index 00000000..3b62d51a --- /dev/null +++ b/xtask/src/cargo.rs @@ -0,0 +1,192 @@ +use std::path::PathBuf; + +use isahc::{HttpClient, ReadResponseExt}; +use semver::Version; +use serde::{de::IgnoredAny, Deserialize}; +use serde_json::from_str as from_json_str; +use toml_edit::{value, Document}; +use xshell::{cmd, pushd, read_file, write_file}; + +use crate::{util::ask_yes_no, Result}; + +const CRATESIO_API: &str = "https://crates.io/api/v1/crates"; + +/// The metadata of a cargo workspace. +#[derive(Clone, Debug, Deserialize)] +pub struct Metadata { + pub workspace_root: PathBuf, + pub packages: Vec, +} + +impl Metadata { + /// Load a new `Metadata` from the command line. + pub fn load() -> Result { + let metadata_json = cmd!("cargo metadata --no-deps --format-version 1").read()?; + Ok(from_json_str(&metadata_json)?) + } +} + +/// A cargo package. +#[derive(Clone, Debug, Deserialize)] +pub struct Package { + /// The package name + pub name: String, + + /// The package version. + pub version: Version, + + /// The package's manifest path. + pub manifest_path: PathBuf, + + /// A map of the package dependencies. + #[serde(default)] + pub dependencies: Vec, +} + +impl Package { + /// Update the version of this crate. + pub fn update_version(&mut self, version: &Version) -> Result<()> { + println!("Updating {} to version {}…", self.name, version); + + let mut document = read_file(&self.manifest_path)?.parse::()?; + + document["package"]["version"] = value(version.to_string()); + + write_file(&self.manifest_path, document.to_string())?; + + self.version = version.clone(); + + Ok(()) + } + + /// Update the version of this crate in dependant crates' manifests, with the given version + /// prefix. + pub fn update_dependants(&self, metadata: &Metadata) -> Result<()> { + for package in metadata.packages.iter().filter(|p| { + p.manifest_path.starts_with(&metadata.workspace_root) + && p.dependencies.iter().any(|d| d.name == self.name) + }) { + println!("Updating dependency in {} crate…", package.name); + + let mut document = read_file(&package.manifest_path)?.parse::()?; + + for dependency in package.dependencies.iter().filter(|d| d.name == self.name) { + let version = if self.version.is_prerelease() || self.name.ends_with("-macros") { + format!("={}", self.version) + } else { + self.version.to_string() + }; + + let kind = match dependency.kind { + Some(DependencyKind::Dev) => "dev-dependencies", + Some(DependencyKind::Build) => "build-dependencies", + None => "dependencies", + }; + + document[kind][&self.name]["version"] = value(version.as_str()); + } + + write_file(&package.manifest_path, document.to_string())?; + } + + Ok(()) + } + + /// Get the changes for the version. If `update` is `true`, update the changelog for the release + /// of the given version. + pub fn changes(&self, update: bool) -> Result { + let mut changelog_path = self.manifest_path.clone(); + changelog_path.set_file_name("CHANGELOG.md"); + + let changelog = read_file(&changelog_path)?; + + if !changelog.starts_with(&format!("# {}\n", self.version)) + && !changelog.starts_with(&format!("# {} (unreleased)\n", self.version)) + && !changelog.starts_with("# [unreleased]\n") + { + return Err("Could not find version title in changelog".into()); + }; + + let changes_start = match changelog.find('\n') { + Some(p) => p + 1, + None => { + return Err("Could not find end of version title in changelog".into()); + } + }; + + let changes_end = match changelog[changes_start..].find("\n# ") { + Some(p) => changes_start + p, + None => changelog.len(), + }; + + let changes = match changelog[changes_start..changes_end].trim() { + s if s.is_empty() => "No changes for this version", + s => s, + }; + + if update { + let changelog = format!( + "# [unreleased]\n\n# {}\n\n{}\n{}", + self.version, + changes, + &changelog[changes_end..] + ); + + write_file(&changelog_path, changelog)?; + } + + Ok(changes.to_owned()) + } + + /// Check if the current version of the crate is published on crates.io. + pub fn is_published(&self, client: &HttpClient) -> Result { + let response: CratesIoCrate = + client.get(format!("{}/{}/{}", CRATESIO_API, self.name, self.version))?.json()?; + + Ok(response.version.is_some()) + } + + /// Publish this package on crates.io. + pub fn publish(&self, client: &HttpClient) -> Result { + println!("Publishing {} {} on crates.io…", self.name, self.version); + let _dir = pushd(&self.manifest_path.parent().unwrap())?; + + if self.is_published(client)? { + if ask_yes_no("This version is already published. Skip this step and continue?")? { + Ok(false) + } else { + Err("Release interrupted by user.".into()) + } + } else { + cmd!("cargo publish").run()?; + Ok(true) + } + } +} + +/// A cargo package dependency. +#[derive(Clone, Debug, Deserialize)] +pub struct Dependency { + /// The package name. + pub name: String, + + /// The kind of the dependency. + pub kind: Option, +} + +/// The kind of a cargo package dependency. +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum DependencyKind { + /// A dev dependency. + Dev, + + /// A build dependency. + Build, +} + +/// A crate from the `GET /crates/{crate}` endpoint of crates.io. +#[derive(Deserialize)] +struct CratesIoCrate { + version: Option, +} diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 85fc7d57..4e3cc7fd 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use xshell::pushd; -use crate::{cmd, Result}; +use crate::{cargo::Metadata, cmd, Result}; const MSRV: &str = "1.45"; @@ -18,8 +18,9 @@ pub struct CiTask { } impl CiTask { - pub(crate) fn new(version: Option, project_root: PathBuf) -> Self { - Self { version, project_root } + pub(crate) fn new(version: Option) -> Result { + let project_root = Metadata::load()?.workspace_root; + Ok(Self { version, project_root }) } pub(crate) fn run(self) -> Result<()> { diff --git a/xtask/src/flags.rs b/xtask/src/flags.rs index a0bfe6c5..117fe47e 100644 --- a/xtask/src/flags.rs +++ b/xtask/src/flags.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] // silence never-used warning for from_vec in generated code +use semver::Version; + xflags::xflags! { src "./src/flags.rs" @@ -14,6 +16,18 @@ xflags::xflags! { cmd release /// The crate to release required name: String + + /// The new version of the crate + required version: Version + {} + + /// Alias for release. + cmd publish + /// The crate to release + required name: String + + /// The new version of the crate + required version: Version {} /// Run CI tests. @@ -34,6 +48,7 @@ pub struct Xtask { pub enum XtaskCmd { Help(Help), Release(Release), + Publish(Publish), Ci(Ci), } @@ -45,6 +60,13 @@ pub struct Help { #[derive(Debug)] pub struct Release { pub name: String, + pub version: Version, +} + +#[derive(Debug)] +pub struct Publish { + pub name: String, + pub version: Version, } #[derive(Debug)] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5f9940b1..c4da2062 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,16 +3,13 @@ //! This binary is integrated into the `cargo` command line by using an alias in //! `.cargo/config`. Run commands as `cargo xtask [command]`. -use std::{ - env, - path::{Path, PathBuf}, -}; +use std::{env, path::Path}; use serde::Deserialize; -use serde_json::from_str as from_json_str; use toml::from_str as from_toml_str; use xshell::read_file; +mod cargo; mod ci; mod flags; mod release; @@ -30,8 +27,6 @@ fn main() { } fn try_main() -> Result<()> { - let project_root = project_root()?; - let flags = flags::Xtask::from_env()?; match flags.subcommand { flags::XtaskCmd::Help(_) => { @@ -39,34 +34,35 @@ fn try_main() -> Result<()> { Ok(()) } flags::XtaskCmd::Release(cmd) => { - let task = ReleaseTask::new(cmd.name, project_root)?; + let mut task = ReleaseTask::new(cmd.name, cmd.version)?; + task.run() + } + flags::XtaskCmd::Publish(cmd) => { + let mut task = ReleaseTask::new(cmd.name, cmd.version)?; task.run() } flags::XtaskCmd::Ci(ci) => { - let task = CiTask::new(ci.version, project_root); + let task = CiTask::new(ci.version)?; task.run() } } } -#[derive(Debug, Deserialize)] -struct CargoMetadata { - workspace_root: PathBuf, -} - -/// Get the project workspace root. -fn project_root() -> Result { - let metadata_json = cmd!("cargo metadata --format-version 1").read()?; - let metadata: CargoMetadata = from_json_str(&metadata_json)?; - Ok(metadata.workspace_root) -} - #[derive(Debug, Deserialize)] struct Config { /// Credentials to authenticate to GitHub. github: GithubConfig, } +impl Config { + /// Load a new `Config` from `config.toml`. + fn load() -> Result { + let path = Path::new(&env!("CARGO_MANIFEST_DIR")).join("config.toml"); + let config = read_file(path)?; + Ok(from_toml_str(&config)?) + } +} + #[derive(Debug, Deserialize)] struct GithubConfig { /// The username to use for authentication. @@ -76,13 +72,6 @@ struct GithubConfig { token: String, } -/// Load the config from `config.toml`. -fn config() -> Result { - let path = Path::new(&env!("CARGO_MANIFEST_DIR")).join("config.toml"); - let config = read_file(path)?; - Ok(from_toml_str(&config)?) -} - #[macro_export] macro_rules! cmd { ($cmd:tt) => { diff --git a/xtask/src/release.rs b/xtask/src/release.rs index 3aa19347..d502acba 100644 --- a/xtask/src/release.rs +++ b/xtask/src/release.rs @@ -1,5 +1,5 @@ use std::{ - path::{Path, PathBuf}, + io::{stdin, stdout, BufRead, Write}, thread::sleep, time::Duration, }; @@ -10,26 +10,30 @@ use isahc::{ http::StatusCode, HttpClient, ReadResponseExt, Request, }; -use itertools::Itertools; -use semver::Version; -use serde::{de::IgnoredAny, Deserialize}; +use semver::{Identifier, Version}; +use serde::Deserialize; use serde_json::json; -use toml::from_str as from_toml_str; -use xshell::{pushd, read_file}; -use crate::{cmd, util::ask_yes_no, GithubConfig, Result}; +use crate::{ + cargo::{Metadata, Package}, + cmd, + util::ask_yes_no, + GithubConfig, Result, +}; -const CRATESIO_API: &str = "https://crates.io/api/v1/crates"; const GITHUB_API_RUMA: &str = "https://api.github.com/repos/ruma/ruma"; /// Task to create a new release of the given crate. #[derive(Debug)] pub struct ReleaseTask { - /// The crate to release. - local_crate: LocalCrate, + /// The metadata of the cargo workspace. + metadata: Metadata, - /// The root of the workspace. - project_root: PathBuf, + /// The crate to release. + package: Package, + + /// The new version of the crate. + version: Version, /// The http client to use for requests. http_client: HttpClient, @@ -39,19 +43,30 @@ pub struct ReleaseTask { } impl ReleaseTask { - /// Create a new `ReleaseTask` with the given `name` and `project_root`. - pub(crate) fn new(name: String, project_root: PathBuf) -> Result { - let local_crate = LocalCrate::new(name, &project_root)?; - let config = crate::config()?.github; + /// Create a new `ReleaseTask` with the given `name` and `version`. + pub(crate) fn new(name: String, version: Version) -> Result { + let metadata = Metadata::load()?; + + let package = metadata + .packages + .clone() + .into_iter() + .find(|p| p.name == name) + .ok_or(format!("Package {} not found in cargo metadata", name))?; + + let config = crate::Config::load()?.github; + let http_client = HttpClient::new()?; - Ok(Self { local_crate, project_root, http_client, config }) + Ok(Self { metadata, package, version, http_client, config }) } /// Run the task to effectively create a release. - pub(crate) fn run(self) -> Result<()> { + pub(crate) fn run(&mut self) -> Result<()> { let title = &self.title(); - let prerelease = self.local_crate.version.is_prerelease(); + let prerelease = self.version.is_prerelease(); + let publish_only = self.package.name == "ruma-identifiers-validation"; + println!( "Starting {} for {}…", match prerelease { @@ -69,15 +84,41 @@ impl ReleaseTask { println!("Checking status of git repository…"); if !cmd!("git status -s -uno").read()?.is_empty() - && !ask_yes_no("This git repository contains untracked files. Continue?")? + && !ask_yes_no("This git repository contains uncommitted changes. Continue?")? { return Ok(()); } - if let Some(macros) = self.macros() { - print!("Found macros crate. "); - let _dir = pushd(¯os.path)?; - let published = macros.publish(&self.http_client)?; + if self.package.version != self.version + && !self.package.version.is_next(&self.version) + && !ask_yes_no(&format!( + "Version {} should not follow version {}. Do you really want to continue?", + self.version, self.package.version, + ))? + { + return Ok(()); + } + + let mut macros = self.macros(); + + if let Some(m) = macros.as_mut() { + println!("Found macros crate {}.", m.name); + + m.update_version(&self.version)?; + m.update_dependants(&self.metadata)?; + + println!("Resuming release of {}…", self.title()); + } + + self.package.update_version(&self.version)?; + self.package.update_dependants(&self.metadata)?; + + let changes = &self.package.changes(!prerelease)?; + + self.commit()?; + + if let Some(m) = macros { + let published = m.publish(&self.http_client)?; if published { // Crate was published, instead of publishing skipped (because release already @@ -85,21 +126,15 @@ impl ReleaseTask { println!("Waiting 10 seconds for the release to make it into the crates.io index…"); sleep(Duration::from_secs(10)); } - - println!("Resuming release of {}…", self.title()); } - let _dir = pushd(&self.local_crate.path)?; + self.package.publish(&self.http_client)?; - self.local_crate.publish(&self.http_client)?; - - if prerelease { - println!("Pre-release created successfully!"); + if publish_only { + println!("Crate published successfully!"); return Ok(()); } - let changes = &self.local_crate.changes()?; - let tag = &self.tag_name(); println!("Creating git tag…"); @@ -116,6 +151,11 @@ impl ReleaseTask { return Ok(()); } + if prerelease { + println!("Pre-release created successfully!"); + return Ok(()); + } + println!("Creating release on GitHub…"); let request_body = &json!({ "tag_name": tag, @@ -132,13 +172,22 @@ impl ReleaseTask { } /// Get the associated `-macros` crate of the current crate, if any. - fn macros(&self) -> Option { - LocalCrate::new(format!("{}-macros", self.local_crate.name), &self.project_root).ok() + fn macros(&self) -> Option { + self.metadata + .packages + .clone() + .into_iter() + .find(|p| p.name == format!("{}-macros", self.package.name)) } /// Get the title of this release. fn title(&self) -> String { - format!("{} {}", self.local_crate.name, self.local_crate.version) + format!("{} {}", self.package.name, self.version) + } + + /// Get the tag name for this release. + fn tag_name(&self) -> String { + format!("{}-{}", self.package.name, self.version) } /// Load the GitHub config from the config file. @@ -153,9 +202,47 @@ impl ReleaseTask { Ok(remote) } - /// Get the tag name for this release. - fn tag_name(&self) -> String { - format!("{}-{}", self.local_crate.name, self.local_crate.version) + /// Commit and push all the changes in the git repository. + fn commit(&self) -> Result<()> { + let mut input = String::new(); + let stdin = stdin(); + + let instructions = "Ready to commit the changes. [continue/abort/diff]: "; + print!("{}", instructions); + stdout().flush()?; + + let mut handle = stdin.lock(); + + while let _ = handle.read_line(&mut input)? { + match input.trim().to_ascii_lowercase().as_str() { + "c" | "continue" => { + break; + } + "a" | "abort" => { + return Err("User aborted commit".into()); + } + "d" | "diff" => { + cmd!("git diff").run()?; + } + _ => { + println!("Unknown command."); + } + } + print!("{}", instructions); + stdout().flush()?; + + input.clear(); + } + + let message = format!("Release {}", self.title()); + + println!("Creating commit…"); + cmd!("git commit -a -m {message}").read()?; + + println!("Pushing commit…"); + cmd!("git push").read()?; + + Ok(()) } /// Check if the tag for the current version of the crate has been pushed on GitHub. @@ -187,104 +274,6 @@ impl ReleaseTask { } } -/// A local Rust crate. -#[derive(Debug)] -struct LocalCrate { - /// The name of the crate. - name: String, - - /// The version of the crate. - version: Version, - - /// The local path of the crate. - path: PathBuf, -} - -impl LocalCrate { - /// Creates a new `Crate` with the given name and project root. - pub fn new(name: String, project_root: &Path) -> Result { - let path = project_root.join(&name); - - let version = Self::version(&path)?; - - Ok(Self { name, version, path }) - } - - /// The current version of the crate at `path` from its manifest. - fn version(path: &Path) -> Result { - let manifest_toml = read_file(path.join("Cargo.toml"))?; - let manifest: CargoManifest = from_toml_str(&manifest_toml)?; - - Ok(manifest.package.version) - } - - /// The changes of the given version from the changelog. - fn changes(&self) -> Result { - let changelog = read_file(self.path.join("CHANGELOG.md"))?; - let lines_nb = changelog.lines().count(); - let mut lines = changelog.lines(); - - let start = match lines.position(|l| l.starts_with(&format!("# {}", self.version))) { - Some(p) => p + 1, - None => { - return Err("Could not find version title in changelog".into()); - } - }; - - let length = match lines.position(|l| l.starts_with("# ")) { - Some(p) => p, - None => lines_nb, - }; - - let changes = changelog.lines().skip(start).take(length).join("\n"); - - Ok(changes.trim().to_owned()) - } - - /// Check if the current version of the crate is published on crates.io. - fn is_published(&self, client: &HttpClient) -> Result { - let response: CratesIoCrate = - client.get(format!("{}/{}/{}", CRATESIO_API, self.name, self.version))?.json()?; - - Ok(response.version.is_some()) - } - - /// Publish this package on crates.io. - fn publish(&self, client: &HttpClient) -> Result { - println!("Publishing {} {} on crates.io…", self.name, self.version); - if self.is_published(client)? { - if ask_yes_no("This version is already published. Skip this step and continue?")? { - Ok(false) - } else { - Err("Release interrupted by user.".into()) - } - } else { - cmd!("cargo publish").run()?; - Ok(true) - } - } -} - -/// The required cargo manifest data of a crate. -#[derive(Debug, Deserialize)] -struct CargoManifest { - /// The package information. - package: CargoPackage, -} - -/// The required package information from a crate's cargo manifest. -#[derive(Debug, Deserialize)] -struct CargoPackage { - /// The package version. - version: Version, -} - -/// A crate from the `GET /crates/{crate}` endpoint of crates.io. -#[derive(Deserialize)] -struct CratesIoCrate { - version: Option, -} - /// A tag from the `GET /repos/{owner}/{repo}/tags` endpoint of GitHub REST API. #[derive(Debug, Deserialize)] struct GithubTag { @@ -343,3 +332,103 @@ impl StrExt for str { string + s } } + +/// Extra Version increment methods for crate release. +trait VersionExt { + /// Adds a pre-release label and number if there is none. + fn add_pre_release(&mut self); + + /// Increments the pre-release number, if this is a pre-release. + fn increment_pre_number(&mut self); + + /// Increments the pre-release label from `alpha` to `beta` if this is a pre-release and it is + /// possible, otherwise does nothing. + fn increment_pre_label(&mut self); + + /// If the given version can be the next after this one. + /// + /// This checks all the version bumps of the format MAJOR.MINOR.PATCH-PRE_LABEL.PRE_NUMBER, with + /// PRE_LABEL = alpha or beta. + fn is_next(&self, version: &Version) -> bool; +} + +impl VersionExt for Version { + fn add_pre_release(&mut self) { + if !self.is_prerelease() { + self.pre = vec![Identifier::AlphaNumeric("alpha".into()), Identifier::Numeric(1)]; + } + } + + fn increment_pre_number(&mut self) { + if self.is_prerelease() { + if let Identifier::Numeric(n) = self.pre[1] { + self.pre[1] = Identifier::Numeric(n + 1); + } + } + } + + fn increment_pre_label(&mut self) { + if self.is_prerelease() { + match &self.pre[0] { + Identifier::AlphaNumeric(n) if n == "alpha" => { + self.pre = + vec![Identifier::AlphaNumeric("beta".into()), Identifier::Numeric(1)]; + } + _ => {} + } + } + } + + fn is_next(&self, version: &Version) -> bool { + let mut next = self.clone(); + + if self.is_prerelease() { + next.increment_pre_number(); + if next == *version { + return true; + } + + next.increment_pre_label(); + if next == *version { + return true; + } + + next.pre = vec![]; + if next == *version { + return true; + } + } else { + next.increment_patch(); + if next == *version { + return true; + } + + next.add_pre_release(); + if next == *version { + return true; + } + + next.increment_minor(); + if next == *version { + return true; + } + + next.add_pre_release(); + if next == *version { + return true; + } + + next.increment_major(); + if next == *version { + return true; + } + + next.add_pre_release(); + if next == *version { + return true; + } + } + + false + } +}