From eb7683bae9d0961d5b7ce09eb06c5d95005dcef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= <76261501+zecakeh@users.noreply.github.com> Date: Sat, 3 Apr 2021 16:59:28 +0200 Subject: [PATCH] Add xtask to automate crate releases --- .cargo/config | 2 + .gitignore | 1 + Cargo.toml | 2 +- xtask/Cargo.toml | 16 ++++ xtask/README.md | 15 ++++ xtask/config.toml.sample | 6 ++ xtask/src/flags.rs | 48 +++++++++++ xtask/src/main.rs | 78 ++++++++++++++++++ xtask/src/release.rs | 166 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 .cargo/config create mode 100644 xtask/Cargo.toml create mode 100644 xtask/README.md create mode 100644 xtask/config.toml.sample create mode 100644 xtask/src/flags.rs create mode 100644 xtask/src/main.rs create mode 100644 xtask/src/release.rs diff --git a/.cargo/config b/.cargo/config new file mode 100644 index 00000000..35049cbc --- /dev/null +++ b/.cargo/config @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.gitignore b/.gitignore index 8094f6ea..c1ee4fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode target Cargo.lock +/xtask/config.toml diff --git a/Cargo.toml b/Cargo.toml index b2f5e08c..76c64b11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["ruma", "ruma-*"] +members = ["ruma", "ruma-*", "xtask"] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 00000000..43aa1862 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "xtask" +version = "0.1.0" +authors = ["Kévin Commaille "] +edition = "2018" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +itertools = "0.10.0" +serde = { version = "1.0.118", features = ["derive"] } +serde_json = "1.0.60" +toml = "0.5.8" +xflags = "0.2.1" +xshell = "0.1.9" diff --git a/xtask/README.md b/xtask/README.md new file mode 100644 index 00000000..f2090073 --- /dev/null +++ b/xtask/README.md @@ -0,0 +1,15 @@ +# Ruma xtasks + +This crate is a helper bin for repetitive tasks during Ruma development, based on [cargo-xtask]. + +To use it, run `cargo xtask [command]` anywhere in the workspace. + +Some commands need configuration variables. Copy `config.toml.sample` to `config.toml` and fill +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`.** + +[cargo-xtask] : https://github.com/matklad/cargo-xtask diff --git a/xtask/config.toml.sample b/xtask/config.toml.sample new file mode 100644 index 00000000..d230df08 --- /dev/null +++ b/xtask/config.toml.sample @@ -0,0 +1,6 @@ +[github] +# The username to use for authentication. +user = "" +# The personal access token to use for authentication. Needs 'repo' scope. +# See: https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token +token = "" diff --git a/xtask/src/flags.rs b/xtask/src/flags.rs new file mode 100644 index 00000000..4bceeb2b --- /dev/null +++ b/xtask/src/flags.rs @@ -0,0 +1,48 @@ +xflags::xflags! { + src "./src/flags.rs" + + /// Run custom task. + cmd xtask { + default cmd help { + /// Print help information. + optional -h, --help + } + + /// Create a new release of the given crate. + cmd release + required name: String + {} + } +} +// generated start +// The following code is generated by `xflags` macro. +// Run `env UPDATE_XFLAGS=1 cargo build` to regenerate. +#[derive(Debug)] +pub struct Xtask { + pub subcommand: XtaskCmd, +} + +#[derive(Debug)] +pub enum XtaskCmd { + Help(Help), + Release(Release), +} + +#[derive(Debug)] +pub struct Help { + pub help: bool, +} + +#[derive(Debug)] +pub struct Release { + pub name: String, +} + +impl Xtask { + pub const HELP: &'static str = Self::HELP_; + + pub fn from_env() -> xflags::Result { + Self::from_env_() + } +} +// generated end diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 00000000..db2a1391 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,78 @@ +//! See . +//! +//! 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 serde::Deserialize; +use serde_json::from_str as from_json_str; +use toml::from_str as from_toml_str; +use xshell::{cmd, read_file}; + +mod flags; +mod release; + +type Result = std::result::Result>; + +fn main() { + if let Err(e) = try_main() { + eprintln!("{}", e); + std::process::exit(-1); + } +} + +fn try_main() -> Result<()> { + let flags = flags::Xtask::from_env()?; + match flags.subcommand { + flags::XtaskCmd::Help(_) => { + println!("{}", flags::Xtask::HELP); + Ok(()) + } + flags::XtaskCmd::Release(cmd) => cmd.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, +} + +#[derive(Debug, Deserialize)] +struct GithubConfig { + /// The username to use for authentication. + user: String, + + /// The personal access token to use for authentication. + token: String, +} + +impl GithubConfig { + /// Get the GitHub credentials formatted as `user:token` + fn credentials(&self) -> String { + format!("{}:{}", self.user, self.token) + } +} + +/// 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)?) +} diff --git a/xtask/src/release.rs b/xtask/src/release.rs new file mode 100644 index 00000000..28ed7fda --- /dev/null +++ b/xtask/src/release.rs @@ -0,0 +1,166 @@ +use std::path::Path; + +use itertools::Itertools; +use serde::Deserialize; +use serde_json::json; +use toml::from_str as from_toml_str; +use xshell::{cmd, pushd, read_file}; + +use crate::{config, flags, project_root, Result}; + +const GITHUB_API_RELEASES: &str = "https://api.github.com/repos/ruma/ruma/releases"; + +impl flags::Release { + /// Run the release command to effectively create a release. + pub(crate) fn run(self) -> Result<()> { + let project_root = &project_root()?; + let _dir = pushd(project_root.join(&self.name))?; + + let remote = &self.get_remote()?; + + if !cmd!("git status -s -uno").read()?.is_empty() { + return Err("This git repository contains untracked files".into()); + } + + let version = &self.get_version(project_root)?; + println!("Making release for {} {}…", self.name, version); + + cmd!("cargo publish").run()?; + + let credentials = &config()?.github.credentials(); + + let changes = &self.get_changes(project_root, &version)?; + + let tag = &format!("{}-{}", self.name, version); + let name = &format!("{} {}", self.name, version); + + cmd!("git tag -s {tag} -m {name} -m {changes}").secret(true).run()?; + + cmd!("git push {remote} {tag}").run()?; + + let request_body = &json!({ + "tag_name": tag, + "name": name, + "body": changes.trim_softbreaks(), + }) + .to_string(); + + cmd!( + "curl -u {credentials} -X POST -H 'Accept: application/vnd.github.v3+json' + {GITHUB_API_RELEASES} -d {request_body}" + ) + .secret(true) + .run()?; + + Ok(()) + } + + /// Get the changes of the given version from the changelog. + fn get_changes(&self, project_root: &Path, version: &str) -> Result { + let changelog = read_file(project_root.join(&self.name).join("CHANGELOG.md"))?; + let lines_nb = changelog.lines().count(); + let mut lines = changelog.lines(); + + let start = match lines.position(|l| l.starts_with(&format!("# {}", 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()) + } + + /// Load the GitHub config from the config file. + fn get_remote(&self) -> Result { + let branch = cmd!("git rev-parse --abbrev-ref HEAD").read()?; + let remote = cmd!("git config branch.{branch}.remote").read()?; + + if remote.is_empty() { + return Err("Could not get current git remote".into()); + } + + Ok(remote) + } + + /// Get the current version of the crate from the manifest. + fn get_version(&self, project_root: &Path) -> Result { + let manifest_toml = read_file(project_root.join(&self.name).join("Cargo.toml"))?; + let manifest: CargoManifest = from_toml_str(&manifest_toml)?; + + Ok(manifest.package.version) + } +} + +/// 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: String, +} + +/// String manipulations for crate release. +trait StrExt { + /// Remove soft line breaks as defined in CommonMark spec. + fn trim_softbreaks(&self) -> String; +} + +impl StrExt for str { + fn trim_softbreaks(&self) -> String { + let mut string = String::new(); + let mut s = self; + + while let Some(pos) = s.find('\n') { + string.push_str(&s[..pos]); + let pos_s = &s[pos..]; + + if pos_s.starts_with("\n\n") { + // Keep new paragraphs (multiple `\n`s). + let next = pos_s.find(|c: char| c != '\n').unwrap_or(0); + let (push, next_s) = pos_s.split_at(next); + + string.push_str(push); + s = next_s; + } else if s[..pos].ends_with(" ") || s[..pos].ends_with('\\') { + // Keep hard line breaks (two spaces or a backslash before the line break). + string.push('\n'); + s = &pos_s[1..]; + } else if let Some(p) = pos_s.find(|c: char| !c.is_ascii_whitespace()) { + // Keep line break before list items (`\n` + whitespaces + `*` + whitespaces). + // Remove line break and keep one space otherwise. + let mut chars = pos_s.char_indices(); + let (_, char) = chars.find(|(i, _)| *i == p).unwrap(); + + if char == '*' || char == '-' { + match chars.next() { + Some((_, next_char)) if next_char.is_ascii_whitespace() => { + string.push('\n'); + s = &pos_s[1..]; + continue; + } + _ => {} + } + } + + string.push(' '); + s = &pos_s[p..]; + } + } + + string + s + } +}