diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b98ba2d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ + +[alias] +xtask = "run --package xtask --" diff --git a/Cargo.toml b/Cargo.toml index 2713681..50a8f7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,14 @@ [workspace] -members = ["omnibor", "gitoid"] +members = ["omnibor", "gitoid", "xtask"] resolver = "2" +[workspace.package] +edition = "2021" +license = "Apache-2.0" +license-file = "LICENSE" +homepage = "https://omnibor.io" + # Config for 'cargo dist' [workspace.metadata.dist] # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) diff --git a/gitoid/Cargo.toml b/gitoid/Cargo.toml index 1e540c6..7c1a764 100644 --- a/gitoid/Cargo.toml +++ b/gitoid/Cargo.toml @@ -1,15 +1,16 @@ [package] categories = ["cryptography", "development-tools"] description = "Git Object Identifiers in Rust" -edition = "2021" -homepage = "https://omnibor.io/" keywords = ["gitbom", "omnibor", "sbom", "gitoid"] -license = "Apache-2.0" name = "gitoid" -readme = "../README.md" +readme = "README.md" repository = "https://github.com/omnibor/omnibor-rs" version = "0.5.1" +homepage.workspace = true +license.workspace = true +edition.workspace = true + [lib] crate-type = ["rlib", "cdylib"] diff --git a/omnibor/Cargo.toml b/omnibor/Cargo.toml index e16ada5..53939b5 100644 --- a/omnibor/Cargo.toml +++ b/omnibor/Cargo.toml @@ -1,15 +1,16 @@ [package] categories = ["cryptography", "development-tools"] description = "Reproducible software identity and fine-grained build dependency tracking." -edition = "2021" -homepage = "https://omnibor.io/" keywords = ["gitbom", "omnibor", "sbom"] -license = "Apache-2.0" name = "omnibor" readme = "../README.md" repository = "https://github.com/omnibor/omnibor-rs" version = "0.4.0" +homepage.workspace = true +license.workspace = true +edition.workspace = true + [dependencies] # Library dependencies diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..8d3c8c6 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xtask" +description = "Helper tasks for the omnibor project workspace" +version = "0.1.0" +publish = false +readme = "README.md" + +homepage.workspace = true +license.workspace = true +edition.workspace = true + +[dependencies] +anyhow = "1.0.80" +clap = "4.5.1" +duct = "0.13.7" +env_logger = "0.11.2" +log = "0.4.20" diff --git a/xtask/README.md b/xtask/README.md new file mode 100644 index 0000000..9c5c61d --- /dev/null +++ b/xtask/README.md @@ -0,0 +1,20 @@ +# `xtask` + +This is the `xtask` package for the OmniBOR Rust project. This implements +commonly-used project-wide commands for convenience. + +## Design Goals + +This crate has a few key design goals: + +- __Fast compilation__: This tool will get recompiled whenever changes are + made to it, and we want to empower contributors to the OmniBOR project to + make changes to `xtask` when they encounter a new task for the project that + they want to automate. To make this editing appealing, the write-edit-run + loop needs to be fast, which means fast compilation. +- __Minimal dependencies__: Related to the above, the `xtask` crate should + have a minimal number of dependencies, and where possible those dependencies + should be configured with the minimum number of features. +- __Easy to use__: The commands exposed by `xtask` should have as simple an + interface, and be as automatic, as possible. Fewer flags, fewer required + arguments, etc. diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs new file mode 100644 index 0000000..f390172 --- /dev/null +++ b/xtask/src/cli.rs @@ -0,0 +1,92 @@ +use clap::{arg, builder::PossibleValue, value_parser, ArgMatches, Command, ValueEnum}; +use std::fmt::{Display, Formatter, Result as FmtResult}; + +pub fn args() -> ArgMatches { + Command::new("xtask") + .about("Task runner for the OmniBOR Rust workspace") + .help_expected(true) + .subcommand( + Command::new("release") + .about("Release a new version of a workspace crate") + .arg( + arg!(-c --crate ) + .required(true) + .value_parser(value_parser!(Crate)) + .help("the crate to release"), + ) + .arg( + arg!(-b --bump ) + .required(true) + .value_parser(value_parser!(Bump)) + .help("the version to bump"), + ) + .arg( + arg!(--execute) + .required(false) + .value_parser(value_parser!(bool)) + .help("not a dry run, actually execute the release") + ), + ) + .get_matches() +} + +/// The crate to release; can be "gitoid" or "omnibor" +#[derive(Debug, Clone, Copy)] +pub enum Crate { + GitOid, + OmniBor, +} + +impl Display for Crate { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Crate::GitOid => write!(f, "gitoid"), + Crate::OmniBor => write!(f, "omnibor"), + } + } +} + +impl ValueEnum for Crate { + fn value_variants<'a>() -> &'a [Self] { + &[Crate::GitOid, Crate::OmniBor] + } + + fn to_possible_value(&self) -> Option { + Some(match self { + Crate::GitOid => PossibleValue::new("gitoid"), + Crate::OmniBor => PossibleValue::new("omnibor"), + }) + } +} + +/// The version to bump; can be "major", "minor", or "patch" +#[derive(Debug, Clone, Copy)] +pub enum Bump { + Major, + Minor, + Patch, +} + +impl Display for Bump { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Bump::Major => write!(f, "major"), + Bump::Minor => write!(f, "minor"), + Bump::Patch => write!(f, "patch"), + } + } +} + +impl ValueEnum for Bump { + fn value_variants<'a>() -> &'a [Self] { + &[Bump::Major, Bump::Minor, Bump::Patch] + } + + fn to_possible_value(&self) -> Option { + Some(match self { + Bump::Major => PossibleValue::new("major"), + Bump::Minor => PossibleValue::new("minor"), + Bump::Patch => PossibleValue::new("patch"), + }) + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..0947a29 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,17 @@ +mod cli; +mod pipeline; +mod release; + +use anyhow::Result; +use env_logger::{Env, Builder as LoggerBuilder}; + +fn main() -> Result<()> { + LoggerBuilder::from_env(Env::default().default_filter_or("info")).init(); + + let args = cli::args(); + + match args.subcommand() { + Some(("release", args)) => release::run(args), + Some(_) | None => Ok(()), + } +} diff --git a/xtask/src/pipeline.rs b/xtask/src/pipeline.rs new file mode 100644 index 0000000..2e52416 --- /dev/null +++ b/xtask/src/pipeline.rs @@ -0,0 +1,197 @@ +use anyhow::{bail, Error, Result}; +use std::error::Error as StdError; +use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::iter::DoubleEndedIterator; +use std::result::Result as StdResult; + +/// A mutable-reference [`Step`]` trait object. +pub type DynStep<'step> = &'step mut dyn Step; + +/// Run a pipeline of steps in order, rolling back if needed. +/// +/// The type signature here is a little funky, but it just means that +/// it takes as a parameter something which can be turned into an owning +/// iterator over mutable references to Step trait objects. +/// +/// This lets the user call it with just a plain array of trait objects, +/// also assisted by the `step!` macro. +pub fn run<'step, I, It>(steps: I) -> Result<()> +where + It: DoubleEndedIterator>, + I: IntoIterator, IntoIter = It>, +{ + fn inner<'step>(mut steps: impl DoubleEndedIterator>) -> Result<()> { + while let Some(step) = steps.next() { + if let Err(forward) = forward(step) { + while let Some(reverse_step) = steps.next_back() { + if let Err(backward) = backward(reverse_step) { + bail!(PipelineError::rollback(forward, backward)); + } + } + + bail!(PipelineError::forward(forward)); + } + } + + Ok(()) + } + + inner(steps.into_iter()) +} + +#[macro_export] +macro_rules! step { + ( $step:expr ) => {{ + &mut $step as &mut dyn Step + }}; +} + +/// A pipeline step which mutates the environment and can be undone. +pub trait Step { + /// The name of the step, to report to the user. + /// + /// # Note + /// + /// This should _always_ return a consistent name for the step, + /// not based on any logic related to the arguments passed to the + /// program. + /// + /// This is a method, not an associated function, to ensure that + /// the [`Step`] trait is object-safe. The `pipeline::run` function + /// runs steps through an iterator of `Step` trait objects, so this + /// is a requirement of the design. + fn name(&self) -> &'static str; + + /// Do the step. + /// + /// Steps are expected to clean up after themselves for the forward + /// direction if they fail after partial completion. The `undo` is + /// only for undoing a completely successful forward step if a later + /// step fails. + fn run(&mut self) -> Result<()>; + + /// Undo the step. + /// + /// This is run automatically by the pipelining system if there's + /// a need to rollback the pipeline because a later step failed. + /// + /// This is to ensure that any pipeline of operations operates + /// a single cohesive whole, either _all_ completing or _none_ + /// visibly completing by the end. + /// + /// Note that this trait does _not_ ensure graceful shutdown if + /// you cancel an operation with a kill signal before the `undo` + /// operation can complete. + fn undo(&mut self) -> Result<()>; +} + +/// Helper function to run a step forward and convert the error to [`StepError`] +fn forward(step: &mut dyn Step) -> StdResult<(), StepError> { + log::info!("running step '{}'", step.name()); + + step.run().map_err(|error| StepError { + name: step.name(), + error, + }) +} + +/// Helper function to run a step backward and convert the error to [`StepError`] +fn backward(step: &mut dyn Step) -> StdResult<(), StepError> { + log::info!("rolling back step '{}'", step.name()); + + step.undo().map_err(|error| StepError { + name: step.name(), + error, + }) +} + +/// An error from running a pipeline of steps. +#[derive(Debug)] +enum PipelineError { + /// An error arose during forward execution. + Forward { + /// The error produced by the offending step. + forward: StepError, + }, + /// An error arose during forward execution and also during rollback. + Rollback { + /// The name of the forward step that errored. + forward_name: &'static str, + + /// The name of the backward step that errored. + backward_name: &'static str, + + /// A combination of the backward and forward error types. + rollback: Error, + }, +} + +impl PipelineError { + /// Construct a forward error. + fn forward(forward: StepError) -> Self { + PipelineError::Forward { forward } + } + + /// Construct a rollback error. + fn rollback(forward: StepError, backward: StepError) -> Self { + let forward_name = forward.name; + let backward_name = backward.name; + let rollback = Error::new(backward).context(forward); + + PipelineError::Rollback { + forward_name, + backward_name, + rollback, + } + } +} + +impl Display for PipelineError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + PipelineError::Forward { forward } => { + write!(f, "{}, but rollback was successful", forward) + } + PipelineError::Rollback { + forward_name, + backward_name, + .. + } => write!( + f, + "step '{}' failed and step '{}' failed to rollback", + forward_name, backward_name + ), + } + } +} + +impl StdError for PipelineError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + PipelineError::Forward { forward } => Some(forward), + PipelineError::Rollback { rollback, .. } => Some(rollback.as_ref()), + } + } +} + +/// An error from a single pipeline step. +#[derive(Debug)] +struct StepError { + /// The name of the step that errored. + name: &'static str, + + /// The error the step produced. + error: Error, +} + +impl Display for StepError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + write!(f, "step '{}' failed", self.name) + } +} + +impl StdError for StepError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(self.error.as_ref()) + } +} diff --git a/xtask/src/release.rs b/xtask/src/release.rs new file mode 100644 index 0000000..21e0e78 --- /dev/null +++ b/xtask/src/release.rs @@ -0,0 +1,102 @@ +use crate::pipeline::{self, Step}; +use crate::step; +use crate::cli::{Crate, Bump}; +use anyhow::{anyhow, Result}; +use clap::ArgMatches; + +/* + # Run `git-cliff` to generate a changelog. + # Commit the changelog w/ commit msg in Conventional Commit fmt. + # Run `cargo-release` to release the new version. + # If anything fails, rollback prior steps in reverse order. + # Probably good for each step to have a "do" and "undo" operation. + # + # ... In fact I'll probably write this in Rust lol. + + # Need: + # + # - git + # - git-cliff + # - cargo + # - cargo-release + */ + + +/// Run the release command. +pub fn run(args: &ArgMatches) -> Result<()> { + let krate: Crate = *args.get_one("crate").ok_or_else(|| anyhow!("'--crate' is a required argument"))?; + let bump: Bump = *args.get_one("bump").ok_or_else(|| anyhow!("'--bump' is a required argument"))?; + + log::info!("running 'release', bumping the {} number for crate '{}'", bump, krate); + + pipeline::run([ + step!(CheckDependencies), + step!(GenerateChangelog), + step!(CommitChangelog), + step!(ReleaseCrate), + ]) +} + +struct CheckDependencies; + +impl Step for CheckDependencies { + fn name(&self) -> &'static str { + "check-dependencies" + } + + fn run(&mut self) -> Result<()> { + Ok(()) + } + + fn undo(&mut self) -> Result<()> { + Ok(()) + } +} + +struct GenerateChangelog; + +impl Step for GenerateChangelog { + fn name(&self) -> &'static str { + "generate-changelog" + } + + fn run(&mut self) -> Result<()> { + Ok(()) + } + + fn undo(&mut self) -> Result<()> { + Ok(()) + } +} + +struct CommitChangelog; + +impl Step for CommitChangelog { + fn name(&self) -> &'static str { + "commit-changelog" + } + + fn run(&mut self) -> Result<()> { + Ok(()) + } + + fn undo(&mut self) -> Result<()> { + Ok(()) + } +} + +struct ReleaseCrate; + +impl Step for ReleaseCrate { + fn name(&self) -> &'static str { + "release-crate" + } + + fn run(&mut self) -> Result<()> { + Ok(()) + } + + fn undo(&mut self) -> Result<()> { + Ok(()) + } +}