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#[derive(Debug, Clone, Eq, PartialEq)]
41pub struct ReleaseOptions {
42    pub repo_root: PathBuf,
43    pub owner: String,
44    pub repo: String,
45    pub base_branch: Option<String>,
46    /// Forced bump level; `None` derives it from conventional commits.
47    pub bump: Option<BumpLevel>,
48    pub tag_prefix: String,
49    pub tag_style: TagStyle,
50    pub update_major_alias: bool,
51    /// Commit message template; `{version}` is replaced with the new version.
52    pub commit_message: String,
53    /// Create or refresh an automation-owned release branch and pull request
54    /// instead of publishing directly to the base branch.
55    pub create_pr: bool,
56    pub release_branch: String,
57    pub dry_run: bool,
58    /// Extra working-tree files to stage into the release commit, on top of
59    /// the manifest and lockfile rewrites. Paths already covered by the
60    /// version rewrite are ignored so the rewrite stays authoritative.
61    pub extra_files: Vec<PathBuf>,
62}
63
64#[derive(Debug, Clone, Copy, Eq, PartialEq)]
65pub enum ReleaseOutcome {
66    Released,
67    PullRequestCreated,
68    PullRequestUpdated,
69    DryRun,
70    SkippedNoReleasableChanges,
71    SkippedRace,
72    SkippedTagExists,
73}
74
75#[derive(Debug, Clone, Eq, PartialEq)]
76pub struct ReleaseReport {
77    pub outcome: ReleaseOutcome,
78    pub current_version: String,
79    pub next_version: Option<String>,
80    pub bump: Option<BumpLevel>,
81    pub tag: Option<String>,
82    pub major_alias: Option<String>,
83    pub commit_sha: Option<String>,
84    pub release_url: Option<String>,
85    pub pull_request_number: Option<u64>,
86    pub pull_request_url: Option<String>,
87    pub release_branch: Option<String>,
88    pub notes: Option<String>,
89    pub commits_analyzed: usize,
90    pub commit_range_truncated: bool,
91    pub files_updated: Vec<PathBuf>,
92}
93
94impl ReleaseReport {
95    /// Render `$GITHUB_OUTPUT` lines. Keys are always present; values are
96    /// empty when the outcome did not produce them.
97    pub fn github_outputs(&self, notes_file: &Path) -> String {
98        let notes_path =
99            if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
100        format!(
101            "released={}\nversion={}\ntag={}\nrelease-url={}\nrelease-pr-number={}\nrelease-pr-url={}\nrelease-branch={}\nnotes-file={notes_path}\n",
102            self.outcome == ReleaseOutcome::Released,
103            self.next_version.as_deref().unwrap_or_default(),
104            self.tag.as_deref().unwrap_or_default(),
105            self.release_url.as_deref().unwrap_or_default(),
106            self.pull_request_number.map(|number| number.to_string()).unwrap_or_default(),
107            self.pull_request_url.as_deref().unwrap_or_default(),
108            self.release_branch.as_deref().unwrap_or_default(),
109        )
110    }
111}
112
113#[derive(Debug)]
114struct Analysis {
115    branch: String,
116    head_sha: String,
117    current_version: String,
118    current: Version,
119    commits: Vec<ConventionalCommit>,
120    truncated: bool,
121}
122
123#[derive(Debug)]
124struct PreparedRelease {
125    branch: String,
126    head_sha: String,
127    next_version: Version,
128    tag: String,
129    plan: versioning::VersionRewritePlan,
130}
131
132#[derive(Debug, Clone)]
133pub struct ReleasePublisher {
134    github: GitHubClient,
135}
136
137impl ReleasePublisher {
138    #[must_use]
139    pub const fn new(github: GitHubClient) -> Self {
140        Self { github }
141    }
142
143    pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
144        self.github.ensure_token()?;
145        if options.create_pr {
146            validate_release_branch(&options.release_branch)?;
147        }
148        let analysis = self.analyze(options)?;
149        let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
150        let mut report = initial_report(&analysis, bump);
151
152        let Some(bump_level) = bump else {
153            return Ok(report);
154        };
155
156        let next_version = conventional::bump_version(&analysis.current, bump_level);
157        let next = next_version.to_string();
158        let tag = format!("{}{next}", options.tag_prefix);
159        report.notes =
160            Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
161        report.next_version = Some(next);
162        report.tag = Some(tag.clone());
163
164        if self
165            .github
166            .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
167            .is_some()
168        {
169            report.outcome = ReleaseOutcome::SkippedTagExists;
170            return Ok(report);
171        }
172
173        let mut plan =
174            versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
175        plan.file_updates.extend(extra_file_updates(
176            &options.repo_root,
177            &options.extra_files,
178            &plan.file_updates,
179        )?);
180        report.files_updated = plan.file_updates.iter().map(|update| update.file.clone()).collect();
181
182        if options.dry_run {
183            report.outcome = ReleaseOutcome::DryRun;
184            return Ok(report);
185        }
186
187        let prepared = PreparedRelease {
188            branch: analysis.branch,
189            head_sha: analysis.head_sha,
190            next_version,
191            tag,
192            plan,
193        };
194        self.publish(options, &prepared, &mut report)?;
195        Ok(report)
196    }
197
198    fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
199        let owner = &options.owner;
200        let repo = &options.repo;
201        let branch = match options.base_branch.as_deref().map(str::trim) {
202            Some(branch) if !branch.is_empty() => branch.to_owned(),
203            _ => self.github.default_branch(owner, repo)?,
204        };
205        let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
206
207        let current_version = versioning::current_version(&options.repo_root)?;
208        let current = Version::parse(&current_version).with_context(|| {
209            format!("invalid current version '{current_version}' in Cargo.toml")
210        })?;
211
212        let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
213        let range = match &last_tag {
214            Some(tag) => {
215                self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
216            }
217            None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
218        };
219
220        Ok(Analysis {
221            branch,
222            head_sha,
223            current_version,
224            current,
225            commits: conventional::classify_commits(&range.commits),
226            truncated: range.truncated,
227        })
228    }
229
230    /// Publish the prepared release. The cheap head re-read gives a clear
231    /// skip; the fast-forward-only ref update is the authoritative
232    /// compare-and-swap against a branch that advanced mid-run.
233    fn publish(
234        &self,
235        options: &ReleaseOptions,
236        prepared: &PreparedRelease,
237        report: &mut ReleaseReport,
238    ) -> Result<()> {
239        let commit_sha = self.build_commit(options, prepared)?;
240
241        let current_head =
242            self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
243        if current_head != prepared.head_sha {
244            report.outcome = ReleaseOutcome::SkippedRace;
245            return Ok(());
246        }
247        report.commit_sha = Some(commit_sha.clone());
248
249        if options.create_pr {
250            return self.publish_pull_request(options, prepared, &commit_sha, report);
251        }
252
253        if !self.github.update_ref_fast_forward(
254            &options.owner,
255            &options.repo,
256            &format!("heads/{}", prepared.branch),
257            &commit_sha,
258        )? {
259            report.outcome = ReleaseOutcome::SkippedRace;
260            return Ok(());
261        }
262
263        self.finalize(options, prepared, &commit_sha, report)
264    }
265
266    fn publish_pull_request(
267        &self,
268        options: &ReleaseOptions,
269        prepared: &PreparedRelease,
270        commit_sha: &str,
271        report: &mut ReleaseReport,
272    ) -> Result<()> {
273        let owner = &options.owner;
274        let repo = &options.repo;
275        let branch_ref = format!("heads/{}", options.release_branch);
276        self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
277
278        let title = format!("chore(release): {}", prepared.tag);
279        let notes = report.notes.clone().unwrap_or_default();
280        let body = format!(
281            "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: {} -->",
282            prepared.tag,
283            report.current_version,
284            prepared.next_version,
285            prepared.branch,
286            notes,
287            options.release_branch,
288        );
289        let pull_request = self.upsert_release_pull_request(
290            owner,
291            repo,
292            &options.release_branch,
293            &prepared.branch,
294            &title,
295            &body,
296            report,
297        )?;
298        report.pull_request_number = Some(pull_request.number);
299        report.pull_request_url = Some(pull_request.url);
300        report.release_branch = Some(options.release_branch.clone());
301        Ok(())
302    }
303
304    fn update_release_branch(
305        &self,
306        owner: &str,
307        repo: &str,
308        branch_ref: &str,
309        commit_sha: &str,
310    ) -> Result<()> {
311        if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
312            self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
313        } else {
314            self.github.create_ref(owner, repo, branch_ref, commit_sha)
315        }
316    }
317
318    fn upsert_release_pull_request(
319        &self,
320        owner: &str,
321        repo: &str,
322        head: &str,
323        base: &str,
324        title: &str,
325        body: &str,
326        report: &mut ReleaseReport,
327    ) -> Result<crate::github::PullRequestInfo> {
328        let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
329        if let Some(existing) = existing {
330            let updated =
331                self.github.update_pull_request(owner, repo, existing.number, title, body)?;
332            report.outcome = ReleaseOutcome::PullRequestUpdated;
333            Ok(updated)
334        } else {
335            let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
336            report.outcome = ReleaseOutcome::PullRequestCreated;
337            Ok(created)
338        }
339    }
340
341    fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
342        let owner = &options.owner;
343        let repo = &options.repo;
344        let repo_root = options.repo_root.canonicalize().with_context(|| {
345            format!("failed to resolve repository root '{}'", options.repo_root.display())
346        })?;
347
348        let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
349        let mut tree_entries = Vec::new();
350        for update in &prepared.plan.file_updates {
351            let path = remote::relative_repository_path(&repo_root, &update.file)?;
352            let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
353            tree_entries.push(TreeEntry { path, sha: blob_sha });
354        }
355        let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
356
357        let message =
358            options.commit_message.replace("{version}", &prepared.next_version.to_string());
359        self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
360    }
361
362    fn finalize(
363        &self,
364        options: &ReleaseOptions,
365        prepared: &PreparedRelease,
366        commit_sha: &str,
367        report: &mut ReleaseReport,
368    ) -> Result<()> {
369        let owner = &options.owner;
370        let repo = &options.repo;
371        let tag_target = match options.tag_style {
372            TagStyle::Annotated => self.github.create_annotated_tag(
373                owner,
374                repo,
375                &prepared.tag,
376                &format!("Release {}", prepared.tag),
377                commit_sha,
378            )?,
379            TagStyle::Lightweight => commit_sha.to_owned(),
380        };
381        self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
382
383        if options.update_major_alias {
384            let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
385            let alias_ref = format!("tags/{alias}");
386            if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
387                self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
388            } else {
389                self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
390            }
391            report.major_alias = Some(alias);
392        }
393
394        let notes = report.notes.clone().unwrap_or_default();
395        let release = self.github.create_release(
396            owner,
397            repo,
398            &prepared.tag,
399            &format!("Release {}", prepared.tag),
400            &notes,
401            commit_sha,
402        )?;
403        report.release_url = Some(release.url);
404        report.outcome = ReleaseOutcome::Released;
405        Ok(())
406    }
407}
408
409fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
410    ReleaseReport {
411        outcome: ReleaseOutcome::SkippedNoReleasableChanges,
412        current_version: analysis.current_version.clone(),
413        next_version: None,
414        bump,
415        tag: None,
416        major_alias: None,
417        commit_sha: None,
418        release_url: None,
419        pull_request_number: None,
420        pull_request_url: None,
421        release_branch: None,
422        notes: None,
423        commits_analyzed: analysis.commits.len(),
424        commit_range_truncated: analysis.truncated,
425        files_updated: Vec::new(),
426    }
427}
428
429/// Read `extra_files` from the working tree so they ride along in the release
430/// commit. Paths the version rewrite already covers are skipped: the rewrite
431/// holds the bumped version, and staging a stale working-tree copy of the same
432/// path would silently revert it.
433fn extra_file_updates(
434    repo_root: &Path,
435    extra_files: &[PathBuf],
436    planned: &[FileUpdate],
437) -> Result<Vec<FileUpdate>> {
438    if extra_files.is_empty() {
439        return Ok(Vec::new());
440    }
441
442    let repo_root = repo_root
443        .canonicalize()
444        .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
445
446    let mut updates = Vec::new();
447    for extra_file in extra_files {
448        let path =
449            if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
450        let path = path.canonicalize().with_context(|| {
451            format!("failed to resolve release file '{}'", extra_file.display())
452        })?;
453        // Keeps the commit inside the repository even when a caller passes an
454        // absolute path or one containing `..`.
455        remote::relative_repository_path(&repo_root, &path)?;
456
457        if planned.iter().any(|update| update.file == path)
458            || updates.iter().any(|update: &FileUpdate| update.file == path)
459        {
460            continue;
461        }
462
463        let updated_content = fs::read_to_string(&path)
464            .with_context(|| format!("failed to read release file '{}'", path.display()))?;
465        updates.push(FileUpdate { file: path, updated_content });
466    }
467
468    Ok(updates)
469}
470
471fn validate_release_branch(branch: &str) -> Result<()> {
472    if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
473        && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
474    {
475        anyhow::bail!(
476            "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
477        );
478    }
479    Ok(())
480}
481
482// Tests live in a sibling file to keep this module within the repository's
483// file-size lint budget; they remain `super::`-scoped unit tests.
484#[cfg(test)]
485#[path = "release_tests.rs"]
486#[allow(clippy::significant_drop_tightening)]
487mod tests;