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.github.ensure_token()?;
236        if options.create_pr {
237            if options.phase != ReleasePhase::All {
238                anyhow::bail!(
239                    "--create-pr cannot be combined with --phase: the release pull request already separates the version bump from the tag"
240                );
241            }
242            validate_release_branch(&options.release_branch)?;
243        }
244        let analysis = self.analyze(options)?;
245        analysis.ensure_manifest_not_behind_latest_tag()?;
246        if options.phase == ReleasePhase::Tag {
247            return self.release_manifest_version(options, analysis);
248        }
249        // A merged release pull request leaves the bump on the base branch with
250        // no tag. Bumping again would open a fresh pull request for a version
251        // nobody asked for and leave the merged one unreleased forever, so the
252        // version already on the branch is what gets tagged.
253        if options.create_pr && analysis.manifest_unreleased() {
254            return self.release_manifest_version(options, analysis);
255        }
256        self.release_bumped_version(options, analysis)
257    }
258
259    /// Bump the manifest version from the conventional commits, then commit it.
260    /// [`ReleasePhase::Bump`] stops there; otherwise the same run tags it.
261    fn release_bumped_version(
262        &self,
263        options: &ReleaseOptions,
264        analysis: Analysis,
265    ) -> Result<ReleaseReport> {
266        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
267        let mut report = initial_report(&analysis, bump);
268
269        let Some(bump_level) = bump else {
270            return Ok(report);
271        };
272
273        let next_version = conventional::bump_version(&analysis.current, bump_level);
274        let next = next_version.to_string();
275        let tag = format!("{}{next}", options.tag_prefix);
276        report.notes = Some(release_notes(options, &tag, &analysis));
277        report.next_version = Some(next);
278        report.tag = Some(tag.clone());
279
280        if self.tag_exists(options, &tag)? {
281            report.outcome = ReleaseOutcome::SkippedTagExists;
282            return Ok(report);
283        }
284
285        let plan = versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
286        let mut file_updates = plan.file_updates;
287        let extra = extra_file_updates(&options.repo_root, &options.extra_files, &file_updates)?;
288        file_updates.extend(extra);
289
290        let action = if options.create_pr {
291            ReleaseAction::ProposeReleasePullRequest {
292                release_branch: options.release_branch.clone(),
293                base: analysis.branch.clone(),
294            }
295        } else if options.phase == ReleasePhase::Bump {
296            ReleaseAction::CommitBump { branch: analysis.branch.clone() }
297        } else {
298            ReleaseAction::CommitAndRelease { branch: analysis.branch.clone() }
299        };
300        self.prepare_and_publish(options, analysis, next_version, tag, file_updates, action, report)
301    }
302
303    /// Tag the version the manifest already holds, without bumping it. The
304    /// bump landed in an earlier [`ReleasePhase::Bump`] run, so a runtime image
305    /// built from this exact version already exists and can be pinned into the
306    /// commit the tag points at.
307    fn release_manifest_version(
308        &self,
309        options: &ReleaseOptions,
310        analysis: Analysis,
311    ) -> Result<ReleaseReport> {
312        let mut report = initial_report(&analysis, None);
313        let version = analysis.current.clone();
314        let tag = format!("{}{version}", options.tag_prefix);
315        report.notes = Some(release_notes(options, &tag, &analysis));
316        report.next_version = Some(version.to_string());
317        report.tag = Some(tag.clone());
318
319        if self.tag_exists(options, &tag)? {
320            report.outcome = ReleaseOutcome::SkippedTagExists;
321            return Ok(report);
322        }
323
324        // No version rewrite: the manifest is already at the released version,
325        // so only the caller's extra files are staged. With none to stage the
326        // tag lands on the existing head instead of an empty commit.
327        let file_updates = extra_file_updates(&options.repo_root, &options.extra_files, &[])?;
328
329        let action = ReleaseAction::TagManifestVersion { branch: analysis.branch.clone() };
330        self.prepare_and_publish(options, analysis, version, tag, file_updates, action, report)
331    }
332
333    /// Record the files and the action the run takes, then stop for a dry run
334    /// or publish the prepared release.
335    fn prepare_and_publish(
336        &self,
337        options: &ReleaseOptions,
338        analysis: Analysis,
339        next_version: Version,
340        tag: String,
341        file_updates: Vec<FileUpdate>,
342        action: ReleaseAction,
343        mut report: ReleaseReport,
344    ) -> Result<ReleaseReport> {
345        report.files_updated = file_updates.iter().map(|update| update.file.clone()).collect();
346        let via_pull_request = matches!(action, ReleaseAction::ProposeReleasePullRequest { .. });
347        report.action = Some(action);
348
349        if options.dry_run {
350            report.outcome = ReleaseOutcome::DryRun;
351            return Ok(report);
352        }
353
354        let prepared = PreparedRelease {
355            branch: analysis.branch,
356            head_sha: analysis.head_sha,
357            next_version,
358            tag,
359            file_updates,
360            via_pull_request,
361        };
362        self.publish(options, &prepared, &mut report)?;
363        Ok(report)
364    }
365
366    fn tag_exists(&self, options: &ReleaseOptions, tag: &str) -> Result<bool> {
367        Ok(self
368            .github
369            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
370            .is_some())
371    }
372
373    /// Read the branch head, the manifest version, the latest release tag, and
374    /// the commits since that tag (or the recent history before the first
375    /// release).
376    fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
377        let owner = &options.owner;
378        let repo = &options.repo;
379        let branch = match options.base_branch.as_deref().map(str::trim) {
380            Some(branch) if !branch.is_empty() => branch.to_owned(),
381            _ => self.github.default_branch(owner, repo)?,
382        };
383        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
384
385        let current_version = versioning::current_version(&options.repo_root)?;
386        let current = Version::parse(&current_version).with_context(|| {
387            format!("invalid current version '{current_version}' in Cargo.toml")
388        })?;
389
390        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
391        let last_released = last_tag
392            .as_ref()
393            .and_then(|tag| tag.name.strip_prefix(options.tag_prefix.as_str()))
394            .and_then(|version| Version::parse(version).ok());
395        let range = match &last_tag {
396            Some(tag) => {
397                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
398            }
399            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
400        };
401
402        Ok(Analysis {
403            branch,
404            head_sha,
405            current_version,
406            current,
407            last_tag: last_tag.map(|tag| tag.name),
408            last_released,
409            commits: conventional::classify_commits(&range.commits),
410            truncated: range.truncated,
411        })
412    }
413
414    /// Publish the prepared release. The cheap head re-read gives a clear
415    /// skip; the fast-forward-only ref update is the authoritative
416    /// compare-and-swap against a branch that advanced mid-run.
417    fn publish(
418        &self,
419        options: &ReleaseOptions,
420        prepared: &PreparedRelease,
421        report: &mut ReleaseReport,
422    ) -> Result<()> {
423        let staged = !prepared.file_updates.is_empty();
424        let commit_sha =
425            if staged { self.build_commit(options, prepared)? } else { prepared.head_sha.clone() };
426
427        let current_head =
428            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
429        if current_head != prepared.head_sha {
430            report.outcome = ReleaseOutcome::SkippedRace;
431            return Ok(());
432        }
433        report.commit_sha = Some(commit_sha.clone());
434
435        if prepared.via_pull_request {
436            return self.publish_pull_request(options, prepared, &commit_sha, report);
437        }
438
439        if staged
440            && !self.github.update_ref_fast_forward(
441                &options.owner,
442                &options.repo,
443                &format!("heads/{}", prepared.branch),
444                &commit_sha,
445            )?
446        {
447            report.outcome = ReleaseOutcome::SkippedRace;
448            return Ok(());
449        }
450
451        if options.phase == ReleasePhase::Bump {
452            report.outcome = ReleaseOutcome::VersionCommitted;
453            return Ok(());
454        }
455
456        self.finalize(options, prepared, &commit_sha, report)
457    }
458
459    fn publish_pull_request(
460        &self,
461        options: &ReleaseOptions,
462        prepared: &PreparedRelease,
463        commit_sha: &str,
464        report: &mut ReleaseReport,
465    ) -> Result<()> {
466        let owner = &options.owner;
467        let repo = &options.repo;
468        let branch_ref = format!("heads/{}", options.release_branch);
469        self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
470
471        let title = format!("chore(release): {}", prepared.tag);
472        let notes = report.notes.clone().unwrap_or_default();
473        let body = format!(
474            "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: {} -->",
475            prepared.tag,
476            report.current_version,
477            prepared.next_version,
478            prepared.branch,
479            notes,
480            options.release_branch,
481        );
482        let pull_request = self.upsert_release_pull_request(
483            owner,
484            repo,
485            &options.release_branch,
486            &prepared.branch,
487            &title,
488            &body,
489            report,
490        )?;
491        report.pull_request_number = Some(pull_request.number);
492        report.pull_request_url = Some(pull_request.url);
493        report.release_branch = Some(options.release_branch.clone());
494        Ok(())
495    }
496
497    fn update_release_branch(
498        &self,
499        owner: &str,
500        repo: &str,
501        branch_ref: &str,
502        commit_sha: &str,
503    ) -> Result<()> {
504        if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
505            self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
506        } else {
507            self.github.create_ref(owner, repo, branch_ref, commit_sha)
508        }
509    }
510
511    fn upsert_release_pull_request(
512        &self,
513        owner: &str,
514        repo: &str,
515        head: &str,
516        base: &str,
517        title: &str,
518        body: &str,
519        report: &mut ReleaseReport,
520    ) -> Result<crate::github::PullRequestInfo> {
521        let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
522        if let Some(existing) = existing {
523            let updated =
524                self.github.update_pull_request(owner, repo, existing.number, title, body)?;
525            report.outcome = ReleaseOutcome::PullRequestUpdated;
526            Ok(updated)
527        } else {
528            let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
529            report.outcome = ReleaseOutcome::PullRequestCreated;
530            Ok(created)
531        }
532    }
533
534    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
535        let owner = &options.owner;
536        let repo = &options.repo;
537        let repo_root = options.repo_root.canonicalize().with_context(|| {
538            format!("failed to resolve repository root '{}'", options.repo_root.display())
539        })?;
540
541        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
542        let mut tree_entries = Vec::new();
543        for update in &prepared.file_updates {
544            let path = remote::relative_repository_path(&repo_root, &update.file)?;
545            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
546            tree_entries.push(TreeEntry { path, sha: blob_sha });
547        }
548        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
549
550        let message =
551            options.commit_message.replace("{version}", &prepared.next_version.to_string());
552        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
553    }
554
555    fn finalize(
556        &self,
557        options: &ReleaseOptions,
558        prepared: &PreparedRelease,
559        commit_sha: &str,
560        report: &mut ReleaseReport,
561    ) -> Result<()> {
562        let owner = &options.owner;
563        let repo = &options.repo;
564        let tag_target = match options.tag_style {
565            TagStyle::Annotated => self.github.create_annotated_tag(
566                owner,
567                repo,
568                &prepared.tag,
569                &format!("Release {}", prepared.tag),
570                commit_sha,
571            )?,
572            TagStyle::Lightweight => commit_sha.to_owned(),
573        };
574        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
575
576        if options.update_major_alias {
577            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
578            let alias_ref = format!("tags/{alias}");
579            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
580                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
581            } else {
582                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
583            }
584            report.major_alias = Some(alias);
585        }
586
587        let notes = report.notes.clone().unwrap_or_default();
588        let release = self.github.create_release(
589            owner,
590            repo,
591            &prepared.tag,
592            &format!("Release {}", prepared.tag),
593            &notes,
594            commit_sha,
595        )?;
596        report.release_url = Some(release.url);
597        report.outcome = ReleaseOutcome::Released;
598        Ok(())
599    }
600}
601
602/// Release notes for `tag`, without the automation's own version-bump commits.
603fn release_notes(options: &ReleaseOptions, tag: &str, analysis: &Analysis) -> String {
604    let commits = without_release_commits(&analysis.commits, &options.commit_message);
605    conventional::release_notes(tag, &commits, analysis.truncated)
606}
607
608/// Drop the commits that are this tool's own version bumps: the first line of
609/// `commit_message` rendered for any version, also as squash-merged from a
610/// release pull request (`chore: release v1.2.3 (#42)`). The tag phase and a
611/// merged release pull request both release a range that contains the bump
612/// commit, and listing it under "Maintenance" would only repeat the title.
613fn without_release_commits(
614    commits: &[ConventionalCommit],
615    commit_message: &str,
616) -> Vec<ConventionalCommit> {
617    let template = commit_message.lines().next().unwrap_or_default().trim();
618    let pattern = template
619        .split("{version}")
620        .map(regex::escape)
621        .collect::<Vec<_>>()
622        .join(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?");
623    let Ok(release_commit) = Regex::new(&format!(r"^{pattern}(?: \(#\d+\))?$")) else {
624        return commits.to_vec();
625    };
626    commits.iter().filter(|commit| !release_commit.is_match(&commit.subject)).cloned().collect()
627}
628
629/// A report that releases nothing yet; callers fill in what the run decides.
630fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
631    ReleaseReport {
632        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
633        action: None,
634        current_version: analysis.current_version.clone(),
635        next_version: None,
636        bump,
637        tag: None,
638        major_alias: None,
639        commit_sha: None,
640        release_url: None,
641        pull_request_number: None,
642        pull_request_url: None,
643        release_branch: None,
644        notes: None,
645        commits_analyzed: analysis.commits.len(),
646        commit_range_truncated: analysis.truncated,
647        files_updated: Vec::new(),
648    }
649}
650
651/// Read `extra_files` from the working tree so they ride along in the release
652/// commit. Paths the version rewrite already covers are skipped: the rewrite
653/// holds the bumped version, and staging a stale working-tree copy of the same
654/// path would silently revert it.
655fn extra_file_updates(
656    repo_root: &Path,
657    extra_files: &[PathBuf],
658    planned: &[FileUpdate],
659) -> Result<Vec<FileUpdate>> {
660    if extra_files.is_empty() {
661        return Ok(Vec::new());
662    }
663
664    let repo_root = repo_root
665        .canonicalize()
666        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
667
668    let mut updates = Vec::new();
669    for extra_file in extra_files {
670        let path =
671            if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
672        let path = path.canonicalize().with_context(|| {
673            format!("failed to resolve release file '{}'", extra_file.display())
674        })?;
675        // Keeps the commit inside the repository even when a caller passes an
676        // absolute path or one containing `..`.
677        remote::relative_repository_path(&repo_root, &path)?;
678
679        if planned.iter().any(|update| update.file == path)
680            || updates.iter().any(|update: &FileUpdate| update.file == path)
681        {
682            continue;
683        }
684
685        let updated_content = fs::read_to_string(&path)
686            .with_context(|| format!("failed to read release file '{}'", path.display()))?;
687        updates.push(FileUpdate { file: path, updated_content });
688    }
689
690    Ok(updates)
691}
692
693fn validate_release_branch(branch: &str) -> Result<()> {
694    if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
695        && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
696    {
697        anyhow::bail!(
698            "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
699        );
700    }
701    Ok(())
702}
703
704// Tests live in a sibling file to keep this module within the repository's
705// file-size lint budget; they remain `super::`-scoped unit tests.
706#[cfg(test)]
707#[path = "release_tests.rs"]
708#[allow(clippy::significant_drop_tightening)]
709mod tests;