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