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;
24const AUTOMATED_RELEASE_BRANCH_PREFIX: &str = "automation/release";
25
26/// How the release tag is created. Annotated tags are the default: they
27/// carry a tagger identity and satisfy `git cat-file -t == "tag"` provenance
28/// checks. The moving major alias tag is always lightweight.
29#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
30pub enum TagStyle {
31    #[default]
32    Annotated,
33    Lightweight,
34}
35
36#[derive(Debug, Clone, Eq, PartialEq)]
37pub struct ReleaseOptions {
38    pub repo_root: PathBuf,
39    pub owner: String,
40    pub repo: String,
41    pub base_branch: Option<String>,
42    /// Forced bump level; `None` derives it from conventional commits.
43    pub bump: Option<BumpLevel>,
44    pub tag_prefix: String,
45    pub tag_style: TagStyle,
46    pub update_major_alias: bool,
47    /// Commit message template; `{version}` is replaced with the new version.
48    pub commit_message: String,
49    /// Create or refresh an automation-owned release branch and pull request
50    /// instead of publishing directly to the base branch.
51    pub create_pr: bool,
52    pub release_branch: String,
53    pub dry_run: bool,
54}
55
56#[derive(Debug, Clone, Copy, Eq, PartialEq)]
57pub enum ReleaseOutcome {
58    Released,
59    PullRequestCreated,
60    PullRequestUpdated,
61    DryRun,
62    SkippedNoReleasableChanges,
63    SkippedRace,
64    SkippedTagExists,
65}
66
67#[derive(Debug, Clone, Eq, PartialEq)]
68pub struct ReleaseReport {
69    pub outcome: ReleaseOutcome,
70    pub current_version: String,
71    pub next_version: Option<String>,
72    pub bump: Option<BumpLevel>,
73    pub tag: Option<String>,
74    pub major_alias: Option<String>,
75    pub commit_sha: Option<String>,
76    pub release_url: Option<String>,
77    pub pull_request_number: Option<u64>,
78    pub pull_request_url: Option<String>,
79    pub release_branch: Option<String>,
80    pub notes: Option<String>,
81    pub commits_analyzed: usize,
82    pub commit_range_truncated: bool,
83    pub files_updated: Vec<PathBuf>,
84}
85
86impl ReleaseReport {
87    /// Render `$GITHUB_OUTPUT` lines. Keys are always present; values are
88    /// empty when the outcome did not produce them.
89    pub fn github_outputs(&self, notes_file: &Path) -> String {
90        let notes_path =
91            if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
92        format!(
93            "released={}\nversion={}\ntag={}\nrelease-url={}\nrelease-pr-number={}\nrelease-pr-url={}\nrelease-branch={}\nnotes-file={notes_path}\n",
94            self.outcome == ReleaseOutcome::Released,
95            self.next_version.as_deref().unwrap_or_default(),
96            self.tag.as_deref().unwrap_or_default(),
97            self.release_url.as_deref().unwrap_or_default(),
98            self.pull_request_number.map(|number| number.to_string()).unwrap_or_default(),
99            self.pull_request_url.as_deref().unwrap_or_default(),
100            self.release_branch.as_deref().unwrap_or_default(),
101        )
102    }
103}
104
105#[derive(Debug)]
106struct Analysis {
107    branch: String,
108    head_sha: String,
109    current_version: String,
110    current: Version,
111    commits: Vec<ConventionalCommit>,
112    truncated: bool,
113}
114
115#[derive(Debug)]
116struct PreparedRelease {
117    branch: String,
118    head_sha: String,
119    next_version: Version,
120    tag: String,
121    plan: versioning::VersionRewritePlan,
122}
123
124#[derive(Debug, Clone)]
125pub struct ReleasePublisher {
126    github: GitHubClient,
127}
128
129impl ReleasePublisher {
130    #[must_use]
131    pub const fn new(github: GitHubClient) -> Self {
132        Self { github }
133    }
134
135    pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
136        self.github.ensure_token()?;
137        if options.create_pr {
138            validate_release_branch(&options.release_branch)?;
139        }
140        let analysis = self.analyze(options)?;
141        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
142        let mut report = initial_report(&analysis, bump);
143
144        let Some(bump_level) = bump else {
145            return Ok(report);
146        };
147
148        let next_version = conventional::bump_version(&analysis.current, bump_level);
149        let next = next_version.to_string();
150        let tag = format!("{}{next}", options.tag_prefix);
151        report.notes =
152            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
153        report.next_version = Some(next);
154        report.tag = Some(tag.clone());
155
156        if self
157            .github
158            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
159            .is_some()
160        {
161            report.outcome = ReleaseOutcome::SkippedTagExists;
162            return Ok(report);
163        }
164
165        let plan = versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
166        report.files_updated = plan.file_updates.iter().map(|update| update.file.clone()).collect();
167
168        if options.dry_run {
169            report.outcome = ReleaseOutcome::DryRun;
170            return Ok(report);
171        }
172
173        let prepared = PreparedRelease {
174            branch: analysis.branch,
175            head_sha: analysis.head_sha,
176            next_version,
177            tag,
178            plan,
179        };
180        self.publish(options, &prepared, &mut report)?;
181        Ok(report)
182    }
183
184    fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
185        let owner = &options.owner;
186        let repo = &options.repo;
187        let branch = match options.base_branch.as_deref().map(str::trim) {
188            Some(branch) if !branch.is_empty() => branch.to_owned(),
189            _ => self.github.default_branch(owner, repo)?,
190        };
191        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
192
193        let current_version = versioning::current_version(&options.repo_root)?;
194        let current = Version::parse(&current_version).with_context(|| {
195            format!("invalid current version '{current_version}' in Cargo.toml")
196        })?;
197
198        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
199        let range = match &last_tag {
200            Some(tag) => {
201                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
202            }
203            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
204        };
205
206        Ok(Analysis {
207            branch,
208            head_sha,
209            current_version,
210            current,
211            commits: conventional::classify_commits(&range.commits),
212            truncated: range.truncated,
213        })
214    }
215
216    /// Publish the prepared release. The cheap head re-read gives a clear
217    /// skip; the fast-forward-only ref update is the authoritative
218    /// compare-and-swap against a branch that advanced mid-run.
219    fn publish(
220        &self,
221        options: &ReleaseOptions,
222        prepared: &PreparedRelease,
223        report: &mut ReleaseReport,
224    ) -> Result<()> {
225        let commit_sha = self.build_commit(options, prepared)?;
226
227        let current_head =
228            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
229        if current_head != prepared.head_sha {
230            report.outcome = ReleaseOutcome::SkippedRace;
231            return Ok(());
232        }
233        report.commit_sha = Some(commit_sha.clone());
234
235        if options.create_pr {
236            return self.publish_pull_request(options, prepared, &commit_sha, report);
237        }
238
239        if !self.github.update_ref_fast_forward(
240            &options.owner,
241            &options.repo,
242            &format!("heads/{}", prepared.branch),
243            &commit_sha,
244        )? {
245            report.outcome = ReleaseOutcome::SkippedRace;
246            return Ok(());
247        }
248
249        self.finalize(options, prepared, &commit_sha, report)
250    }
251
252    fn publish_pull_request(
253        &self,
254        options: &ReleaseOptions,
255        prepared: &PreparedRelease,
256        commit_sha: &str,
257        report: &mut ReleaseReport,
258    ) -> Result<()> {
259        let owner = &options.owner;
260        let repo = &options.repo;
261        let branch_ref = format!("heads/{}", options.release_branch);
262        self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
263
264        let title = format!("chore(release): {}", prepared.tag);
265        let notes = report.notes.clone().unwrap_or_default();
266        let body = format!(
267            "Automated release update for `{}`.\n\nThis PR updates the package version from `{}` to `{}`. Merging it into `{}` allows the release workflow to publish the tag and GitHub release.\n\n{}\n\n<!-- automated-release-branch: {} -->",
268            prepared.tag,
269            report.current_version,
270            prepared.next_version,
271            prepared.branch,
272            notes,
273            options.release_branch,
274        );
275        let pull_request = self.upsert_release_pull_request(
276            owner,
277            repo,
278            &options.release_branch,
279            &prepared.branch,
280            &title,
281            &body,
282            report,
283        )?;
284        report.pull_request_number = Some(pull_request.number);
285        report.pull_request_url = Some(pull_request.url);
286        report.release_branch = Some(options.release_branch.clone());
287        Ok(())
288    }
289
290    fn update_release_branch(
291        &self,
292        owner: &str,
293        repo: &str,
294        branch_ref: &str,
295        commit_sha: &str,
296    ) -> Result<()> {
297        if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
298            self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
299        } else {
300            self.github.create_ref(owner, repo, branch_ref, commit_sha)
301        }
302    }
303
304    fn upsert_release_pull_request(
305        &self,
306        owner: &str,
307        repo: &str,
308        head: &str,
309        base: &str,
310        title: &str,
311        body: &str,
312        report: &mut ReleaseReport,
313    ) -> Result<crate::github::PullRequestInfo> {
314        let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
315        if let Some(existing) = existing {
316            let updated =
317                self.github.update_pull_request(owner, repo, existing.number, title, body)?;
318            report.outcome = ReleaseOutcome::PullRequestUpdated;
319            Ok(updated)
320        } else {
321            let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
322            report.outcome = ReleaseOutcome::PullRequestCreated;
323            Ok(created)
324        }
325    }
326
327    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
328        let owner = &options.owner;
329        let repo = &options.repo;
330        let repo_root = options.repo_root.canonicalize().with_context(|| {
331            format!("failed to resolve repository root '{}'", options.repo_root.display())
332        })?;
333
334        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
335        let mut tree_entries = Vec::new();
336        for update in &prepared.plan.file_updates {
337            let path = remote::relative_repository_path(&repo_root, &update.file)?;
338            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
339            tree_entries.push(TreeEntry { path, sha: blob_sha });
340        }
341        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
342
343        let message =
344            options.commit_message.replace("{version}", &prepared.next_version.to_string());
345        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
346    }
347
348    fn finalize(
349        &self,
350        options: &ReleaseOptions,
351        prepared: &PreparedRelease,
352        commit_sha: &str,
353        report: &mut ReleaseReport,
354    ) -> Result<()> {
355        let owner = &options.owner;
356        let repo = &options.repo;
357        let tag_target = match options.tag_style {
358            TagStyle::Annotated => self.github.create_annotated_tag(
359                owner,
360                repo,
361                &prepared.tag,
362                &format!("Release {}", prepared.tag),
363                commit_sha,
364            )?,
365            TagStyle::Lightweight => commit_sha.to_owned(),
366        };
367        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
368
369        if options.update_major_alias {
370            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
371            let alias_ref = format!("tags/{alias}");
372            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
373                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
374            } else {
375                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
376            }
377            report.major_alias = Some(alias);
378        }
379
380        let notes = report.notes.clone().unwrap_or_default();
381        let release = self.github.create_release(
382            owner,
383            repo,
384            &prepared.tag,
385            &format!("Release {}", prepared.tag),
386            &notes,
387            commit_sha,
388        )?;
389        report.release_url = Some(release.url);
390        report.outcome = ReleaseOutcome::Released;
391        Ok(())
392    }
393}
394
395fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
396    ReleaseReport {
397        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
398        current_version: analysis.current_version.clone(),
399        next_version: None,
400        bump,
401        tag: None,
402        major_alias: None,
403        commit_sha: None,
404        release_url: None,
405        pull_request_number: None,
406        pull_request_url: None,
407        release_branch: None,
408        notes: None,
409        commits_analyzed: analysis.commits.len(),
410        commit_range_truncated: analysis.truncated,
411        files_updated: Vec::new(),
412    }
413}
414
415fn validate_release_branch(branch: &str) -> Result<()> {
416    if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
417        && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
418    {
419        anyhow::bail!(
420            "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
421        );
422    }
423    Ok(())
424}
425
426// Tests live in a sibling file to keep this module within the repository's
427// file-size lint budget; they remain `super::`-scoped unit tests.
428#[cfg(test)]
429#[path = "release_tests.rs"]
430#[allow(clippy::significant_drop_tightening)]
431mod tests;