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::{
12    fs,
13    path::{Path, PathBuf},
14};
15
16use anyhow::{Context, Result};
17use semver::Version;
18
19use crate::{
20    conventional::{self, BumpLevel, ConventionalCommit},
21    github::{GitHubClient, TreeEntry},
22    model::FileUpdate,
23    remote, versioning,
24};
25
26const MAX_COMPARE_PAGES: u32 = 10;
27const MAX_FIRST_RELEASE_PAGES: u32 = 3;
28const AUTOMATED_RELEASE_BRANCH_PREFIX: &str = "automation/release";
29
30/// How the release tag is created. Annotated tags are the default: they
31/// carry a tagger identity and satisfy `git cat-file -t == "tag"` provenance
32/// checks. The moving major alias tag is always lightweight.
33#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
34pub enum TagStyle {
35    #[default]
36    Annotated,
37    Lightweight,
38}
39
40/// Which part of the release a run performs.
41///
42/// A container action cannot pin a runtime image built from its own release,
43/// because an image tagged with the version only exists once the tag does.
44/// Splitting the release breaks that cycle: [`Self::Bump`] lands the version
45/// bump so an image can be built from the released version, and [`Self::Tag`]
46/// then pins that image and tags the result. Only the two Dockerfiles differ
47/// between the image's source and the tagged tree, and they do not affect the
48/// image, so the tag ships a runtime whose reported version is its own.
49#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
50pub enum ReleasePhase {
51    /// Bump, tag, and publish in a single run.
52    #[default]
53    All,
54    /// Commit the version bump and stop, leaving the version untagged.
55    Bump,
56    /// Tag and publish the version the manifest already holds, without bumping.
57    Tag,
58}
59
60#[derive(Debug, Clone, Eq, PartialEq)]
61pub struct ReleaseOptions {
62    pub repo_root: PathBuf,
63    pub owner: String,
64    pub repo: String,
65    pub base_branch: Option<String>,
66    /// Forced bump level; `None` derives it from conventional commits.
67    pub bump: Option<BumpLevel>,
68    pub tag_prefix: String,
69    pub tag_style: TagStyle,
70    pub update_major_alias: bool,
71    /// Commit message template; `{version}` is replaced with the new version.
72    pub commit_message: String,
73    /// Create or refresh an automation-owned release branch and pull request
74    /// instead of publishing directly to the base branch.
75    pub create_pr: bool,
76    pub release_branch: String,
77    pub dry_run: bool,
78    /// Extra working-tree files to stage into the release commit, on top of
79    /// the manifest and lockfile rewrites. Paths already covered by the
80    /// version rewrite are ignored so the rewrite stays authoritative.
81    pub extra_files: Vec<PathBuf>,
82    pub phase: ReleasePhase,
83}
84
85#[derive(Debug, Clone, Copy, Eq, PartialEq)]
86pub enum ReleaseOutcome {
87    Released,
88    /// The version bump was committed; the tag and release are still pending.
89    VersionCommitted,
90    PullRequestCreated,
91    PullRequestUpdated,
92    DryRun,
93    SkippedNoReleasableChanges,
94    SkippedRace,
95    SkippedTagExists,
96}
97
98#[derive(Debug, Clone, Eq, PartialEq)]
99pub struct ReleaseReport {
100    pub outcome: ReleaseOutcome,
101    pub current_version: String,
102    pub next_version: Option<String>,
103    pub bump: Option<BumpLevel>,
104    pub tag: Option<String>,
105    pub major_alias: Option<String>,
106    pub commit_sha: Option<String>,
107    pub release_url: Option<String>,
108    pub pull_request_number: Option<u64>,
109    pub pull_request_url: Option<String>,
110    pub release_branch: Option<String>,
111    pub notes: Option<String>,
112    pub commits_analyzed: usize,
113    pub commit_range_truncated: bool,
114    pub files_updated: Vec<PathBuf>,
115}
116
117impl ReleaseReport {
118    /// Render `$GITHUB_OUTPUT` lines. Keys are always present; values are
119    /// empty when the outcome did not produce them.
120    pub fn github_outputs(&self, notes_file: &Path) -> String {
121        let notes_path =
122            if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
123        format!(
124            "released={}\nversion={}\ntag={}\ncommit={}\nrelease-url={}\nrelease-pr-number={}\nrelease-pr-url={}\nrelease-branch={}\nnotes-file={notes_path}\n",
125            self.outcome == ReleaseOutcome::Released,
126            self.next_version.as_deref().unwrap_or_default(),
127            self.tag.as_deref().unwrap_or_default(),
128            self.commit_sha.as_deref().unwrap_or_default(),
129            self.release_url.as_deref().unwrap_or_default(),
130            self.pull_request_number.map(|number| number.to_string()).unwrap_or_default(),
131            self.pull_request_url.as_deref().unwrap_or_default(),
132            self.release_branch.as_deref().unwrap_or_default(),
133        )
134    }
135}
136
137#[derive(Debug)]
138struct Analysis {
139    branch: String,
140    head_sha: String,
141    current_version: String,
142    current: Version,
143    commits: Vec<ConventionalCommit>,
144    truncated: bool,
145}
146
147#[derive(Debug)]
148struct PreparedRelease {
149    branch: String,
150    head_sha: String,
151    next_version: Version,
152    tag: String,
153    file_updates: Vec<FileUpdate>,
154}
155
156#[derive(Debug, Clone)]
157pub struct ReleasePublisher {
158    github: GitHubClient,
159}
160
161impl ReleasePublisher {
162    #[must_use]
163    pub const fn new(github: GitHubClient) -> Self {
164        Self { github }
165    }
166
167    pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
168        self.github.ensure_token()?;
169        if options.create_pr {
170            if options.phase != ReleasePhase::All {
171                anyhow::bail!(
172                    "--create-pr cannot be combined with --phase: the release pull request already separates the version bump from the tag"
173                );
174            }
175            validate_release_branch(&options.release_branch)?;
176        }
177        let analysis = self.analyze(options)?;
178        if options.phase == ReleasePhase::Tag {
179            return self.release_manifest_version(options, analysis);
180        }
181        self.release_bumped_version(options, analysis)
182    }
183
184    /// Bump the manifest version from the conventional commits, then commit it.
185    /// [`ReleasePhase::Bump`] stops there; otherwise the same run tags it.
186    fn release_bumped_version(
187        &self,
188        options: &ReleaseOptions,
189        analysis: Analysis,
190    ) -> Result<ReleaseReport> {
191        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
192        let mut report = initial_report(&analysis, bump);
193
194        let Some(bump_level) = bump else {
195            return Ok(report);
196        };
197
198        let next_version = conventional::bump_version(&analysis.current, bump_level);
199        let next = next_version.to_string();
200        let tag = format!("{}{next}", options.tag_prefix);
201        report.notes =
202            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
203        report.next_version = Some(next);
204        report.tag = Some(tag.clone());
205
206        if self.tag_exists(options, &tag)? {
207            report.outcome = ReleaseOutcome::SkippedTagExists;
208            return Ok(report);
209        }
210
211        let plan = versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
212        let mut file_updates = plan.file_updates;
213        let extra = extra_file_updates(&options.repo_root, &options.extra_files, &file_updates)?;
214        file_updates.extend(extra);
215
216        self.prepare_and_publish(options, analysis, next_version, tag, file_updates, report)
217    }
218
219    /// Tag the version the manifest already holds, without bumping it. The
220    /// bump landed in an earlier [`ReleasePhase::Bump`] run, so a runtime image
221    /// built from this exact version already exists and can be pinned into the
222    /// commit the tag points at.
223    fn release_manifest_version(
224        &self,
225        options: &ReleaseOptions,
226        analysis: Analysis,
227    ) -> Result<ReleaseReport> {
228        let mut report = initial_report(&analysis, None);
229        let version = analysis.current.clone();
230        let tag = format!("{}{version}", options.tag_prefix);
231        report.notes =
232            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
233        report.next_version = Some(version.to_string());
234        report.tag = Some(tag.clone());
235
236        if self.tag_exists(options, &tag)? {
237            report.outcome = ReleaseOutcome::SkippedTagExists;
238            return Ok(report);
239        }
240
241        // No version rewrite: the manifest is already at the released version,
242        // so only the caller's extra files are staged. With none to stage the
243        // tag lands on the existing head instead of an empty commit.
244        let file_updates = extra_file_updates(&options.repo_root, &options.extra_files, &[])?;
245
246        self.prepare_and_publish(options, analysis, version, tag, file_updates, report)
247    }
248
249    fn prepare_and_publish(
250        &self,
251        options: &ReleaseOptions,
252        analysis: Analysis,
253        next_version: Version,
254        tag: String,
255        file_updates: Vec<FileUpdate>,
256        mut report: ReleaseReport,
257    ) -> Result<ReleaseReport> {
258        report.files_updated = file_updates.iter().map(|update| update.file.clone()).collect();
259
260        if options.dry_run {
261            report.outcome = ReleaseOutcome::DryRun;
262            return Ok(report);
263        }
264
265        let prepared = PreparedRelease {
266            branch: analysis.branch,
267            head_sha: analysis.head_sha,
268            next_version,
269            tag,
270            file_updates,
271        };
272        self.publish(options, &prepared, &mut report)?;
273        Ok(report)
274    }
275
276    fn tag_exists(&self, options: &ReleaseOptions, tag: &str) -> Result<bool> {
277        Ok(self
278            .github
279            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
280            .is_some())
281    }
282
283    fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
284        let owner = &options.owner;
285        let repo = &options.repo;
286        let branch = match options.base_branch.as_deref().map(str::trim) {
287            Some(branch) if !branch.is_empty() => branch.to_owned(),
288            _ => self.github.default_branch(owner, repo)?,
289        };
290        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
291
292        let current_version = versioning::current_version(&options.repo_root)?;
293        let current = Version::parse(&current_version).with_context(|| {
294            format!("invalid current version '{current_version}' in Cargo.toml")
295        })?;
296
297        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
298        let range = match &last_tag {
299            Some(tag) => {
300                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
301            }
302            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
303        };
304
305        Ok(Analysis {
306            branch,
307            head_sha,
308            current_version,
309            current,
310            commits: conventional::classify_commits(&range.commits),
311            truncated: range.truncated,
312        })
313    }
314
315    /// Publish the prepared release. The cheap head re-read gives a clear
316    /// skip; the fast-forward-only ref update is the authoritative
317    /// compare-and-swap against a branch that advanced mid-run.
318    fn publish(
319        &self,
320        options: &ReleaseOptions,
321        prepared: &PreparedRelease,
322        report: &mut ReleaseReport,
323    ) -> Result<()> {
324        let staged = !prepared.file_updates.is_empty();
325        let commit_sha =
326            if staged { self.build_commit(options, prepared)? } else { prepared.head_sha.clone() };
327
328        let current_head =
329            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
330        if current_head != prepared.head_sha {
331            report.outcome = ReleaseOutcome::SkippedRace;
332            return Ok(());
333        }
334        report.commit_sha = Some(commit_sha.clone());
335
336        if options.create_pr {
337            return self.publish_pull_request(options, prepared, &commit_sha, report);
338        }
339
340        if staged
341            && !self.github.update_ref_fast_forward(
342                &options.owner,
343                &options.repo,
344                &format!("heads/{}", prepared.branch),
345                &commit_sha,
346            )?
347        {
348            report.outcome = ReleaseOutcome::SkippedRace;
349            return Ok(());
350        }
351
352        if options.phase == ReleasePhase::Bump {
353            report.outcome = ReleaseOutcome::VersionCommitted;
354            return Ok(());
355        }
356
357        self.finalize(options, prepared, &commit_sha, report)
358    }
359
360    fn publish_pull_request(
361        &self,
362        options: &ReleaseOptions,
363        prepared: &PreparedRelease,
364        commit_sha: &str,
365        report: &mut ReleaseReport,
366    ) -> Result<()> {
367        let owner = &options.owner;
368        let repo = &options.repo;
369        let branch_ref = format!("heads/{}", options.release_branch);
370        self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
371
372        let title = format!("chore(release): {}", prepared.tag);
373        let notes = report.notes.clone().unwrap_or_default();
374        let body = format!(
375            "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: {} -->",
376            prepared.tag,
377            report.current_version,
378            prepared.next_version,
379            prepared.branch,
380            notes,
381            options.release_branch,
382        );
383        let pull_request = self.upsert_release_pull_request(
384            owner,
385            repo,
386            &options.release_branch,
387            &prepared.branch,
388            &title,
389            &body,
390            report,
391        )?;
392        report.pull_request_number = Some(pull_request.number);
393        report.pull_request_url = Some(pull_request.url);
394        report.release_branch = Some(options.release_branch.clone());
395        Ok(())
396    }
397
398    fn update_release_branch(
399        &self,
400        owner: &str,
401        repo: &str,
402        branch_ref: &str,
403        commit_sha: &str,
404    ) -> Result<()> {
405        if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
406            self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
407        } else {
408            self.github.create_ref(owner, repo, branch_ref, commit_sha)
409        }
410    }
411
412    fn upsert_release_pull_request(
413        &self,
414        owner: &str,
415        repo: &str,
416        head: &str,
417        base: &str,
418        title: &str,
419        body: &str,
420        report: &mut ReleaseReport,
421    ) -> Result<crate::github::PullRequestInfo> {
422        let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
423        if let Some(existing) = existing {
424            let updated =
425                self.github.update_pull_request(owner, repo, existing.number, title, body)?;
426            report.outcome = ReleaseOutcome::PullRequestUpdated;
427            Ok(updated)
428        } else {
429            let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
430            report.outcome = ReleaseOutcome::PullRequestCreated;
431            Ok(created)
432        }
433    }
434
435    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
436        let owner = &options.owner;
437        let repo = &options.repo;
438        let repo_root = options.repo_root.canonicalize().with_context(|| {
439            format!("failed to resolve repository root '{}'", options.repo_root.display())
440        })?;
441
442        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
443        let mut tree_entries = Vec::new();
444        for update in &prepared.file_updates {
445            let path = remote::relative_repository_path(&repo_root, &update.file)?;
446            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
447            tree_entries.push(TreeEntry { path, sha: blob_sha });
448        }
449        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
450
451        let message =
452            options.commit_message.replace("{version}", &prepared.next_version.to_string());
453        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
454    }
455
456    fn finalize(
457        &self,
458        options: &ReleaseOptions,
459        prepared: &PreparedRelease,
460        commit_sha: &str,
461        report: &mut ReleaseReport,
462    ) -> Result<()> {
463        let owner = &options.owner;
464        let repo = &options.repo;
465        let tag_target = match options.tag_style {
466            TagStyle::Annotated => self.github.create_annotated_tag(
467                owner,
468                repo,
469                &prepared.tag,
470                &format!("Release {}", prepared.tag),
471                commit_sha,
472            )?,
473            TagStyle::Lightweight => commit_sha.to_owned(),
474        };
475        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
476
477        if options.update_major_alias {
478            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
479            let alias_ref = format!("tags/{alias}");
480            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
481                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
482            } else {
483                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
484            }
485            report.major_alias = Some(alias);
486        }
487
488        let notes = report.notes.clone().unwrap_or_default();
489        let release = self.github.create_release(
490            owner,
491            repo,
492            &prepared.tag,
493            &format!("Release {}", prepared.tag),
494            &notes,
495            commit_sha,
496        )?;
497        report.release_url = Some(release.url);
498        report.outcome = ReleaseOutcome::Released;
499        Ok(())
500    }
501}
502
503fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
504    ReleaseReport {
505        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
506        current_version: analysis.current_version.clone(),
507        next_version: None,
508        bump,
509        tag: None,
510        major_alias: None,
511        commit_sha: None,
512        release_url: None,
513        pull_request_number: None,
514        pull_request_url: None,
515        release_branch: None,
516        notes: None,
517        commits_analyzed: analysis.commits.len(),
518        commit_range_truncated: analysis.truncated,
519        files_updated: Vec::new(),
520    }
521}
522
523/// Read `extra_files` from the working tree so they ride along in the release
524/// commit. Paths the version rewrite already covers are skipped: the rewrite
525/// holds the bumped version, and staging a stale working-tree copy of the same
526/// path would silently revert it.
527fn extra_file_updates(
528    repo_root: &Path,
529    extra_files: &[PathBuf],
530    planned: &[FileUpdate],
531) -> Result<Vec<FileUpdate>> {
532    if extra_files.is_empty() {
533        return Ok(Vec::new());
534    }
535
536    let repo_root = repo_root
537        .canonicalize()
538        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
539
540    let mut updates = Vec::new();
541    for extra_file in extra_files {
542        let path =
543            if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
544        let path = path.canonicalize().with_context(|| {
545            format!("failed to resolve release file '{}'", extra_file.display())
546        })?;
547        // Keeps the commit inside the repository even when a caller passes an
548        // absolute path or one containing `..`.
549        remote::relative_repository_path(&repo_root, &path)?;
550
551        if planned.iter().any(|update| update.file == path)
552            || updates.iter().any(|update: &FileUpdate| update.file == path)
553        {
554            continue;
555        }
556
557        let updated_content = fs::read_to_string(&path)
558            .with_context(|| format!("failed to read release file '{}'", path.display()))?;
559        updates.push(FileUpdate { file: path, updated_content });
560    }
561
562    Ok(updates)
563}
564
565fn validate_release_branch(branch: &str) -> Result<()> {
566    if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
567        && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
568    {
569        anyhow::bail!(
570            "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
571        );
572    }
573    Ok(())
574}
575
576// Tests live in a sibling file to keep this module within the repository's
577// file-size lint budget; they remain `super::`-scoped unit tests.
578#[cfg(test)]
579#[path = "release_tests.rs"]
580#[allow(clippy::significant_drop_tightening)]
581mod tests;