diff --git a/.gitignore b/.gitignore index 263876a38b..187c6656e8 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ VERSION .env .vscode/ vendor/ -*.tmp \ No newline at end of file +*.tmp +CHANGELOG-generated.md diff --git a/Cargo.lock b/Cargo.lock index b9b5ccdf53..a1b9c0a3ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1885,6 +1885,21 @@ dependencies = [ "syn", ] +[[package]] +name = "devops-cli" +version = "0.0.0" +dependencies = [ + "anyhow", + "clap 4.1.8", + "semver 1.0.16", + "serde", + "serde_json", + "strum", + "subprocess", + "tracing", + "tracing-subscriber", +] + [[package]] name = "dialoguer" version = "0.10.3" @@ -6681,6 +6696,9 @@ name = "strum" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" @@ -6695,6 +6713,16 @@ dependencies = [ "syn", ] +[[package]] +name = "subprocess" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2e86926081dda636c546d8c5e641661049d7562a68f5488be4a1f7f66f6086" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "subtle" version = "2.4.1" diff --git a/Cargo.toml b/Cargo.toml index 34ff8e62b7..511a04ff11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,8 @@ members = [ "crates/cdk", "crates/cargo-builder", "connector/json-test-connector", - "connector/sink-test-connector" + "connector/sink-test-connector", + "release-tools/devops-cli", ] exclude = ["smartmodule/regex-filter"] diff --git a/cliff.toml b/cliff.toml index 6c7c9e8c69..262d736d9e 100644 --- a/cliff.toml +++ b/cliff.toml @@ -1,14 +1,14 @@ # configuration file for git-cliff (0.1.0) [changelog] -# changelog header -header = """ -# Changelog\n -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n -""" +## changelog header +#header = """ +## Changelog\n +#All notable changes to this project will be documented in this file. +# +#The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +#and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n +#""" # template for the changelog body # https://tera.netlify.app/docs/#introduction body = """ @@ -27,9 +27,9 @@ body = """ # remove the leading and trailing whitespace from the template trim = true # changelog footer -footer = """ - -""" +#footer = """ +# +#""" [git] # parse the commits based on https://www.conventionalcommits.org @@ -108,11 +108,11 @@ commit_parsers = [ ] # filter out the commits that are not matched by commit parsers -filter_commits = true +filter_commits = false # glob pattern for matching git tags tag_pattern = "v[0-9]*" # regex for skipping tags -skip_tags = ".*-rc|.*-alpha|.*-beta" +#skip_tags = ".*-rc|.*-alpha|.*-beta" # regex for ignoring tags ignore_tags = "" # sort the tags chronologically diff --git a/release-tools/devops-cli/Cargo.toml b/release-tools/devops-cli/Cargo.toml new file mode 100644 index 0000000000..139e2b68d0 --- /dev/null +++ b/release-tools/devops-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "devops-cli" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = { workspace = true } +subprocess = "0.2" +semver = "1.0" +clap = { workspace = true , features = ["derive"] } +tracing-subscriber = { version ="0.3.16", features = ["env-filter"] } +tracing = "0.1" +strum = { version = "0.24", features = ["derive"] } + +#strum_macros = "0.24" + +serde = { version = "1.0.148", features = ["derive"] } +serde_json = "1.0.89" +#thiserror = "1.0.37" +#toml = "0.5" diff --git a/release-tools/devops-cli/src/changelog/mod.rs b/release-tools/devops-cli/src/changelog/mod.rs new file mode 100644 index 0000000000..aa72114242 --- /dev/null +++ b/release-tools/devops-cli/src/changelog/mod.rs @@ -0,0 +1,142 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{Result}; +use subprocess::{Exec, Redirection}; +use semver::{Version, Prerelease, BuildMetadata}; +use clap::Parser; +use tracing::info; + +#[derive(Debug, Parser)] +pub struct UpdateChangelogOpt { + /// Path to git-cliff config + #[clap(long, default_value = "./cliff.toml")] + git_cliff_config: PathBuf, + + /// Release version to use. Defaults to VERSION + #[clap(long)] + release_version: Option, + + /// Previous version to use. Defaults to previous stable patch version + #[clap(long)] + prev_version: Option, + + /// Git commit range start for git-cliff. Defaults to previous stable + #[clap(long)] + range_start: Option, + + /// Git commit range end for git-cliff. Defaults to HEAD + #[clap(long)] + range_end: Option, + + /// Target path to generated changelog + #[clap(long, default_value = "CHANGELOG-generated.md", group = "output")] + changelog_out: PathBuf, + + /// Generate output as json to stdout + #[clap(long, action, group = "output")] + json: bool, + + /// Print out debugging info + #[clap(long, action, group = "output")] + verbose: bool, +} + +impl UpdateChangelogOpt { + pub fn execute(&self) -> Result<()> { + let version_str = self.get_target_version()?; + let version: Version = version_str.parse()?; + let previous_stable = self.get_previous_stable(&version)?; + let (range_start, range_end) = self.get_commit_range(&previous_stable); + let tag_range = format!("{range_start}..{range_end}"); + let changelog_path: String = self.changelog_out.display().to_string(); + let git_cliff_config = self.git_cliff_config.display().to_string(); + + let cmd = "git"; + let mut args = vec![ + "cliff", + "--config", + &git_cliff_config, + "--tag", + &version_str, + &tag_range, + ]; + + if self.json { + args.push("--context") + } else { + args.push("-o"); + args.push(&changelog_path); + } + + // We want to be able to pipe json output to `jq`, + if self.verbose { + info!("Previous: v{previous_stable}"); + info!("Current v{version}"); + info!("Range: {tag_range}"); + info!("Output file: {changelog_path}"); + info!("Git-cliff config: {git_cliff_config}"); + + args.push("--verbose"); + } + + if !self.json { + info!("Generating changelog {tag_range}") + } + + let stream = Exec::cmd(cmd) + .args(&args) + .stdout(Redirection::Pipe) + .stderr(Redirection::Merge) + .capture()? + .stdout_str(); + + println!("{stream}"); + + Ok(()) + } + + /// Return the release version of the previous patch release, unless `--prev-version` is used + fn get_previous_stable(&self, version: &Version) -> Result { + let previous_stable: Version = if let Some(prev) = &self.prev_version { + prev.parse()? + } else { + Version { + major: version.major, + minor: version.minor, + patch: version.patch - 1, + pre: Prerelease::EMPTY, + build: BuildMetadata::EMPTY, + } + }; + Ok(previous_stable) + } + + /// Read the version from VERSION file, unless `--release-version` used + fn get_target_version(&self) -> Result { + let version_str: String = if let Some(v) = &self.release_version { + v.to_string() + } else { + fs::read_to_string("VERSION")? + }; + Ok(version_str) + } + + /// Returns range of `v..HEAD`, + /// unless we override with `--range-start` or `--range-end` + fn get_commit_range(&self, previous_stable: &Version) -> (String, String) { + let range_start = if let Some(start) = &self.range_start { + start.to_string() + } else { + format!("v{previous_stable}") + }; + + let range_end = if let Some(end) = &self.range_end { + end.to_string() + } else { + "HEAD".to_string() + }; + + (range_start, range_end) + } +} diff --git a/release-tools/devops-cli/src/main.rs b/release-tools/devops-cli/src/main.rs new file mode 100644 index 0000000000..a50aa48acc --- /dev/null +++ b/release-tools/devops-cli/src/main.rs @@ -0,0 +1,45 @@ +mod changelog; +use changelog::UpdateChangelogOpt; + +mod repo_version; +use repo_version::UpdateVersionOpt; + +use anyhow::{Result}; +use clap::Parser; + +use tracing_subscriber::filter::{EnvFilter, LevelFilter}; + +#[derive(Debug, Parser)] +struct FluvioDevOpsOpt { + #[clap(subcommand)] + command: FluvioDevOpsCmd, +} + +#[derive(Debug, Parser)] +enum FluvioDevOpsCmd { + /// Generates the most recent changelog for the repo using `git cliff`. + UpdateChangelog(UpdateChangelogOpt), + /// Modify the version + UpdateVersion(UpdateVersionOpt), +} + +fn main() -> Result<()> { + let _ = tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into())) + .try_init(); + + let opt = FluvioDevOpsOpt::parse(); + + opt.command.execute()?; + + Ok(()) +} + +impl FluvioDevOpsCmd { + pub fn execute(&self) -> Result<()> { + match self { + Self::UpdateChangelog(update_changelog_opt) => update_changelog_opt.execute(), + Self::UpdateVersion(update_version_opt) => update_version_opt.execute(), + } + } +} diff --git a/release-tools/devops-cli/src/repo_version/mod.rs b/release-tools/devops-cli/src/repo_version/mod.rs new file mode 100644 index 0000000000..a161cea77c --- /dev/null +++ b/release-tools/devops-cli/src/repo_version/mod.rs @@ -0,0 +1,346 @@ +use std::fs; +use std::fs::File; +use std::io::{Write}; + +use semver::{Version, Prerelease, BuildMetadata}; +use anyhow::{Result, anyhow}; +use clap::{Parser, ValueEnum}; +use tracing::{info, error}; +use serde::{Serialize, Deserialize}; +use strum::{EnumString, EnumVariantNames, Display, VariantNames}; + +const VERSION: &str = "VERSION"; +const DEFAULT_PRERELEASE_ITER: u32 = 1; + +/// Dev.n -> Alpha.n -> Beta.n -> Release -> Dev.n ... +#[derive( + Debug, + Parser, + Clone, + ValueEnum, + PartialEq, + Serialize, + Deserialize, + EnumString, + Display, + Default, + EnumVariantNames, +)] +#[strum(serialize_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum VersionBump { + None, + /// `x.y.z-dev.(# + 1)` + #[default] + Dev, + /// `x.y.z-alpha.(# + 1)` + Alpha, + /// `x.y.z-beta.(# + 1)` + Beta, + /// `x.y.z` + Release, + /// `x.y.(z + 1)` + Patch, + /// `x.(y + 1).z` + Minor, + /// `(x + 1).y.z` + Major, +} + +#[derive(Debug, Parser)] +pub struct UpdateVersionOpt { + #[clap(value_enum, group = "version", default_value = "none")] + bump: VersionBump, + #[clap(long, group = "version")] + set: Option, +} + +impl UpdateVersionOpt { + pub fn execute(&self) -> Result<()> { + if let Some(version) = &self.set { + info!("Write version {version} to {VERSION}"); + + let parsed: Version = version.clone().parse()?; + + if self.validate_prerelease(&parsed.pre) { + let path = VERSION; + let mut output = File::create(path)?; + let line = version.to_string(); + + info!("Next version: {line}"); + write!(output, "{line}")?; + + return Ok(()); + } else { + return Err(anyhow!( + "Prerelease is not in format: .<#iter> - ex alpha.1" + )); + } + } + + let version_str = fs::read_to_string(VERSION)?; + let version: Version = version_str.parse()?; + + info!("Current: {version:?}"); + + if self.bump == VersionBump::None && self.set.is_none() { + return Err(anyhow!( + "Choose a version bump type {:?} or use --set ", + VersionBump::VARIANTS + )); + } + + let next = self.update_version(version)?; + + let path = VERSION; + let mut output = File::create(path)?; + let line = format!("{next}"); + write!(output, "{line}")?; + + Ok(()) + } + + /// Calculate the version based on `VersionBump` + fn update_version(&self, version: Version) -> Result { + let next = match &self.bump { + VersionBump::Major => Version { + major: version.major + 1, + minor: version.minor, + patch: version.patch, + pre: Prerelease::EMPTY, + build: BuildMetadata::EMPTY, + }, + VersionBump::Minor => Version { + major: version.major, + minor: version.minor + 1, + patch: version.patch, + pre: Prerelease::EMPTY, + build: BuildMetadata::EMPTY, + }, + VersionBump::Patch => Version { + major: version.major, + minor: version.minor, + patch: version.patch + 1, + pre: Prerelease::EMPTY, + build: BuildMetadata::EMPTY, + }, + VersionBump::Release => Version { + major: version.major, + minor: version.minor, + patch: version.patch, + pre: Prerelease::EMPTY, + build: BuildMetadata::EMPTY, + }, + _ => Version { + major: version.major, + minor: version.minor, + patch: self.prerelease_patch(&version)?, + pre: self.next_prerelease(&version)?, + build: BuildMetadata::EMPTY, + }, + }; + + info!("Next version: {next}"); + Ok(next) + } + + /// Returns the patch version depending on whether we're currently in a prerelease or a stable release. + fn prerelease_patch(&self, version: &Version) -> Result { + let patch = if version.pre.is_empty() { + version.patch + 1 + } else { + version.patch + }; + + Ok(patch) + } + + /// Loosely checks that our naming conventions are being followed. + /// Will accept form with `-` instead of `.` (i.e., `-#`). + /// + /// If you use `--set` and use `-`, it will be written. + /// But any prerelease bump will correct it to use `.` + fn validate_prerelease(&self, pre: &Prerelease) -> bool { + if pre.is_empty() { + true + } else { + let pre = pre.as_str(); + let current: Vec<&str> = pre.split(['.', '-']).collect(); + info!("stage: {}", ¤t[0]); + + if !current.is_empty() && current.len() != 2 { + error!("{} is not in format: .<#iter> - ex alpha.1", pre); + false + } else if !VersionBump::VARIANTS.contains(¤t[0]) { + error!("{} is not one of our prerelease stages", ¤t[0]); + false + } else { + info!("Prerelease acceptable format: {}", pre); + true + } + } + } + + /// Increase the iteration of the prerelease stage, if the stage matches + /// + /// If we're on a release version (i.e., the current version has no prerelease, ex. `0.10.5`), + /// the next bump will also increase minor number. + /// + /// So a "dev" bump from `0.10.5` will be `0.10.6-dev.1` + /// + /// If the file version uses `-` as a iteration separator, + /// this will enforce using `.` so `semver` can use comparison operators (`<`, `>`, etc.) + fn next_prerelease(&self, version: &Version) -> Result { + if !(self.bump == VersionBump::Dev + || self.bump == VersionBump::Alpha + || self.bump == VersionBump::Beta) + { + return Ok(Prerelease::EMPTY); + } + + if version.pre.is_empty() { + Ok(default_prerelease()?) + } else { + if !self.validate_prerelease(&version.pre) { + return Err(anyhow!( + "Prerelease is not in format: .<#iter> - ex alpha.1" + )); + } + + let current: Vec<&str> = version.pre.as_str().split(['.', '-']).collect(); + + let current_stage = match current[0] { + "dev" => VersionBump::Dev, + "alpha" => VersionBump::Alpha, + "beta" => VersionBump::Beta, + found => return Err(anyhow!("Unsupported prerelease stage: {found}")), + }; + let current_iter: u32 = current[1].parse()?; + info!("Iter: {current_iter}"); + + let next_prerelease = if self.bump != current_stage { + format!("{}.{}", self.bump, DEFAULT_PRERELEASE_ITER) + } else { + format!("{}.{}", self.bump, current_iter + 1) + }; + info!("Next prerelease: {next_prerelease}"); + + Ok(Prerelease::new(&next_prerelease)?) + } + } +} + +fn default_prerelease() -> Result { + let default_pre = format!("{}.{}", VersionBump::default(), DEFAULT_PRERELEASE_ITER); + Ok(Prerelease::new(&default_pre)?) +} + +#[cfg(test)] +mod test { + + use super::*; + + #[test] + fn test_prerelease() -> Result<()> { + let opt = UpdateVersionOpt { + bump: VersionBump::None, + set: None, + }; + + // Pass + assert!(opt.validate_prerelease(&Version::parse("0.1.0-dev-1")?.pre)); + assert!(opt.validate_prerelease(&Version::parse("0.1.0-alpha.0")?.pre)); + assert!(opt.validate_prerelease(&Version::parse("0.2.0-beta.6542")?.pre)); + + // fail + assert!(!opt.validate_prerelease(&Version::parse("0.6.7-gamma.1")?.pre)); + assert!(!opt.validate_prerelease(&Version::parse("0.6.7-alpha")?.pre)); + + Ok(()) + } + + #[test] + fn test_next_prerelease() -> Result<()> { + let dev = UpdateVersionOpt { + bump: VersionBump::Dev, + set: None, + }; + + assert_eq!( + dev.next_prerelease(&Version::parse("0.1.0-dev-1")?)? + .as_str(), + "dev.2" + ); + + assert_eq!( + dev.next_prerelease(&Version::parse("0.1.0-dev.2")?)? + .as_str(), + "dev.3" + ); + + let beta = UpdateVersionOpt { + bump: VersionBump::Beta, + set: None, + }; + + assert_eq!( + beta.next_prerelease(&Version::parse("0.1.0-dev.3")?)? + .as_str(), + "beta.1" + ); + + assert_eq!( + dev.next_prerelease(&Version::parse("0.1.0-beta.1")?)? + .as_str(), + "dev.1" + ); + + let release = UpdateVersionOpt { + bump: VersionBump::Release, + set: None, + }; + + assert_eq!( + release + .next_prerelease(&Version::parse("0.1.0-dev.1")?)? + .as_str(), + "" + ); + + assert_eq!( + dev.next_prerelease(&Version::parse("0.1.0")?)?.as_str(), + "dev.1" + ); + + Ok(()) + } + + #[test] + fn test_update_version() -> Result<()> { + let dev = UpdateVersionOpt { + bump: VersionBump::Dev, + set: None, + }; + + assert_eq!( + dev.update_version(Version::parse("0.1.0")?)?, + Version::parse("0.1.1-dev.1")? + ); + assert_eq!( + dev.update_version(Version::parse("0.1.1-dev.1")?)?, + Version::parse("0.1.1-dev.2")? + ); + + let release = UpdateVersionOpt { + bump: VersionBump::Release, + set: None, + }; + + assert_eq!( + release.update_version(Version::parse("0.1.1-dev.1")?)?, + Version::parse("0.1.1")? + ); + + Ok(()) + } +}