xtask: Add release commit creation to release

This commit is contained in:
Kévin Commaille 2021-04-14 18:53:56 +02:00 committed by GitHub
parent 3c237652db
commit bc62192e60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 469 additions and 173 deletions

View file

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

View file

@ -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<sup>[1](#ref-1)</sup>, 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
<sup><span id="ref-1">1</span></sup> if `crate` is a user-facing crate and `version` is not a pre-release.
[xtask]: https://github.com/matklad/cargo-xtask

192
xtask/src/cargo.rs Normal file
View file

@ -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<Package>,
}
impl Metadata {
/// Load a new `Metadata` from the command line.
pub fn load() -> Result<Metadata> {
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<Dependency>,
}
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>()?;
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::<Document>()?;
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<String> {
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<bool> {
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<bool> {
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<DependencyKind>,
}
/// 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<IgnoredAny>,
}

View file

@ -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<String>, project_root: PathBuf) -> Self {
Self { version, project_root }
pub(crate) fn new(version: Option<String>) -> Result<Self> {
let project_root = Metadata::load()?.workspace_root;
Ok(Self { version, project_root })
}
pub(crate) fn run(self) -> Result<()> {

View file

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

View file

@ -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<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,
}
impl Config {
/// Load a new `Config` from `config.toml`.
fn load() -> Result<Self> {
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<Config> {
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) => {

View file

@ -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<Self> {
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<Self> {
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(&macros.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> {
LocalCrate::new(format!("{}-macros", self.local_crate.name), &self.project_root).ok()
fn macros(&self) -> Option<Package> {
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<Self> {
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<Version> {
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<String> {
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<bool> {
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<bool> {
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<IgnoredAny>,
}
/// 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
}
}