Add xtask to automate crate releases

This commit is contained in:
Kévin Commaille 2021-04-03 16:59:28 +02:00 committed by GitHub
parent 5b0c675cb8
commit eb7683bae9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 333 additions and 1 deletions

2
.cargo/config Normal file
View File

@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --"

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.vscode
target
Cargo.lock
/xtask/config.toml

View File

@ -1,2 +1,2 @@
[workspace]
members = ["ruma", "ruma-*"]
members = ["ruma", "ruma-*", "xtask"]

16
xtask/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "xtask"
version = "0.1.0"
authors = ["Kévin Commaille <zecakeh@pm.me>"]
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"

15
xtask/README.md Normal file
View File

@ -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

6
xtask/config.toml.sample Normal file
View File

@ -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 = ""

48
xtask/src/flags.rs Normal file
View File

@ -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> {
Self::from_env_()
}
}
// generated end

78
xtask/src/main.rs Normal file
View File

@ -0,0 +1,78 @@
//! See <https://github.com/matklad/cargo-xtask/>.
//!
//! 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<T> = std::result::Result<T, Box<dyn std::error::Error>>;
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<PathBuf> {
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<Config> {
let path = Path::new(&env!("CARGO_MANIFEST_DIR")).join("config.toml");
let config = read_file(path)?;
Ok(from_toml_str(&config)?)
}

166
xtask/src/release.rs Normal file
View File

@ -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<String> {
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<String> {
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<String> {
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
}
}