1use 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#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
35pub enum TagStyle {
36 #[default]
37 Annotated,
38 Lightweight,
39}
40
41#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
51pub enum ReleasePhase {
52 #[default]
54 All,
55 Bump,
57 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 pub bump: Option<BumpLevel>,
69 pub tag_prefix: String,
70 pub tag_style: TagStyle,
71 pub update_major_alias: bool,
72 pub commit_message: String,
74 pub create_pr: bool,
77 pub release_branch: String,
78 pub dry_run: bool,
79 pub extra_files: Vec<PathBuf>,
83 pub phase: ReleasePhase,
84}
85
86#[derive(Debug, Clone, Copy, Eq, PartialEq)]
87pub enum ReleaseOutcome {
88 Released,
89 VersionCommitted,
91 PullRequestCreated,
92 PullRequestUpdated,
93 DryRun,
94 SkippedNoReleasableChanges,
95 SkippedRace,
96 SkippedTagExists,
97}
98
99#[derive(Debug, Clone, Eq, PartialEq)]
103pub enum ReleaseAction {
104 CommitAndRelease { branch: String },
107 CommitBump { branch: String },
110 TagManifestVersion { branch: String },
114 ProposeReleasePullRequest { release_branch: String, base: String },
118}
119
120#[derive(Debug, Clone, Eq, PartialEq)]
121pub struct ReleaseReport {
122 pub outcome: ReleaseOutcome,
123 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 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 last_tag: Option<String>,
172 last_released: Option<Version>,
173 commits: Vec<ConventionalCommit>,
174 truncated: bool,
175}
176
177impl Analysis {
178 fn manifest_unreleased(&self) -> bool {
184 self.last_released.as_ref().is_none_or(|released| self.current > *released)
185 }
186
187 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 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 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 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 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 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 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 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 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(¤t_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 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 ¬es,
594 commit_sha,
595 )?;
596 report.release_url = Some(release.url);
597 report.outcome = ReleaseOutcome::Released;
598 Ok(())
599 }
600}
601
602fn 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
608fn 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
629fn 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
651fn 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 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#[cfg(test)]
707#[path = "release_tests.rs"]
708#[allow(clippy::significant_drop_tightening)]
709mod tests;