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    /// Version of the latest release tag, absent until the first release.
144    last_released: Option<Version>,
145    commits: Vec<ConventionalCommit>,
146    truncated: bool,
147}
148
149impl Analysis {
150    /// True when the manifest holds a version that was never tagged.
151    ///
152    /// A direct release tags the version in the same run that bumps it, so the
153    /// two only diverge once a release pull request lands its bump on the base
154    /// branch without a tag - or on a repository that has never released.
155    fn manifest_unreleased(&self) -> bool {
156        self.last_released.as_ref().is_none_or(|released| self.current > *released)
157    }
158}
159
160#[derive(Debug)]
161struct PreparedRelease {
162    branch: String,
163    head_sha: String,
164    next_version: Version,
165    tag: String,
166    file_updates: Vec<FileUpdate>,
167    /// Route the version bump through a pull request instead of publishing it.
168    /// Tagging a version the manifest already holds needs no bump, so it
169    /// publishes directly even when the caller asked for release pull requests.
170    via_pull_request: bool,
171}
172
173#[derive(Debug, Clone)]
174pub struct ReleasePublisher {
175    github: GitHubClient,
176}
177
178impl ReleasePublisher {
179    #[must_use]
180    pub const fn new(github: GitHubClient) -> Self {
181        Self { github }
182    }
183
184    pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
185        self.github.ensure_token()?;
186        if options.create_pr {
187            if options.phase != ReleasePhase::All {
188                anyhow::bail!(
189                    "--create-pr cannot be combined with --phase: the release pull request already separates the version bump from the tag"
190                );
191            }
192            validate_release_branch(&options.release_branch)?;
193        }
194        let analysis = self.analyze(options)?;
195        if options.phase == ReleasePhase::Tag {
196            return self.release_manifest_version(options, analysis);
197        }
198        // A merged release pull request leaves the bump on the base branch with
199        // no tag. Bumping again would open a fresh pull request for a version
200        // nobody asked for and leave the merged one unreleased forever, so the
201        // version already on the branch is what gets tagged.
202        if options.create_pr && analysis.manifest_unreleased() {
203            return self.release_manifest_version(options, analysis);
204        }
205        self.release_bumped_version(options, analysis)
206    }
207
208    /// Bump the manifest version from the conventional commits, then commit it.
209    /// [`ReleasePhase::Bump`] stops there; otherwise the same run tags it.
210    fn release_bumped_version(
211        &self,
212        options: &ReleaseOptions,
213        analysis: Analysis,
214    ) -> Result<ReleaseReport> {
215        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
216        let mut report = initial_report(&analysis, bump);
217
218        let Some(bump_level) = bump else {
219            return Ok(report);
220        };
221
222        let next_version = conventional::bump_version(&analysis.current, bump_level);
223        let next = next_version.to_string();
224        let tag = format!("{}{next}", options.tag_prefix);
225        report.notes =
226            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
227        report.next_version = Some(next);
228        report.tag = Some(tag.clone());
229
230        if self.tag_exists(options, &tag)? {
231            report.outcome = ReleaseOutcome::SkippedTagExists;
232            return Ok(report);
233        }
234
235        let plan = versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
236        let mut file_updates = plan.file_updates;
237        let extra = extra_file_updates(&options.repo_root, &options.extra_files, &file_updates)?;
238        file_updates.extend(extra);
239
240        self.prepare_and_publish(
241            options,
242            analysis,
243            next_version,
244            tag,
245            file_updates,
246            options.create_pr,
247            report,
248        )
249    }
250
251    /// Tag the version the manifest already holds, without bumping it. The
252    /// bump landed in an earlier [`ReleasePhase::Bump`] run, so a runtime image
253    /// built from this exact version already exists and can be pinned into the
254    /// commit the tag points at.
255    fn release_manifest_version(
256        &self,
257        options: &ReleaseOptions,
258        analysis: Analysis,
259    ) -> Result<ReleaseReport> {
260        let mut report = initial_report(&analysis, None);
261        let version = analysis.current.clone();
262        let tag = format!("{}{version}", options.tag_prefix);
263        report.notes =
264            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
265        report.next_version = Some(version.to_string());
266        report.tag = Some(tag.clone());
267
268        if self.tag_exists(options, &tag)? {
269            report.outcome = ReleaseOutcome::SkippedTagExists;
270            return Ok(report);
271        }
272
273        // No version rewrite: the manifest is already at the released version,
274        // so only the caller's extra files are staged. With none to stage the
275        // tag lands on the existing head instead of an empty commit.
276        let file_updates = extra_file_updates(&options.repo_root, &options.extra_files, &[])?;
277
278        self.prepare_and_publish(options, analysis, version, tag, file_updates, false, report)
279    }
280
281    fn prepare_and_publish(
282        &self,
283        options: &ReleaseOptions,
284        analysis: Analysis,
285        next_version: Version,
286        tag: String,
287        file_updates: Vec<FileUpdate>,
288        via_pull_request: bool,
289        mut report: ReleaseReport,
290    ) -> Result<ReleaseReport> {
291        report.files_updated = file_updates.iter().map(|update| update.file.clone()).collect();
292
293        if options.dry_run {
294            report.outcome = ReleaseOutcome::DryRun;
295            return Ok(report);
296        }
297
298        let prepared = PreparedRelease {
299            branch: analysis.branch,
300            head_sha: analysis.head_sha,
301            next_version,
302            tag,
303            file_updates,
304            via_pull_request,
305        };
306        self.publish(options, &prepared, &mut report)?;
307        Ok(report)
308    }
309
310    fn tag_exists(&self, options: &ReleaseOptions, tag: &str) -> Result<bool> {
311        Ok(self
312            .github
313            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
314            .is_some())
315    }
316
317    fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
318        let owner = &options.owner;
319        let repo = &options.repo;
320        let branch = match options.base_branch.as_deref().map(str::trim) {
321            Some(branch) if !branch.is_empty() => branch.to_owned(),
322            _ => self.github.default_branch(owner, repo)?,
323        };
324        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
325
326        let current_version = versioning::current_version(&options.repo_root)?;
327        let current = Version::parse(&current_version).with_context(|| {
328            format!("invalid current version '{current_version}' in Cargo.toml")
329        })?;
330
331        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
332        let last_released = last_tag
333            .as_ref()
334            .and_then(|tag| tag.name.strip_prefix(options.tag_prefix.as_str()))
335            .and_then(|version| Version::parse(version).ok());
336        let range = match &last_tag {
337            Some(tag) => {
338                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
339            }
340            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
341        };
342
343        Ok(Analysis {
344            branch,
345            head_sha,
346            current_version,
347            current,
348            last_released,
349            commits: conventional::classify_commits(&range.commits),
350            truncated: range.truncated,
351        })
352    }
353
354    /// Publish the prepared release. The cheap head re-read gives a clear
355    /// skip; the fast-forward-only ref update is the authoritative
356    /// compare-and-swap against a branch that advanced mid-run.
357    fn publish(
358        &self,
359        options: &ReleaseOptions,
360        prepared: &PreparedRelease,
361        report: &mut ReleaseReport,
362    ) -> Result<()> {
363        let staged = !prepared.file_updates.is_empty();
364        let commit_sha =
365            if staged { self.build_commit(options, prepared)? } else { prepared.head_sha.clone() };
366
367        let current_head =
368            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
369        if current_head != prepared.head_sha {
370            report.outcome = ReleaseOutcome::SkippedRace;
371            return Ok(());
372        }
373        report.commit_sha = Some(commit_sha.clone());
374
375        if prepared.via_pull_request {
376            return self.publish_pull_request(options, prepared, &commit_sha, report);
377        }
378
379        if staged
380            && !self.github.update_ref_fast_forward(
381                &options.owner,
382                &options.repo,
383                &format!("heads/{}", prepared.branch),
384                &commit_sha,
385            )?
386        {
387            report.outcome = ReleaseOutcome::SkippedRace;
388            return Ok(());
389        }
390
391        if options.phase == ReleasePhase::Bump {
392            report.outcome = ReleaseOutcome::VersionCommitted;
393            return Ok(());
394        }
395
396        self.finalize(options, prepared, &commit_sha, report)
397    }
398
399    fn publish_pull_request(
400        &self,
401        options: &ReleaseOptions,
402        prepared: &PreparedRelease,
403        commit_sha: &str,
404        report: &mut ReleaseReport,
405    ) -> Result<()> {
406        let owner = &options.owner;
407        let repo = &options.repo;
408        let branch_ref = format!("heads/{}", options.release_branch);
409        self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
410
411        let title = format!("chore(release): {}", prepared.tag);
412        let notes = report.notes.clone().unwrap_or_default();
413        let body = format!(
414            "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: {} -->",
415            prepared.tag,
416            report.current_version,
417            prepared.next_version,
418            prepared.branch,
419            notes,
420            options.release_branch,
421        );
422        let pull_request = self.upsert_release_pull_request(
423            owner,
424            repo,
425            &options.release_branch,
426            &prepared.branch,
427            &title,
428            &body,
429            report,
430        )?;
431        report.pull_request_number = Some(pull_request.number);
432        report.pull_request_url = Some(pull_request.url);
433        report.release_branch = Some(options.release_branch.clone());
434        Ok(())
435    }
436
437    fn update_release_branch(
438        &self,
439        owner: &str,
440        repo: &str,
441        branch_ref: &str,
442        commit_sha: &str,
443    ) -> Result<()> {
444        if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
445            self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
446        } else {
447            self.github.create_ref(owner, repo, branch_ref, commit_sha)
448        }
449    }
450
451    fn upsert_release_pull_request(
452        &self,
453        owner: &str,
454        repo: &str,
455        head: &str,
456        base: &str,
457        title: &str,
458        body: &str,
459        report: &mut ReleaseReport,
460    ) -> Result<crate::github::PullRequestInfo> {
461        let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
462        if let Some(existing) = existing {
463            let updated =
464                self.github.update_pull_request(owner, repo, existing.number, title, body)?;
465            report.outcome = ReleaseOutcome::PullRequestUpdated;
466            Ok(updated)
467        } else {
468            let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
469            report.outcome = ReleaseOutcome::PullRequestCreated;
470            Ok(created)
471        }
472    }
473
474    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
475        let owner = &options.owner;
476        let repo = &options.repo;
477        let repo_root = options.repo_root.canonicalize().with_context(|| {
478            format!("failed to resolve repository root '{}'", options.repo_root.display())
479        })?;
480
481        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
482        let mut tree_entries = Vec::new();
483        for update in &prepared.file_updates {
484            let path = remote::relative_repository_path(&repo_root, &update.file)?;
485            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
486            tree_entries.push(TreeEntry { path, sha: blob_sha });
487        }
488        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
489
490        let message =
491            options.commit_message.replace("{version}", &prepared.next_version.to_string());
492        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
493    }
494
495    fn finalize(
496        &self,
497        options: &ReleaseOptions,
498        prepared: &PreparedRelease,
499        commit_sha: &str,
500        report: &mut ReleaseReport,
501    ) -> Result<()> {
502        let owner = &options.owner;
503        let repo = &options.repo;
504        let tag_target = match options.tag_style {
505            TagStyle::Annotated => self.github.create_annotated_tag(
506                owner,
507                repo,
508                &prepared.tag,
509                &format!("Release {}", prepared.tag),
510                commit_sha,
511            )?,
512            TagStyle::Lightweight => commit_sha.to_owned(),
513        };
514        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
515
516        if options.update_major_alias {
517            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
518            let alias_ref = format!("tags/{alias}");
519            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
520                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
521            } else {
522                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
523            }
524            report.major_alias = Some(alias);
525        }
526
527        let notes = report.notes.clone().unwrap_or_default();
528        let release = self.github.create_release(
529            owner,
530            repo,
531            &prepared.tag,
532            &format!("Release {}", prepared.tag),
533            &notes,
534            commit_sha,
535        )?;
536        report.release_url = Some(release.url);
537        report.outcome = ReleaseOutcome::Released;
538        Ok(())
539    }
540}
541
542fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
543    ReleaseReport {
544        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
545        current_version: analysis.current_version.clone(),
546        next_version: None,
547        bump,
548        tag: None,
549        major_alias: None,
550        commit_sha: None,
551        release_url: None,
552        pull_request_number: None,
553        pull_request_url: None,
554        release_branch: None,
555        notes: None,
556        commits_analyzed: analysis.commits.len(),
557        commit_range_truncated: analysis.truncated,
558        files_updated: Vec::new(),
559    }
560}
561
562/// Read `extra_files` from the working tree so they ride along in the release
563/// commit. Paths the version rewrite already covers are skipped: the rewrite
564/// holds the bumped version, and staging a stale working-tree copy of the same
565/// path would silently revert it.
566fn extra_file_updates(
567    repo_root: &Path,
568    extra_files: &[PathBuf],
569    planned: &[FileUpdate],
570) -> Result<Vec<FileUpdate>> {
571    if extra_files.is_empty() {
572        return Ok(Vec::new());
573    }
574
575    let repo_root = repo_root
576        .canonicalize()
577        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
578
579    let mut updates = Vec::new();
580    for extra_file in extra_files {
581        let path =
582            if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
583        let path = path.canonicalize().with_context(|| {
584            format!("failed to resolve release file '{}'", extra_file.display())
585        })?;
586        // Keeps the commit inside the repository even when a caller passes an
587        // absolute path or one containing `..`.
588        remote::relative_repository_path(&repo_root, &path)?;
589
590        if planned.iter().any(|update| update.file == path)
591            || updates.iter().any(|update: &FileUpdate| update.file == path)
592        {
593            continue;
594        }
595
596        let updated_content = fs::read_to_string(&path)
597            .with_context(|| format!("failed to read release file '{}'", path.display()))?;
598        updates.push(FileUpdate { file: path, updated_content });
599    }
600
601    Ok(updates)
602}
603
604fn validate_release_branch(branch: &str) -> Result<()> {
605    if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
606        && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
607    {
608        anyhow::bail!(
609            "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
610        );
611    }
612    Ok(())
613}
614
615// Tests live in a sibling file to keep this module within the repository's
616// file-size lint budget; they remain `super::`-scoped unit tests.
617#[cfg(test)]
618#[path = "release_tests.rs"]
619#[allow(clippy::significant_drop_tightening)]
620mod tests;