Skip to main content

github_actions_maintainer/
release.rs

1//! Automated release publishing driven by conventional commits.
2//!
3//! The publisher analyzes commits since the last release tag through the
4//! GitHub REST API, bumps the Cargo manifest version, and creates the release
5//! commit, tag, and GitHub Release without shelling out to git — so it runs in
6//! minimal containers. Failures after object creation but before the branch
7//! ref update leave only unreachable git objects behind, which GitHub
8//! garbage-collects; the window between tag creation and release creation is
9//! not auto-healed and requires a manual `gh release create` for the tag.
10
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result};
14use semver::Version;
15
16use crate::{
17    conventional::{self, BumpLevel, ConventionalCommit},
18    github::{GitHubClient, TreeEntry},
19    remote, versioning,
20};
21
22const MAX_COMPARE_PAGES: u32 = 10;
23const MAX_FIRST_RELEASE_PAGES: u32 = 3;
24
25#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct ReleaseOptions {
27    pub repo_root: PathBuf,
28    pub owner: String,
29    pub repo: String,
30    pub base_branch: Option<String>,
31    /// Forced bump level; `None` derives it from conventional commits.
32    pub bump: Option<BumpLevel>,
33    pub tag_prefix: String,
34    pub update_major_alias: bool,
35    /// Commit message template; `{version}` is replaced with the new version.
36    pub commit_message: String,
37    pub dry_run: bool,
38}
39
40#[derive(Debug, Clone, Copy, Eq, PartialEq)]
41pub enum ReleaseOutcome {
42    Released,
43    DryRun,
44    SkippedNoReleasableChanges,
45    SkippedRace,
46    SkippedTagExists,
47}
48
49#[derive(Debug, Clone, Eq, PartialEq)]
50pub struct ReleaseReport {
51    pub outcome: ReleaseOutcome,
52    pub current_version: String,
53    pub next_version: Option<String>,
54    pub bump: Option<BumpLevel>,
55    pub tag: Option<String>,
56    pub major_alias: Option<String>,
57    pub commit_sha: Option<String>,
58    pub release_url: Option<String>,
59    pub notes: Option<String>,
60    pub commits_analyzed: usize,
61    pub commit_range_truncated: bool,
62    pub files_updated: Vec<PathBuf>,
63}
64
65impl ReleaseReport {
66    /// Render `$GITHUB_OUTPUT` lines. Keys are always present; values are
67    /// empty when the outcome did not produce them.
68    pub fn github_outputs(&self, notes_file: &Path) -> String {
69        let notes_path =
70            if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
71        format!(
72            "released={}\nversion={}\ntag={}\nrelease-url={}\nnotes-file={notes_path}\n",
73            self.outcome == ReleaseOutcome::Released,
74            self.next_version.as_deref().unwrap_or_default(),
75            self.tag.as_deref().unwrap_or_default(),
76            self.release_url.as_deref().unwrap_or_default(),
77        )
78    }
79}
80
81#[derive(Debug)]
82struct Analysis {
83    branch: String,
84    head_sha: String,
85    current_version: String,
86    current: Version,
87    commits: Vec<ConventionalCommit>,
88    truncated: bool,
89}
90
91#[derive(Debug)]
92struct PreparedRelease {
93    branch: String,
94    head_sha: String,
95    next_version: Version,
96    tag: String,
97    plan: versioning::VersionRewritePlan,
98}
99
100#[derive(Debug, Clone)]
101pub struct ReleasePublisher {
102    github: GitHubClient,
103}
104
105impl ReleasePublisher {
106    #[must_use]
107    pub const fn new(github: GitHubClient) -> Self {
108        Self { github }
109    }
110
111    pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
112        self.github.ensure_token()?;
113        let analysis = self.analyze(options)?;
114        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
115        let mut report = initial_report(&analysis, bump);
116
117        let Some(bump_level) = bump else {
118            return Ok(report);
119        };
120
121        let next_version = conventional::bump_version(&analysis.current, bump_level);
122        let next = next_version.to_string();
123        let tag = format!("{}{next}", options.tag_prefix);
124        report.notes =
125            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
126        report.next_version = Some(next);
127        report.tag = Some(tag.clone());
128
129        if self
130            .github
131            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
132            .is_some()
133        {
134            report.outcome = ReleaseOutcome::SkippedTagExists;
135            return Ok(report);
136        }
137
138        let plan = versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
139        report.files_updated = plan.file_updates.iter().map(|update| update.file.clone()).collect();
140
141        if options.dry_run {
142            report.outcome = ReleaseOutcome::DryRun;
143            return Ok(report);
144        }
145
146        let prepared = PreparedRelease {
147            branch: analysis.branch,
148            head_sha: analysis.head_sha,
149            next_version,
150            tag,
151            plan,
152        };
153        self.publish(options, &prepared, &mut report)?;
154        Ok(report)
155    }
156
157    fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
158        let owner = &options.owner;
159        let repo = &options.repo;
160        let branch = match options.base_branch.as_deref().map(str::trim) {
161            Some(branch) if !branch.is_empty() => branch.to_owned(),
162            _ => self.github.default_branch(owner, repo)?,
163        };
164        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
165
166        let current_version = versioning::current_version(&options.repo_root)?;
167        let current = Version::parse(&current_version).with_context(|| {
168            format!("invalid current version '{current_version}' in Cargo.toml")
169        })?;
170
171        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
172        let range = match &last_tag {
173            Some(tag) => {
174                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
175            }
176            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
177        };
178
179        Ok(Analysis {
180            branch,
181            head_sha,
182            current_version,
183            current,
184            commits: conventional::classify_commits(&range.commits),
185            truncated: range.truncated,
186        })
187    }
188
189    /// Publish the prepared release. The cheap head re-read gives a clear
190    /// skip; the fast-forward-only ref update is the authoritative
191    /// compare-and-swap against a branch that advanced mid-run.
192    fn publish(
193        &self,
194        options: &ReleaseOptions,
195        prepared: &PreparedRelease,
196        report: &mut ReleaseReport,
197    ) -> Result<()> {
198        let commit_sha = self.build_commit(options, prepared)?;
199
200        let current_head =
201            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
202        if current_head != prepared.head_sha
203            || !self.github.update_ref_fast_forward(
204                &options.owner,
205                &options.repo,
206                &format!("heads/{}", prepared.branch),
207                &commit_sha,
208            )?
209        {
210            report.outcome = ReleaseOutcome::SkippedRace;
211            return Ok(());
212        }
213        report.commit_sha = Some(commit_sha.clone());
214
215        self.finalize(options, prepared, &commit_sha, report)
216    }
217
218    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
219        let owner = &options.owner;
220        let repo = &options.repo;
221        let repo_root = options.repo_root.canonicalize().with_context(|| {
222            format!("failed to resolve repository root '{}'", options.repo_root.display())
223        })?;
224
225        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
226        let mut tree_entries = Vec::new();
227        for update in &prepared.plan.file_updates {
228            let path = remote::relative_repository_path(&repo_root, &update.file)?;
229            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
230            tree_entries.push(TreeEntry { path, sha: blob_sha });
231        }
232        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
233
234        let message =
235            options.commit_message.replace("{version}", &prepared.next_version.to_string());
236        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
237    }
238
239    fn finalize(
240        &self,
241        options: &ReleaseOptions,
242        prepared: &PreparedRelease,
243        commit_sha: &str,
244        report: &mut ReleaseReport,
245    ) -> Result<()> {
246        let owner = &options.owner;
247        let repo = &options.repo;
248        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), commit_sha)?;
249
250        if options.update_major_alias {
251            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
252            let alias_ref = format!("tags/{alias}");
253            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
254                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
255            } else {
256                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
257            }
258            report.major_alias = Some(alias);
259        }
260
261        let notes = report.notes.clone().unwrap_or_default();
262        let release = self.github.create_release(
263            owner,
264            repo,
265            &prepared.tag,
266            &format!("Release {}", prepared.tag),
267            &notes,
268            commit_sha,
269        )?;
270        report.release_url = Some(release.url);
271        report.outcome = ReleaseOutcome::Released;
272        Ok(())
273    }
274}
275
276fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
277    ReleaseReport {
278        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
279        current_version: analysis.current_version.clone(),
280        next_version: None,
281        bump,
282        tag: None,
283        major_alias: None,
284        commit_sha: None,
285        release_url: None,
286        notes: None,
287        commits_analyzed: analysis.commits.len(),
288        commit_range_truncated: analysis.truncated,
289        files_updated: Vec::new(),
290    }
291}
292
293// Tests live in a sibling file to keep this module within the repository's
294// file-size lint budget; they remain `super::`-scoped unit tests.
295#[cfg(test)]
296#[path = "release_tests.rs"]
297#[allow(clippy::significant_drop_tightening)]
298mod tests;