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, bail};
17use regex::Regex;
18use semver::Version;
19
20use crate::{
21    conventional::{self, BumpLevel, ConventionalCommit},
22    github::{GitHubClient, TreeEntry},
23    model::FileUpdate,
24    remote, versioning,
25};
26
27const MAX_COMPARE_PAGES: u32 = 10;
28const MAX_FIRST_RELEASE_PAGES: u32 = 3;
29const AUTOMATED_RELEASE_BRANCH_PREFIX: &str = "automation/release";
30
31/// How the release tag is created. Annotated tags are the default: they
32/// carry a tagger identity and satisfy `git cat-file -t == "tag"` provenance
33/// checks. The moving major alias tag is always lightweight.
34#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
35pub enum TagStyle {
36    #[default]
37    Annotated,
38    Lightweight,
39}
40
41/// Which part of the release a run performs.
42///
43/// A container action cannot pin a runtime image built from its own release,
44/// because an image tagged with the version only exists once the tag does.
45/// Splitting the release breaks that cycle: [`Self::Bump`] lands the version
46/// bump so an image can be built from the released version, and [`Self::Tag`]
47/// then pins that image and tags the result. Only the two Dockerfiles differ
48/// between the image's source and the tagged tree, and they do not affect the
49/// image, so the tag ships a runtime whose reported version is its own.
50#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
51pub enum ReleasePhase {
52    /// Bump, tag, and publish in a single run.
53    #[default]
54    All,
55    /// Commit the version bump and stop, leaving the version untagged.
56    Bump,
57    /// Tag and publish the version the manifest already holds, without bumping.
58    Tag,
59}
60
61#[derive(Debug, Clone, Eq, PartialEq)]
62pub struct ReleaseOptions {
63    pub repo_root: PathBuf,
64    pub owner: String,
65    pub repo: String,
66    pub base_branch: Option<String>,
67    /// Forced bump level; `None` derives it from conventional commits.
68    pub bump: Option<BumpLevel>,
69    pub tag_prefix: String,
70    pub tag_style: TagStyle,
71    pub update_major_alias: bool,
72    /// Commit message template; `{version}` is replaced with the new version.
73    pub commit_message: String,
74    /// Create or refresh an automation-owned release branch and pull request
75    /// instead of publishing directly to the base branch.
76    pub create_pr: bool,
77    pub release_branch: String,
78    pub dry_run: bool,
79    /// Extra working-tree files to stage into the release commit, on top of
80    /// the manifest and lockfile rewrites. Paths already covered by the
81    /// version rewrite are ignored so the rewrite stays authoritative.
82    pub extra_files: Vec<PathBuf>,
83    pub phase: ReleasePhase,
84}
85
86#[derive(Debug, Clone, Copy, Eq, PartialEq)]
87pub enum ReleaseOutcome {
88    Released,
89    /// The version bump was committed; the tag and release are still pending.
90    VersionCommitted,
91    PullRequestCreated,
92    PullRequestUpdated,
93    DryRun,
94    SkippedNoReleasableChanges,
95    SkippedRace,
96    SkippedTagExists,
97}
98
99/// What a run does once its analysis finds a version to release, or on a dry
100/// run what it would have done. Recorded so a dry run can say which path the
101/// real run takes, not just which version it computed.
102#[derive(Debug, Clone, Eq, PartialEq)]
103pub enum ReleaseAction {
104    /// Commit the version bump to `branch`, tag that commit, and publish the
105    /// GitHub Release.
106    CommitAndRelease { branch: String },
107    /// Commit the version bump to `branch` and stop, leaving the tag to a later
108    /// [`ReleasePhase::Tag`] run.
109    CommitBump { branch: String },
110    /// Tag the version the manifest already holds on `branch` and publish its
111    /// GitHub Release, without bumping it. Only staged extra files, if any, are
112    /// committed first.
113    TagManifestVersion { branch: String },
114    /// Point `release_branch` at a version-bump commit on top of `base` and
115    /// open or update its pull request into `base`. Nothing is tagged until the
116    /// pull request merges.
117    ProposeReleasePullRequest { release_branch: String, base: String },
118}
119
120#[derive(Debug, Clone, Eq, PartialEq)]
121pub struct ReleaseReport {
122    pub outcome: ReleaseOutcome,
123    /// The path the run took (or, on a dry run, would take); `None` when it
124    /// stopped before choosing one because nothing needed releasing or the tag
125    /// already exists.
126    pub action: Option<ReleaseAction>,
127    pub current_version: String,
128    pub next_version: Option<String>,
129    pub bump: Option<BumpLevel>,
130    pub tag: Option<String>,
131    pub major_alias: Option<String>,
132    pub commit_sha: Option<String>,
133    pub release_url: Option<String>,
134    pub pull_request_number: Option<u64>,
135    pub pull_request_url: Option<String>,
136    pub release_branch: Option<String>,
137    pub notes: Option<String>,
138    pub commits_analyzed: usize,
139    pub commit_range_truncated: bool,
140    pub files_updated: Vec<PathBuf>,
141}
142
143impl ReleaseReport {
144    /// Render `$GITHUB_OUTPUT` lines. Keys are always present; values are
145    /// empty when the outcome did not produce them.
146    pub fn github_outputs(&self, notes_file: &Path) -> String {
147        let notes_path =
148            if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
149        format!(
150            "released={}\nversion={}\ntag={}\ncommit={}\nrelease-url={}\nrelease-pr-number={}\nrelease-pr-url={}\nrelease-branch={}\nnotes-file={notes_path}\n",
151            self.outcome == ReleaseOutcome::Released,
152            self.next_version.as_deref().unwrap_or_default(),
153            self.tag.as_deref().unwrap_or_default(),
154            self.commit_sha.as_deref().unwrap_or_default(),
155            self.release_url.as_deref().unwrap_or_default(),
156            self.pull_request_number.map(|number| number.to_string()).unwrap_or_default(),
157            self.pull_request_url.as_deref().unwrap_or_default(),
158            self.release_branch.as_deref().unwrap_or_default(),
159        )
160    }
161}
162
163#[derive(Debug)]
164struct Analysis {
165    branch: String,
166    head_sha: String,
167    current_version: String,
168    current: Version,
169    /// Name of the latest release tag and the version it carries, both absent
170    /// until the first release.
171    last_tag: Option<String>,
172    last_released: Option<Version>,
173    commits: Vec<ConventionalCommit>,
174    truncated: bool,
175}
176
177impl Analysis {
178    /// True when the manifest holds a version that was never tagged.
179    ///
180    /// A direct release tags the version in the same run that bumps it, so the
181    /// two only diverge once a release pull request lands its bump on the base
182    /// branch without a tag - or on a repository that has never released.
183    fn manifest_unreleased(&self) -> bool {
184        self.last_released.as_ref().is_none_or(|released| self.current > *released)
185    }
186
187    /// Refuse to release from a manifest older than the latest release tag.
188    ///
189    /// The commit range starts at the highest tag, but the next version is
190    /// bumped from the manifest. A manifest behind that tag therefore yields a
191    /// version at or below one already released: it either collides with an
192    /// existing tag or publishes a release older than the latest one, and as
193    /// the range never moves past the highest tag, every later run repeats it.
194    fn ensure_manifest_not_behind_latest_tag(&self) -> Result<()> {
195        match (&self.last_tag, &self.last_released) {
196            (Some(tag), Some(released)) if self.current < *released => bail!(
197                "Cargo.toml version {current} is lower than the latest release tag {tag}. \
198                 Releasing would bump from {current} and produce a version at or below one that is \
199                 already tagged. Set the manifest version to {released}, or delete {tag} if it was \
200                 created by mistake, then re-run.",
201                current = self.current_version,
202            ),
203            _ => Ok(()),
204        }
205    }
206}
207
208#[derive(Debug)]
209struct PreparedRelease {
210    branch: String,
211    head_sha: String,
212    next_version: Version,
213    tag: String,
214    file_updates: Vec<FileUpdate>,
215    /// Route the version bump through a pull request instead of publishing it.
216    /// Tagging a version the manifest already holds needs no bump, so it
217    /// publishes directly even when the caller asked for release pull requests.
218    via_pull_request: bool,
219}
220
221#[derive(Debug, Clone)]
222pub struct ReleasePublisher {
223    github: GitHubClient,
224}
225
226impl ReleasePublisher {
227    #[must_use]
228    pub const fn new(github: GitHubClient) -> Self {
229        Self { github }
230    }
231
232    /// Analyze the commits since the latest release tag and release, propose,
233    /// or (on a dry run) report the next version according to `options`.
234    pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
235        self.release_with_expected_head(options, None)
236    }
237
238    /// Release only if the branch still points at the exact commit validated
239    /// by the caller. Reject a mismatch before reading release history or
240    /// preparing any writes; the existing mid-run race checks still apply.
241    pub fn release_at_head(
242        &self,
243        options: &ReleaseOptions,
244        expected_head: &str,
245    ) -> Result<ReleaseReport> {
246        anyhow::ensure!(
247            !expected_head.trim().is_empty(),
248            "expected release head must not be empty"
249        );
250        self.release_with_expected_head(options, Some(expected_head))
251    }
252
253    fn release_with_expected_head(
254        &self,
255        options: &ReleaseOptions,
256        expected_head: Option<&str>,
257    ) -> Result<ReleaseReport> {
258        self.github.ensure_token()?;
259        if options.create_pr {
260            if options.phase != ReleasePhase::All {
261                anyhow::bail!(
262                    "--create-pr cannot be combined with --phase: the release pull request already separates the version bump from the tag"
263                );
264            }
265            validate_release_branch(&options.release_branch)?;
266        }
267        let analysis = self.analyze(options, expected_head)?;
268        analysis.ensure_manifest_not_behind_latest_tag()?;
269        if options.phase == ReleasePhase::Tag {
270            return self.release_manifest_version(options, analysis);
271        }
272        // A merged release pull request leaves the bump on the base branch with
273        // no tag. Bumping again would open a fresh pull request for a version
274        // nobody asked for and leave the merged one unreleased forever, so the
275        // version already on the branch is what gets tagged.
276        if options.create_pr && analysis.manifest_unreleased() {
277            return self.release_manifest_version(options, analysis);
278        }
279        self.release_bumped_version(options, analysis)
280    }
281
282    /// Bump the manifest version from the conventional commits, then commit it.
283    /// [`ReleasePhase::Bump`] stops there; otherwise the same run tags it.
284    fn release_bumped_version(
285        &self,
286        options: &ReleaseOptions,
287        analysis: Analysis,
288    ) -> Result<ReleaseReport> {
289        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
290        let mut report = initial_report(&analysis, bump);
291
292        let Some(bump_level) = bump else {
293            return Ok(report);
294        };
295
296        let next_version = conventional::bump_version(&analysis.current, bump_level);
297        let next = next_version.to_string();
298        let tag = format!("{}{next}", options.tag_prefix);
299        report.notes = Some(release_notes(options, &tag, &analysis));
300        report.next_version = Some(next);
301        report.tag = Some(tag.clone());
302
303        if self.tag_exists(options, &tag)? {
304            report.outcome = ReleaseOutcome::SkippedTagExists;
305            return Ok(report);
306        }
307
308        let plan =
309            self.version_rewrite_plan(options, &analysis.head_sha, &next_version.to_string())?;
310        let mut file_updates = plan.file_updates;
311        let extra = extra_file_updates(&options.repo_root, &options.extra_files, &file_updates)?;
312        file_updates.extend(extra);
313
314        let action = if options.create_pr {
315            ReleaseAction::ProposeReleasePullRequest {
316                release_branch: options.release_branch.clone(),
317                base: analysis.branch.clone(),
318            }
319        } else if options.phase == ReleasePhase::Bump {
320            ReleaseAction::CommitBump { branch: analysis.branch.clone() }
321        } else {
322            ReleaseAction::CommitAndRelease { branch: analysis.branch.clone() }
323        };
324        self.prepare_and_publish(options, analysis, next_version, tag, file_updates, action, report)
325    }
326
327    fn version_rewrite_plan(
328        &self,
329        options: &ReleaseOptions,
330        analyzed_head: &str,
331        next_version: &str,
332    ) -> Result<versioning::VersionRewritePlan> {
333        // PR dry runs deliberately analyze main while previewing local PR
334        // contents. Preserve that read-only contract; write paths below bind
335        // every consulted source to the exact analyzed tree instead.
336        if options.dry_run {
337            return versioning::plan_read_only_rewrite(&options.repo_root, next_version);
338        }
339        let repo_root = options.repo_root.canonicalize()?;
340        let tracked =
341            self.github.regular_files_at_commit(&options.owner, &options.repo, analyzed_head)?;
342        if versioning::tracked_nested_lockfiles(&tracked.keys().cloned().collect()).next().is_none()
343        {
344            return versioning::plan_version_rewrite(&options.repo_root, next_version);
345        }
346        let rewrite = versioning::plan_version_rewrite_in_tree(
347            &repo_root,
348            next_version,
349            &tracked.keys().cloned().collect(),
350        )?;
351        self.github.validate_release_sources(
352            &options.owner,
353            &options.repo,
354            &repo_root,
355            &tracked,
356            &rewrite.original_files,
357        )?;
358        Ok(rewrite.plan)
359    }
360
361    /// Tag the version the manifest already holds, without bumping it. The
362    /// bump landed in an earlier [`ReleasePhase::Bump`] run, so a runtime image
363    /// built from this exact version already exists and can be pinned into the
364    /// commit the tag points at.
365    fn release_manifest_version(
366        &self,
367        options: &ReleaseOptions,
368        analysis: Analysis,
369    ) -> Result<ReleaseReport> {
370        let mut report = initial_report(&analysis, None);
371        let version = analysis.current.clone();
372        let tag = format!("{}{version}", options.tag_prefix);
373        report.notes = Some(release_notes(options, &tag, &analysis));
374        report.next_version = Some(version.to_string());
375        report.tag = Some(tag.clone());
376
377        if self.tag_exists(options, &tag)? {
378            report.outcome = ReleaseOutcome::SkippedTagExists;
379            return Ok(report);
380        }
381
382        // No version rewrite: the manifest is already at the released version,
383        // so only the caller's extra files are staged. With none to stage the
384        // tag lands on the existing head instead of an empty commit.
385        let file_updates = extra_file_updates(&options.repo_root, &options.extra_files, &[])?;
386
387        let action = ReleaseAction::TagManifestVersion { branch: analysis.branch.clone() };
388        self.prepare_and_publish(options, analysis, version, tag, file_updates, action, report)
389    }
390
391    /// Record the files and the action the run takes, then stop for a dry run
392    /// or publish the prepared release.
393    fn prepare_and_publish(
394        &self,
395        options: &ReleaseOptions,
396        analysis: Analysis,
397        next_version: Version,
398        tag: String,
399        file_updates: Vec<FileUpdate>,
400        action: ReleaseAction,
401        mut report: ReleaseReport,
402    ) -> Result<ReleaseReport> {
403        report.files_updated = file_updates.iter().map(|update| update.file.clone()).collect();
404        let via_pull_request = matches!(action, ReleaseAction::ProposeReleasePullRequest { .. });
405        report.action = Some(action);
406
407        if options.dry_run {
408            report.outcome = ReleaseOutcome::DryRun;
409            return Ok(report);
410        }
411
412        let prepared = PreparedRelease {
413            branch: analysis.branch,
414            head_sha: analysis.head_sha,
415            next_version,
416            tag,
417            file_updates,
418            via_pull_request,
419        };
420        self.publish(options, &prepared, &mut report)?;
421        Ok(report)
422    }
423
424    fn tag_exists(&self, options: &ReleaseOptions, tag: &str) -> Result<bool> {
425        Ok(self
426            .github
427            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
428            .is_some())
429    }
430
431    /// Read the branch head, the manifest version, the latest release tag, and
432    /// the commits since that tag (or the recent history before the first
433    /// release).
434    fn analyze(&self, options: &ReleaseOptions, expected_head: Option<&str>) -> Result<Analysis> {
435        let owner = &options.owner;
436        let repo = &options.repo;
437        let branch = match options.base_branch.as_deref().map(str::trim) {
438            Some(branch) if !branch.is_empty() => branch.to_owned(),
439            _ => self.github.default_branch(owner, repo)?,
440        };
441        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
442        if let Some(expected) = expected_head {
443            anyhow::ensure!(
444                head_sha == expected,
445                "release branch '{branch}' head {head_sha} does not match expected head {expected}; validate the current head before releasing"
446            );
447        }
448
449        let current_version = versioning::current_version(&options.repo_root)?;
450        let current = Version::parse(&current_version).with_context(|| {
451            format!("invalid current version '{current_version}' in Cargo.toml")
452        })?;
453
454        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
455        let last_released = last_tag
456            .as_ref()
457            .and_then(|tag| tag.name.strip_prefix(options.tag_prefix.as_str()))
458            .and_then(|version| Version::parse(version).ok());
459        let range = match &last_tag {
460            Some(tag) => {
461                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
462            }
463            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
464        };
465
466        Ok(Analysis {
467            branch,
468            head_sha,
469            current_version,
470            current,
471            last_tag: last_tag.map(|tag| tag.name),
472            last_released,
473            commits: conventional::classify_commits(&range.commits),
474            truncated: range.truncated,
475        })
476    }
477
478    /// Publish the prepared release. The cheap head re-read gives a clear
479    /// skip; the fast-forward-only ref update is the authoritative
480    /// compare-and-swap against a branch that advanced mid-run.
481    fn publish(
482        &self,
483        options: &ReleaseOptions,
484        prepared: &PreparedRelease,
485        report: &mut ReleaseReport,
486    ) -> Result<()> {
487        let staged = !prepared.file_updates.is_empty();
488        let commit_sha =
489            if staged { self.build_commit(options, prepared)? } else { prepared.head_sha.clone() };
490
491        let current_head =
492            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
493        if current_head != prepared.head_sha {
494            report.outcome = ReleaseOutcome::SkippedRace;
495            return Ok(());
496        }
497        report.commit_sha = Some(commit_sha.clone());
498
499        if prepared.via_pull_request {
500            return self.publish_pull_request(options, prepared, &commit_sha, report);
501        }
502
503        if staged
504            && !self.github.update_ref_fast_forward(
505                &options.owner,
506                &options.repo,
507                &format!("heads/{}", prepared.branch),
508                &commit_sha,
509            )?
510        {
511            report.outcome = ReleaseOutcome::SkippedRace;
512            return Ok(());
513        }
514
515        if options.phase == ReleasePhase::Bump {
516            report.outcome = ReleaseOutcome::VersionCommitted;
517            return Ok(());
518        }
519
520        self.finalize(options, prepared, &commit_sha, report)
521    }
522
523    fn publish_pull_request(
524        &self,
525        options: &ReleaseOptions,
526        prepared: &PreparedRelease,
527        commit_sha: &str,
528        report: &mut ReleaseReport,
529    ) -> Result<()> {
530        let owner = &options.owner;
531        let repo = &options.repo;
532        let branch_ref = format!("heads/{}", options.release_branch);
533        self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
534
535        let title = format!("chore(release): {}", prepared.tag);
536        let notes = report.notes.clone().unwrap_or_default();
537        let body = format!(
538            "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: {} -->",
539            prepared.tag,
540            report.current_version,
541            prepared.next_version,
542            prepared.branch,
543            notes,
544            options.release_branch,
545        );
546        let pull_request = self.upsert_release_pull_request(
547            owner,
548            repo,
549            &options.release_branch,
550            &prepared.branch,
551            &title,
552            &body,
553            report,
554        )?;
555        report.pull_request_number = Some(pull_request.number);
556        report.pull_request_url = Some(pull_request.url);
557        report.release_branch = Some(options.release_branch.clone());
558        Ok(())
559    }
560
561    fn update_release_branch(
562        &self,
563        owner: &str,
564        repo: &str,
565        branch_ref: &str,
566        commit_sha: &str,
567    ) -> Result<()> {
568        if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
569            self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
570        } else {
571            self.github.create_ref(owner, repo, branch_ref, commit_sha)
572        }
573    }
574
575    fn upsert_release_pull_request(
576        &self,
577        owner: &str,
578        repo: &str,
579        head: &str,
580        base: &str,
581        title: &str,
582        body: &str,
583        report: &mut ReleaseReport,
584    ) -> Result<crate::github::PullRequestInfo> {
585        let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
586        if let Some(existing) = existing {
587            let updated =
588                self.github.update_pull_request(owner, repo, existing.number, title, body)?;
589            report.outcome = ReleaseOutcome::PullRequestUpdated;
590            Ok(updated)
591        } else {
592            let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
593            report.outcome = ReleaseOutcome::PullRequestCreated;
594            Ok(created)
595        }
596    }
597
598    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
599        let owner = &options.owner;
600        let repo = &options.repo;
601        let repo_root = options.repo_root.canonicalize().with_context(|| {
602            format!("failed to resolve repository root '{}'", options.repo_root.display())
603        })?;
604
605        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
606        let mut tree_entries = Vec::new();
607        for update in &prepared.file_updates {
608            let path = remote::relative_repository_path(&repo_root, &update.file)?;
609            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
610            tree_entries.push(TreeEntry { path, sha: blob_sha });
611        }
612        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
613
614        let message =
615            options.commit_message.replace("{version}", &prepared.next_version.to_string());
616        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
617    }
618
619    fn finalize(
620        &self,
621        options: &ReleaseOptions,
622        prepared: &PreparedRelease,
623        commit_sha: &str,
624        report: &mut ReleaseReport,
625    ) -> Result<()> {
626        let owner = &options.owner;
627        let repo = &options.repo;
628        let tag_target = match options.tag_style {
629            TagStyle::Annotated => self.github.create_annotated_tag(
630                owner,
631                repo,
632                &prepared.tag,
633                &format!("Release {}", prepared.tag),
634                commit_sha,
635            )?,
636            TagStyle::Lightweight => commit_sha.to_owned(),
637        };
638        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
639
640        if options.update_major_alias {
641            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
642            let alias_ref = format!("tags/{alias}");
643            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
644                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
645            } else {
646                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
647            }
648            report.major_alias = Some(alias);
649        }
650
651        let notes = report.notes.clone().unwrap_or_default();
652        let release = self.github.create_release(
653            owner,
654            repo,
655            &prepared.tag,
656            &format!("Release {}", prepared.tag),
657            &notes,
658            commit_sha,
659        )?;
660        report.release_url = Some(release.url);
661        report.outcome = ReleaseOutcome::Released;
662        Ok(())
663    }
664}
665
666/// Release notes for `tag`, without the automation's own version-bump commits.
667fn release_notes(options: &ReleaseOptions, tag: &str, analysis: &Analysis) -> String {
668    let commits = without_release_commits(&analysis.commits, &options.commit_message);
669    conventional::release_notes(tag, &commits, analysis.truncated)
670}
671
672/// Drop the commits that are this tool's own version bumps: the first line of
673/// `commit_message` rendered for any version, also as squash-merged from a
674/// release pull request (`chore: release v1.2.3 (#42)`). The tag phase and a
675/// merged release pull request both release a range that contains the bump
676/// commit, and listing it under "Maintenance" would only repeat the title.
677fn without_release_commits(
678    commits: &[ConventionalCommit],
679    commit_message: &str,
680) -> Vec<ConventionalCommit> {
681    let template = commit_message.lines().next().unwrap_or_default().trim();
682    let pattern = template
683        .split("{version}")
684        .map(regex::escape)
685        .collect::<Vec<_>>()
686        .join(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?");
687    let Ok(release_commit) = Regex::new(&format!(r"^{pattern}(?: \(#\d+\))?$")) else {
688        return commits.to_vec();
689    };
690    commits.iter().filter(|commit| !release_commit.is_match(&commit.subject)).cloned().collect()
691}
692
693/// A report that releases nothing yet; callers fill in what the run decides.
694fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
695    ReleaseReport {
696        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
697        action: None,
698        current_version: analysis.current_version.clone(),
699        next_version: None,
700        bump,
701        tag: None,
702        major_alias: None,
703        commit_sha: None,
704        release_url: None,
705        pull_request_number: None,
706        pull_request_url: None,
707        release_branch: None,
708        notes: None,
709        commits_analyzed: analysis.commits.len(),
710        commit_range_truncated: analysis.truncated,
711        files_updated: Vec::new(),
712    }
713}
714
715/// Read `extra_files` from the working tree so they ride along in the release
716/// commit. Paths the version rewrite already covers are skipped: the rewrite
717/// holds the bumped version, and staging a stale working-tree copy of the same
718/// path would silently revert it.
719fn extra_file_updates(
720    repo_root: &Path,
721    extra_files: &[PathBuf],
722    planned: &[FileUpdate],
723) -> Result<Vec<FileUpdate>> {
724    if extra_files.is_empty() {
725        return Ok(Vec::new());
726    }
727
728    let repo_root = repo_root
729        .canonicalize()
730        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
731
732    let mut updates = Vec::new();
733    for extra_file in extra_files {
734        let path =
735            if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
736        let path = path.canonicalize().with_context(|| {
737            format!("failed to resolve release file '{}'", extra_file.display())
738        })?;
739        // Keeps the commit inside the repository even when a caller passes an
740        // absolute path or one containing `..`.
741        remote::relative_repository_path(&repo_root, &path)?;
742
743        if planned.iter().any(|update| update.file == path)
744            || updates.iter().any(|update: &FileUpdate| update.file == path)
745        {
746            continue;
747        }
748
749        let updated_content = fs::read_to_string(&path)
750            .with_context(|| format!("failed to read release file '{}'", path.display()))?;
751        updates.push(FileUpdate { file: path, updated_content });
752    }
753
754    Ok(updates)
755}
756
757fn validate_release_branch(branch: &str) -> Result<()> {
758    if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
759        && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
760    {
761        anyhow::bail!(
762            "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
763        );
764    }
765    Ok(())
766}
767
768// Tests live in a sibling file to keep this module within the repository's
769// file-size lint budget; they remain `super::`-scoped unit tests.
770#[cfg(test)]
771#[path = "release_tests.rs"]
772#[allow(clippy::significant_drop_tightening)]
773mod tests;