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 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/// What a run does once its analysis finds a version to release, or on a dry
99/// run what it would have done. Recorded so a dry run can say which path the
100/// real run takes, not just which version it computed.
101#[derive(Debug, Clone, Eq, PartialEq)]
102pub enum ReleaseAction {
103    /// Commit the version bump to `branch`, tag that commit, and publish the
104    /// GitHub Release.
105    CommitAndRelease { branch: String },
106    /// Commit the version bump to `branch` and stop, leaving the tag to a later
107    /// [`ReleasePhase::Tag`] run.
108    CommitBump { branch: String },
109    /// Tag the version the manifest already holds on `branch` and publish its
110    /// GitHub Release, without bumping it. Only staged extra files, if any, are
111    /// committed first.
112    TagManifestVersion { branch: String },
113    /// Point `release_branch` at a version-bump commit on top of `base` and
114    /// open or update its pull request into `base`. Nothing is tagged until the
115    /// pull request merges.
116    ProposeReleasePullRequest { release_branch: String, base: String },
117}
118
119#[derive(Debug, Clone, Eq, PartialEq)]
120pub struct ReleaseReport {
121    pub outcome: ReleaseOutcome,
122    /// The path the run took (or, on a dry run, would take); `None` when it
123    /// stopped before choosing one because nothing needed releasing or the tag
124    /// already exists.
125    pub action: Option<ReleaseAction>,
126    pub current_version: String,
127    pub next_version: Option<String>,
128    pub bump: Option<BumpLevel>,
129    pub tag: Option<String>,
130    pub major_alias: Option<String>,
131    pub commit_sha: Option<String>,
132    pub release_url: Option<String>,
133    pub pull_request_number: Option<u64>,
134    pub pull_request_url: Option<String>,
135    pub release_branch: Option<String>,
136    pub notes: Option<String>,
137    pub commits_analyzed: usize,
138    pub commit_range_truncated: bool,
139    pub files_updated: Vec<PathBuf>,
140}
141
142impl ReleaseReport {
143    /// Render `$GITHUB_OUTPUT` lines. Keys are always present; values are
144    /// empty when the outcome did not produce them.
145    pub fn github_outputs(&self, notes_file: &Path) -> String {
146        let notes_path =
147            if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
148        format!(
149            "released={}\nversion={}\ntag={}\ncommit={}\nrelease-url={}\nrelease-pr-number={}\nrelease-pr-url={}\nrelease-branch={}\nnotes-file={notes_path}\n",
150            self.outcome == ReleaseOutcome::Released,
151            self.next_version.as_deref().unwrap_or_default(),
152            self.tag.as_deref().unwrap_or_default(),
153            self.commit_sha.as_deref().unwrap_or_default(),
154            self.release_url.as_deref().unwrap_or_default(),
155            self.pull_request_number.map(|number| number.to_string()).unwrap_or_default(),
156            self.pull_request_url.as_deref().unwrap_or_default(),
157            self.release_branch.as_deref().unwrap_or_default(),
158        )
159    }
160}
161
162#[derive(Debug)]
163struct Analysis {
164    branch: String,
165    head_sha: String,
166    current_version: String,
167    current: Version,
168    /// Name of the latest release tag and the version it carries, both absent
169    /// until the first release.
170    last_tag: Option<String>,
171    last_released: Option<Version>,
172    commits: Vec<ConventionalCommit>,
173    truncated: bool,
174}
175
176impl Analysis {
177    /// True when the manifest holds a version that was never tagged.
178    ///
179    /// A direct release tags the version in the same run that bumps it, so the
180    /// two only diverge once a release pull request lands its bump on the base
181    /// branch without a tag - or on a repository that has never released.
182    fn manifest_unreleased(&self) -> bool {
183        self.last_released.as_ref().is_none_or(|released| self.current > *released)
184    }
185
186    /// Refuse to release from a manifest older than the latest release tag.
187    ///
188    /// The commit range starts at the highest tag, but the next version is
189    /// bumped from the manifest. A manifest behind that tag therefore yields a
190    /// version at or below one already released: it either collides with an
191    /// existing tag or publishes a release older than the latest one, and as
192    /// the range never moves past the highest tag, every later run repeats it.
193    fn ensure_manifest_not_behind_latest_tag(&self) -> Result<()> {
194        match (&self.last_tag, &self.last_released) {
195            (Some(tag), Some(released)) if self.current < *released => bail!(
196                "Cargo.toml version {current} is lower than the latest release tag {tag}. \
197                 Releasing would bump from {current} and produce a version at or below one that is \
198                 already tagged. Set the manifest version to {released}, or delete {tag} if it was \
199                 created by mistake, then re-run.",
200                current = self.current_version,
201            ),
202            _ => Ok(()),
203        }
204    }
205}
206
207#[derive(Debug)]
208struct PreparedRelease {
209    branch: String,
210    head_sha: String,
211    next_version: Version,
212    tag: String,
213    file_updates: Vec<FileUpdate>,
214    /// Route the version bump through a pull request instead of publishing it.
215    /// Tagging a version the manifest already holds needs no bump, so it
216    /// publishes directly even when the caller asked for release pull requests.
217    via_pull_request: bool,
218}
219
220#[derive(Debug, Clone)]
221pub struct ReleasePublisher {
222    github: GitHubClient,
223}
224
225impl ReleasePublisher {
226    #[must_use]
227    pub const fn new(github: GitHubClient) -> Self {
228        Self { github }
229    }
230
231    /// Analyze the commits since the latest release tag and release, propose,
232    /// or (on a dry run) report the next version according to `options`.
233    pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
234        self.github.ensure_token()?;
235        if options.create_pr {
236            if options.phase != ReleasePhase::All {
237                anyhow::bail!(
238                    "--create-pr cannot be combined with --phase: the release pull request already separates the version bump from the tag"
239                );
240            }
241            validate_release_branch(&options.release_branch)?;
242        }
243        let analysis = self.analyze(options)?;
244        analysis.ensure_manifest_not_behind_latest_tag()?;
245        if options.phase == ReleasePhase::Tag {
246            return self.release_manifest_version(options, analysis);
247        }
248        // A merged release pull request leaves the bump on the base branch with
249        // no tag. Bumping again would open a fresh pull request for a version
250        // nobody asked for and leave the merged one unreleased forever, so the
251        // version already on the branch is what gets tagged.
252        if options.create_pr && analysis.manifest_unreleased() {
253            return self.release_manifest_version(options, analysis);
254        }
255        self.release_bumped_version(options, analysis)
256    }
257
258    /// Bump the manifest version from the conventional commits, then commit it.
259    /// [`ReleasePhase::Bump`] stops there; otherwise the same run tags it.
260    fn release_bumped_version(
261        &self,
262        options: &ReleaseOptions,
263        analysis: Analysis,
264    ) -> Result<ReleaseReport> {
265        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
266        let mut report = initial_report(&analysis, bump);
267
268        let Some(bump_level) = bump else {
269            return Ok(report);
270        };
271
272        let next_version = conventional::bump_version(&analysis.current, bump_level);
273        let next = next_version.to_string();
274        let tag = format!("{}{next}", options.tag_prefix);
275        report.notes =
276            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
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 =
316            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
317        report.next_version = Some(version.to_string());
318        report.tag = Some(tag.clone());
319
320        if self.tag_exists(options, &tag)? {
321            report.outcome = ReleaseOutcome::SkippedTagExists;
322            return Ok(report);
323        }
324
325        // No version rewrite: the manifest is already at the released version,
326        // so only the caller's extra files are staged. With none to stage the
327        // tag lands on the existing head instead of an empty commit.
328        let file_updates = extra_file_updates(&options.repo_root, &options.extra_files, &[])?;
329
330        let action = ReleaseAction::TagManifestVersion { branch: analysis.branch.clone() };
331        self.prepare_and_publish(options, analysis, version, tag, file_updates, action, report)
332    }
333
334    /// Record the files and the action the run takes, then stop for a dry run
335    /// or publish the prepared release.
336    fn prepare_and_publish(
337        &self,
338        options: &ReleaseOptions,
339        analysis: Analysis,
340        next_version: Version,
341        tag: String,
342        file_updates: Vec<FileUpdate>,
343        action: ReleaseAction,
344        mut report: ReleaseReport,
345    ) -> Result<ReleaseReport> {
346        report.files_updated = file_updates.iter().map(|update| update.file.clone()).collect();
347        let via_pull_request = matches!(action, ReleaseAction::ProposeReleasePullRequest { .. });
348        report.action = Some(action);
349
350        if options.dry_run {
351            report.outcome = ReleaseOutcome::DryRun;
352            return Ok(report);
353        }
354
355        let prepared = PreparedRelease {
356            branch: analysis.branch,
357            head_sha: analysis.head_sha,
358            next_version,
359            tag,
360            file_updates,
361            via_pull_request,
362        };
363        self.publish(options, &prepared, &mut report)?;
364        Ok(report)
365    }
366
367    fn tag_exists(&self, options: &ReleaseOptions, tag: &str) -> Result<bool> {
368        Ok(self
369            .github
370            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
371            .is_some())
372    }
373
374    /// Read the branch head, the manifest version, the latest release tag, and
375    /// the commits since that tag (or the recent history before the first
376    /// release).
377    fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
378        let owner = &options.owner;
379        let repo = &options.repo;
380        let branch = match options.base_branch.as_deref().map(str::trim) {
381            Some(branch) if !branch.is_empty() => branch.to_owned(),
382            _ => self.github.default_branch(owner, repo)?,
383        };
384        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
385
386        let current_version = versioning::current_version(&options.repo_root)?;
387        let current = Version::parse(&current_version).with_context(|| {
388            format!("invalid current version '{current_version}' in Cargo.toml")
389        })?;
390
391        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
392        let last_released = last_tag
393            .as_ref()
394            .and_then(|tag| tag.name.strip_prefix(options.tag_prefix.as_str()))
395            .and_then(|version| Version::parse(version).ok());
396        let range = match &last_tag {
397            Some(tag) => {
398                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
399            }
400            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
401        };
402
403        Ok(Analysis {
404            branch,
405            head_sha,
406            current_version,
407            current,
408            last_tag: last_tag.map(|tag| tag.name),
409            last_released,
410            commits: conventional::classify_commits(&range.commits),
411            truncated: range.truncated,
412        })
413    }
414
415    /// Publish the prepared release. The cheap head re-read gives a clear
416    /// skip; the fast-forward-only ref update is the authoritative
417    /// compare-and-swap against a branch that advanced mid-run.
418    fn publish(
419        &self,
420        options: &ReleaseOptions,
421        prepared: &PreparedRelease,
422        report: &mut ReleaseReport,
423    ) -> Result<()> {
424        let staged = !prepared.file_updates.is_empty();
425        let commit_sha =
426            if staged { self.build_commit(options, prepared)? } else { prepared.head_sha.clone() };
427
428        let current_head =
429            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
430        if current_head != prepared.head_sha {
431            report.outcome = ReleaseOutcome::SkippedRace;
432            return Ok(());
433        }
434        report.commit_sha = Some(commit_sha.clone());
435
436        if prepared.via_pull_request {
437            return self.publish_pull_request(options, prepared, &commit_sha, report);
438        }
439
440        if staged
441            && !self.github.update_ref_fast_forward(
442                &options.owner,
443                &options.repo,
444                &format!("heads/{}", prepared.branch),
445                &commit_sha,
446            )?
447        {
448            report.outcome = ReleaseOutcome::SkippedRace;
449            return Ok(());
450        }
451
452        if options.phase == ReleasePhase::Bump {
453            report.outcome = ReleaseOutcome::VersionCommitted;
454            return Ok(());
455        }
456
457        self.finalize(options, prepared, &commit_sha, report)
458    }
459
460    fn publish_pull_request(
461        &self,
462        options: &ReleaseOptions,
463        prepared: &PreparedRelease,
464        commit_sha: &str,
465        report: &mut ReleaseReport,
466    ) -> Result<()> {
467        let owner = &options.owner;
468        let repo = &options.repo;
469        let branch_ref = format!("heads/{}", options.release_branch);
470        self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
471
472        let title = format!("chore(release): {}", prepared.tag);
473        let notes = report.notes.clone().unwrap_or_default();
474        let body = format!(
475            "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: {} -->",
476            prepared.tag,
477            report.current_version,
478            prepared.next_version,
479            prepared.branch,
480            notes,
481            options.release_branch,
482        );
483        let pull_request = self.upsert_release_pull_request(
484            owner,
485            repo,
486            &options.release_branch,
487            &prepared.branch,
488            &title,
489            &body,
490            report,
491        )?;
492        report.pull_request_number = Some(pull_request.number);
493        report.pull_request_url = Some(pull_request.url);
494        report.release_branch = Some(options.release_branch.clone());
495        Ok(())
496    }
497
498    fn update_release_branch(
499        &self,
500        owner: &str,
501        repo: &str,
502        branch_ref: &str,
503        commit_sha: &str,
504    ) -> Result<()> {
505        if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
506            self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
507        } else {
508            self.github.create_ref(owner, repo, branch_ref, commit_sha)
509        }
510    }
511
512    fn upsert_release_pull_request(
513        &self,
514        owner: &str,
515        repo: &str,
516        head: &str,
517        base: &str,
518        title: &str,
519        body: &str,
520        report: &mut ReleaseReport,
521    ) -> Result<crate::github::PullRequestInfo> {
522        let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
523        if let Some(existing) = existing {
524            let updated =
525                self.github.update_pull_request(owner, repo, existing.number, title, body)?;
526            report.outcome = ReleaseOutcome::PullRequestUpdated;
527            Ok(updated)
528        } else {
529            let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
530            report.outcome = ReleaseOutcome::PullRequestCreated;
531            Ok(created)
532        }
533    }
534
535    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
536        let owner = &options.owner;
537        let repo = &options.repo;
538        let repo_root = options.repo_root.canonicalize().with_context(|| {
539            format!("failed to resolve repository root '{}'", options.repo_root.display())
540        })?;
541
542        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
543        let mut tree_entries = Vec::new();
544        for update in &prepared.file_updates {
545            let path = remote::relative_repository_path(&repo_root, &update.file)?;
546            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
547            tree_entries.push(TreeEntry { path, sha: blob_sha });
548        }
549        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
550
551        let message =
552            options.commit_message.replace("{version}", &prepared.next_version.to_string());
553        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
554    }
555
556    fn finalize(
557        &self,
558        options: &ReleaseOptions,
559        prepared: &PreparedRelease,
560        commit_sha: &str,
561        report: &mut ReleaseReport,
562    ) -> Result<()> {
563        let owner = &options.owner;
564        let repo = &options.repo;
565        let tag_target = match options.tag_style {
566            TagStyle::Annotated => self.github.create_annotated_tag(
567                owner,
568                repo,
569                &prepared.tag,
570                &format!("Release {}", prepared.tag),
571                commit_sha,
572            )?,
573            TagStyle::Lightweight => commit_sha.to_owned(),
574        };
575        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
576
577        if options.update_major_alias {
578            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
579            let alias_ref = format!("tags/{alias}");
580            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
581                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
582            } else {
583                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
584            }
585            report.major_alias = Some(alias);
586        }
587
588        let notes = report.notes.clone().unwrap_or_default();
589        let release = self.github.create_release(
590            owner,
591            repo,
592            &prepared.tag,
593            &format!("Release {}", prepared.tag),
594            &notes,
595            commit_sha,
596        )?;
597        report.release_url = Some(release.url);
598        report.outcome = ReleaseOutcome::Released;
599        Ok(())
600    }
601}
602
603/// A report that releases nothing yet; callers fill in what the run decides.
604fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
605    ReleaseReport {
606        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
607        action: None,
608        current_version: analysis.current_version.clone(),
609        next_version: None,
610        bump,
611        tag: None,
612        major_alias: None,
613        commit_sha: None,
614        release_url: None,
615        pull_request_number: None,
616        pull_request_url: None,
617        release_branch: None,
618        notes: None,
619        commits_analyzed: analysis.commits.len(),
620        commit_range_truncated: analysis.truncated,
621        files_updated: Vec::new(),
622    }
623}
624
625/// Read `extra_files` from the working tree so they ride along in the release
626/// commit. Paths the version rewrite already covers are skipped: the rewrite
627/// holds the bumped version, and staging a stale working-tree copy of the same
628/// path would silently revert it.
629fn extra_file_updates(
630    repo_root: &Path,
631    extra_files: &[PathBuf],
632    planned: &[FileUpdate],
633) -> Result<Vec<FileUpdate>> {
634    if extra_files.is_empty() {
635        return Ok(Vec::new());
636    }
637
638    let repo_root = repo_root
639        .canonicalize()
640        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
641
642    let mut updates = Vec::new();
643    for extra_file in extra_files {
644        let path =
645            if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
646        let path = path.canonicalize().with_context(|| {
647            format!("failed to resolve release file '{}'", extra_file.display())
648        })?;
649        // Keeps the commit inside the repository even when a caller passes an
650        // absolute path or one containing `..`.
651        remote::relative_repository_path(&repo_root, &path)?;
652
653        if planned.iter().any(|update| update.file == path)
654            || updates.iter().any(|update: &FileUpdate| update.file == path)
655        {
656            continue;
657        }
658
659        let updated_content = fs::read_to_string(&path)
660            .with_context(|| format!("failed to read release file '{}'", path.display()))?;
661        updates.push(FileUpdate { file: path, updated_content });
662    }
663
664    Ok(updates)
665}
666
667fn validate_release_branch(branch: &str) -> Result<()> {
668    if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
669        && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
670    {
671        anyhow::bail!(
672            "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
673        );
674    }
675    Ok(())
676}
677
678// Tests live in a sibling file to keep this module within the repository's
679// file-size lint budget; they remain `super::`-scoped unit tests.
680#[cfg(test)]
681#[path = "release_tests.rs"]
682#[allow(clippy::significant_drop_tightening)]
683mod tests;