Skip to main content

holochain_release_util/
lib.rs

1use crate::prepare_release::{
2    generate_changelog, get_next_version, get_released_version_tag, run_semver_checks, set_version,
3};
4use crate::publish_release::{create_gh_release, is_releasable_change, publish};
5use crate::utils::{get_current_version_from_cargo_toml, get_revision_for_tag, push_tag, tag};
6use anyhow::Context;
7use std::fs::read_to_string;
8use std::path::Path;
9
10mod prepare_release;
11mod publish_release;
12pub mod utils;
13
14pub const RELEASE_LABEL: &str = "hra-release";
15
16/// Prepares changes for the next release.
17///
18/// - Runs semver checks on the current branch to ensure it is releasable with
19///   the requested configuration.
20/// - Generates a changelog using `git-cliff` based on the provided configuration.
21/// - Sets the version in the `Cargo.toml` files to the next version determined by `git-cliff`.
22pub fn prepare_release(
23    dir: impl AsRef<Path>,
24    cliff_config: String,
25    force_version: Option<String>,
26    skip_semver_checks: bool,
27    i_am_so_sorry_but_my_features_clash: bool,
28) -> anyhow::Result<()> {
29    let repository = git2::Repository::open(&dir).context("Failed to open git repository")?;
30
31    let force_tag = input_version_to_version_tag(force_version)?;
32
33    // Generate the changelog and check what version it chose.
34    generate_changelog(&dir, &cliff_config, &force_tag)?;
35    let next_version_tag = get_next_version(&dir, &cliff_config, &force_tag)?;
36
37    // Set the version in the Cargo.toml files.
38    set_version(&dir, &next_version_tag)?;
39
40    // Ensure the changes on the current branch pass semver checks.
41    if skip_semver_checks {
42        let msg = "Semver checks were skipped for this release. Ensure the version bump is intentional.";
43        if std::env::var("GITHUB_ACTIONS").as_deref() == Ok("true") {
44            println!("::warning title=Semver Checks Skipped::{msg}");
45        } else {
46            eprintln!("WARNING: {msg}");
47        }
48    } else {
49        match get_released_version_tag(&dir, &cliff_config, &force_tag) {
50            Ok(released_version_tag) => {
51                println!("Retrieving revision for tag: {}", released_version_tag);
52                let revision = get_revision_for_tag(&repository, &released_version_tag)?;
53                run_semver_checks(&dir, &revision, i_am_so_sorry_but_my_features_clash)?;
54            }
55            Err(e) => {
56                eprintln!("No previous release found, skipping semver checks: {e:?}");
57            }
58        }
59    }
60
61    Ok(())
62}
63
64/// Publishes a release if one is found.
65///
66/// - First checks whether the current HEAD commit is part of a releasable change. A change is
67///   releasable if the commit was introduced by a PR that has the `hra-release` label.
68/// - If a releasable change is found, it tags the current HEAD commit with the version from the
69///   `Cargo.toml` file.
70/// - Finally, it publishes the crates.
71pub fn publish_release(
72    dir: impl AsRef<Path>,
73    git_token: String,
74    danger_skip_releasable_changes_check: bool,
75    danger_skip_create_gh_release: bool,
76) -> anyhow::Result<()> {
77    let repository = git2::Repository::open(&dir).context("Failed to open git repository")?;
78
79    if !danger_skip_releasable_changes_check {
80        let maybe_pr_number = is_releasable_change(&repository, &dir)?;
81        let Some(pr_number) = maybe_pr_number else {
82            println!("Not a releasable change, stopping.");
83            return Ok(());
84        };
85        println!("Found releasable change with PR number: {}", pr_number);
86    }
87
88    let cargo_toml =
89        read_to_string(dir.as_ref().join("Cargo.toml")).context("Failed to read Cargo.toml")?;
90    let current_version = get_current_version_from_cargo_toml(&cargo_toml)
91        .context("Failed to find version in Cargo.toml")?;
92    let current_tag = format!("v{current_version}");
93
94    tag(&repository, &current_tag, &current_tag).context("Failed to tag the release")?;
95    println!("Tagged current HEAD with: {}", current_tag);
96
97    push_tag(&repository, &git_token, &current_tag).context("Failed to push tag to remote")?;
98    println!("Pushed tag to remote: {}", current_tag);
99
100    publish(&dir).context("Failed to publish crates")?;
101
102    if !danger_skip_create_gh_release {
103        create_gh_release(&dir, &current_tag).context("Failed to create GitHub release")?;
104    }
105
106    println!("Release-util completed successfully. Another successful release on the 📔📘!");
107
108    Ok(())
109}
110
111pub(crate) fn input_version_to_version_tag(
112    force_version: Option<String>,
113) -> anyhow::Result<Option<String>> {
114    let force_tag = match force_version {
115        Some(input) if input.is_empty() => None,
116        Some(maybe_version_tag) => match maybe_version_tag.strip_prefix("v") {
117            Some(version) => {
118                if semver::Version::parse(version).is_ok() {
119                    Some(maybe_version_tag)
120                } else {
121                    anyhow::bail!("Invalid version format: {}", version);
122                }
123            }
124            None => {
125                if semver::Version::parse(&maybe_version_tag).is_ok() {
126                    Some(format!("v{}", maybe_version_tag))
127                } else {
128                    anyhow::bail!("Invalid version format: {}", maybe_version_tag);
129                }
130            }
131        },
132        None => None,
133    };
134
135    Ok(force_tag)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn convert_input_version_to_version_tag() {
144        // Maps no input to None
145        assert_eq!(input_version_to_version_tag(None).unwrap(), None);
146
147        // Valid version with 'v' prefix remains unchanged
148        assert_eq!(
149            input_version_to_version_tag(Some("v1.2.3".to_string())).unwrap(),
150            Some("v1.2.3".to_string())
151        );
152
153        // Valid version gets prefixed with 'v'
154        assert_eq!(
155            input_version_to_version_tag(Some("1.2.3".to_string())).unwrap(),
156            Some("v1.2.3".to_string())
157        );
158
159        // Invalid semver is rejected
160        assert!(input_version_to_version_tag(Some("invalid".to_string())).is_err());
161        // Invalid semver with a 'v' prefix is rejected
162        assert!(input_version_to_version_tag(Some("vinvalid".to_string())).is_err());
163    }
164}