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.release_with_expected_head(options, None)
236 }
237
238 pub fn release_at_head(
242 &self,
243 options: &ReleaseOptions,
244 expected_head: &str,
245 ) -> Result<ReleaseReport> {
246 anyhow::ensure!(
247 !expected_head.trim().is_empty(),
248 "expected release head must not be empty"
249 );
250 self.release_with_expected_head(options, Some(expected_head))
251 }
252
253 fn release_with_expected_head(
254 &self,
255 options: &ReleaseOptions,
256 expected_head: Option<&str>,
257 ) -> Result<ReleaseReport> {
258 self.github.ensure_token()?;
259 if options.create_pr {
260 if options.phase != ReleasePhase::All {
261 anyhow::bail!(
262 "--create-pr cannot be combined with --phase: the release pull request already separates the version bump from the tag"
263 );
264 }
265 validate_release_branch(&options.release_branch)?;
266 }
267 let analysis = self.analyze(options, expected_head)?;
268 analysis.ensure_manifest_not_behind_latest_tag()?;
269 if options.phase == ReleasePhase::Tag {
270 return self.release_manifest_version(options, analysis);
271 }
272 if options.create_pr && analysis.manifest_unreleased() {
277 return self.release_manifest_version(options, analysis);
278 }
279 self.release_bumped_version(options, analysis)
280 }
281
282 fn release_bumped_version(
285 &self,
286 options: &ReleaseOptions,
287 analysis: Analysis,
288 ) -> Result<ReleaseReport> {
289 let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
290 let mut report = initial_report(&analysis, bump);
291
292 let Some(bump_level) = bump else {
293 return Ok(report);
294 };
295
296 let next_version = conventional::bump_version(&analysis.current, bump_level);
297 let next = next_version.to_string();
298 let tag = format!("{}{next}", options.tag_prefix);
299 report.notes = Some(release_notes(options, &tag, &analysis));
300 report.next_version = Some(next);
301 report.tag = Some(tag.clone());
302
303 if self.tag_exists(options, &tag)? {
304 report.outcome = ReleaseOutcome::SkippedTagExists;
305 return Ok(report);
306 }
307
308 let plan =
309 self.version_rewrite_plan(options, &analysis.head_sha, &next_version.to_string())?;
310 let mut file_updates = plan.file_updates;
311 let extra = extra_file_updates(&options.repo_root, &options.extra_files, &file_updates)?;
312 file_updates.extend(extra);
313
314 let action = if options.create_pr {
315 ReleaseAction::ProposeReleasePullRequest {
316 release_branch: options.release_branch.clone(),
317 base: analysis.branch.clone(),
318 }
319 } else if options.phase == ReleasePhase::Bump {
320 ReleaseAction::CommitBump { branch: analysis.branch.clone() }
321 } else {
322 ReleaseAction::CommitAndRelease { branch: analysis.branch.clone() }
323 };
324 self.prepare_and_publish(options, analysis, next_version, tag, file_updates, action, report)
325 }
326
327 fn version_rewrite_plan(
328 &self,
329 options: &ReleaseOptions,
330 analyzed_head: &str,
331 next_version: &str,
332 ) -> Result<versioning::VersionRewritePlan> {
333 if options.dry_run {
337 return versioning::plan_read_only_rewrite(&options.repo_root, next_version);
338 }
339 let repo_root = options.repo_root.canonicalize()?;
340 let tracked =
341 self.github.regular_files_at_commit(&options.owner, &options.repo, analyzed_head)?;
342 if versioning::tracked_nested_lockfiles(&tracked.keys().cloned().collect()).next().is_none()
343 {
344 return versioning::plan_version_rewrite(&options.repo_root, next_version);
345 }
346 let rewrite = versioning::plan_version_rewrite_in_tree(
347 &repo_root,
348 next_version,
349 &tracked.keys().cloned().collect(),
350 )?;
351 self.github.validate_release_sources(
352 &options.owner,
353 &options.repo,
354 &repo_root,
355 &tracked,
356 &rewrite.original_files,
357 )?;
358 Ok(rewrite.plan)
359 }
360
361 fn release_manifest_version(
366 &self,
367 options: &ReleaseOptions,
368 analysis: Analysis,
369 ) -> Result<ReleaseReport> {
370 let mut report = initial_report(&analysis, None);
371 let version = analysis.current.clone();
372 let tag = format!("{}{version}", options.tag_prefix);
373 report.notes = Some(release_notes(options, &tag, &analysis));
374 report.next_version = Some(version.to_string());
375 report.tag = Some(tag.clone());
376
377 if self.tag_exists(options, &tag)? {
378 report.outcome = ReleaseOutcome::SkippedTagExists;
379 return Ok(report);
380 }
381
382 let file_updates = extra_file_updates(&options.repo_root, &options.extra_files, &[])?;
386
387 let action = ReleaseAction::TagManifestVersion { branch: analysis.branch.clone() };
388 self.prepare_and_publish(options, analysis, version, tag, file_updates, action, report)
389 }
390
391 fn prepare_and_publish(
394 &self,
395 options: &ReleaseOptions,
396 analysis: Analysis,
397 next_version: Version,
398 tag: String,
399 file_updates: Vec<FileUpdate>,
400 action: ReleaseAction,
401 mut report: ReleaseReport,
402 ) -> Result<ReleaseReport> {
403 report.files_updated = file_updates.iter().map(|update| update.file.clone()).collect();
404 let via_pull_request = matches!(action, ReleaseAction::ProposeReleasePullRequest { .. });
405 report.action = Some(action);
406
407 if options.dry_run {
408 report.outcome = ReleaseOutcome::DryRun;
409 return Ok(report);
410 }
411
412 let prepared = PreparedRelease {
413 branch: analysis.branch,
414 head_sha: analysis.head_sha,
415 next_version,
416 tag,
417 file_updates,
418 via_pull_request,
419 };
420 self.publish(options, &prepared, &mut report)?;
421 Ok(report)
422 }
423
424 fn tag_exists(&self, options: &ReleaseOptions, tag: &str) -> Result<bool> {
425 Ok(self
426 .github
427 .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
428 .is_some())
429 }
430
431 fn analyze(&self, options: &ReleaseOptions, expected_head: Option<&str>) -> Result<Analysis> {
435 let owner = &options.owner;
436 let repo = &options.repo;
437 let branch = match options.base_branch.as_deref().map(str::trim) {
438 Some(branch) if !branch.is_empty() => branch.to_owned(),
439 _ => self.github.default_branch(owner, repo)?,
440 };
441 let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
442 if let Some(expected) = expected_head {
443 anyhow::ensure!(
444 head_sha == expected,
445 "release branch '{branch}' head {head_sha} does not match expected head {expected}; validate the current head before releasing"
446 );
447 }
448
449 let current_version = versioning::current_version(&options.repo_root)?;
450 let current = Version::parse(¤t_version).with_context(|| {
451 format!("invalid current version '{current_version}' in Cargo.toml")
452 })?;
453
454 let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
455 let last_released = last_tag
456 .as_ref()
457 .and_then(|tag| tag.name.strip_prefix(options.tag_prefix.as_str()))
458 .and_then(|version| Version::parse(version).ok());
459 let range = match &last_tag {
460 Some(tag) => {
461 self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
462 }
463 None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
464 };
465
466 Ok(Analysis {
467 branch,
468 head_sha,
469 current_version,
470 current,
471 last_tag: last_tag.map(|tag| tag.name),
472 last_released,
473 commits: conventional::classify_commits(&range.commits),
474 truncated: range.truncated,
475 })
476 }
477
478 fn publish(
482 &self,
483 options: &ReleaseOptions,
484 prepared: &PreparedRelease,
485 report: &mut ReleaseReport,
486 ) -> Result<()> {
487 let staged = !prepared.file_updates.is_empty();
488 let commit_sha =
489 if staged { self.build_commit(options, prepared)? } else { prepared.head_sha.clone() };
490
491 let current_head =
492 self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
493 if current_head != prepared.head_sha {
494 report.outcome = ReleaseOutcome::SkippedRace;
495 return Ok(());
496 }
497 report.commit_sha = Some(commit_sha.clone());
498
499 if prepared.via_pull_request {
500 return self.publish_pull_request(options, prepared, &commit_sha, report);
501 }
502
503 if staged
504 && !self.github.update_ref_fast_forward(
505 &options.owner,
506 &options.repo,
507 &format!("heads/{}", prepared.branch),
508 &commit_sha,
509 )?
510 {
511 report.outcome = ReleaseOutcome::SkippedRace;
512 return Ok(());
513 }
514
515 if options.phase == ReleasePhase::Bump {
516 report.outcome = ReleaseOutcome::VersionCommitted;
517 return Ok(());
518 }
519
520 self.finalize(options, prepared, &commit_sha, report)
521 }
522
523 fn publish_pull_request(
524 &self,
525 options: &ReleaseOptions,
526 prepared: &PreparedRelease,
527 commit_sha: &str,
528 report: &mut ReleaseReport,
529 ) -> Result<()> {
530 let owner = &options.owner;
531 let repo = &options.repo;
532 let branch_ref = format!("heads/{}", options.release_branch);
533 self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
534
535 let title = format!("chore(release): {}", prepared.tag);
536 let notes = report.notes.clone().unwrap_or_default();
537 let body = format!(
538 "Automated release update for `{}`.\n\nThis PR updates the package version from `{}` to `{}`. Merging it into `{}` allows the release workflow to publish the tag and GitHub release.\n\n{}\n\n<!-- automated-release-branch: {} -->",
539 prepared.tag,
540 report.current_version,
541 prepared.next_version,
542 prepared.branch,
543 notes,
544 options.release_branch,
545 );
546 let pull_request = self.upsert_release_pull_request(
547 owner,
548 repo,
549 &options.release_branch,
550 &prepared.branch,
551 &title,
552 &body,
553 report,
554 )?;
555 report.pull_request_number = Some(pull_request.number);
556 report.pull_request_url = Some(pull_request.url);
557 report.release_branch = Some(options.release_branch.clone());
558 Ok(())
559 }
560
561 fn update_release_branch(
562 &self,
563 owner: &str,
564 repo: &str,
565 branch_ref: &str,
566 commit_sha: &str,
567 ) -> Result<()> {
568 if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
569 self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
570 } else {
571 self.github.create_ref(owner, repo, branch_ref, commit_sha)
572 }
573 }
574
575 fn upsert_release_pull_request(
576 &self,
577 owner: &str,
578 repo: &str,
579 head: &str,
580 base: &str,
581 title: &str,
582 body: &str,
583 report: &mut ReleaseReport,
584 ) -> Result<crate::github::PullRequestInfo> {
585 let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
586 if let Some(existing) = existing {
587 let updated =
588 self.github.update_pull_request(owner, repo, existing.number, title, body)?;
589 report.outcome = ReleaseOutcome::PullRequestUpdated;
590 Ok(updated)
591 } else {
592 let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
593 report.outcome = ReleaseOutcome::PullRequestCreated;
594 Ok(created)
595 }
596 }
597
598 fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
599 let owner = &options.owner;
600 let repo = &options.repo;
601 let repo_root = options.repo_root.canonicalize().with_context(|| {
602 format!("failed to resolve repository root '{}'", options.repo_root.display())
603 })?;
604
605 let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
606 let mut tree_entries = Vec::new();
607 for update in &prepared.file_updates {
608 let path = remote::relative_repository_path(&repo_root, &update.file)?;
609 let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
610 tree_entries.push(TreeEntry { path, sha: blob_sha });
611 }
612 let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
613
614 let message =
615 options.commit_message.replace("{version}", &prepared.next_version.to_string());
616 self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
617 }
618
619 fn finalize(
620 &self,
621 options: &ReleaseOptions,
622 prepared: &PreparedRelease,
623 commit_sha: &str,
624 report: &mut ReleaseReport,
625 ) -> Result<()> {
626 let owner = &options.owner;
627 let repo = &options.repo;
628 let tag_target = match options.tag_style {
629 TagStyle::Annotated => self.github.create_annotated_tag(
630 owner,
631 repo,
632 &prepared.tag,
633 &format!("Release {}", prepared.tag),
634 commit_sha,
635 )?,
636 TagStyle::Lightweight => commit_sha.to_owned(),
637 };
638 self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
639
640 if options.update_major_alias {
641 let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
642 let alias_ref = format!("tags/{alias}");
643 if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
644 self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
645 } else {
646 self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
647 }
648 report.major_alias = Some(alias);
649 }
650
651 let notes = report.notes.clone().unwrap_or_default();
652 let release = self.github.create_release(
653 owner,
654 repo,
655 &prepared.tag,
656 &format!("Release {}", prepared.tag),
657 ¬es,
658 commit_sha,
659 )?;
660 report.release_url = Some(release.url);
661 report.outcome = ReleaseOutcome::Released;
662 Ok(())
663 }
664}
665
666fn release_notes(options: &ReleaseOptions, tag: &str, analysis: &Analysis) -> String {
668 let commits = without_release_commits(&analysis.commits, &options.commit_message);
669 conventional::release_notes(tag, &commits, analysis.truncated)
670}
671
672fn without_release_commits(
678 commits: &[ConventionalCommit],
679 commit_message: &str,
680) -> Vec<ConventionalCommit> {
681 let template = commit_message.lines().next().unwrap_or_default().trim();
682 let pattern = template
683 .split("{version}")
684 .map(regex::escape)
685 .collect::<Vec<_>>()
686 .join(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?");
687 let Ok(release_commit) = Regex::new(&format!(r"^{pattern}(?: \(#\d+\))?$")) else {
688 return commits.to_vec();
689 };
690 commits.iter().filter(|commit| !release_commit.is_match(&commit.subject)).cloned().collect()
691}
692
693fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
695 ReleaseReport {
696 outcome: ReleaseOutcome::SkippedNoReleasableChanges,
697 action: None,
698 current_version: analysis.current_version.clone(),
699 next_version: None,
700 bump,
701 tag: None,
702 major_alias: None,
703 commit_sha: None,
704 release_url: None,
705 pull_request_number: None,
706 pull_request_url: None,
707 release_branch: None,
708 notes: None,
709 commits_analyzed: analysis.commits.len(),
710 commit_range_truncated: analysis.truncated,
711 files_updated: Vec::new(),
712 }
713}
714
715fn extra_file_updates(
720 repo_root: &Path,
721 extra_files: &[PathBuf],
722 planned: &[FileUpdate],
723) -> Result<Vec<FileUpdate>> {
724 if extra_files.is_empty() {
725 return Ok(Vec::new());
726 }
727
728 let repo_root = repo_root
729 .canonicalize()
730 .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
731
732 let mut updates = Vec::new();
733 for extra_file in extra_files {
734 let path =
735 if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
736 let path = path.canonicalize().with_context(|| {
737 format!("failed to resolve release file '{}'", extra_file.display())
738 })?;
739 remote::relative_repository_path(&repo_root, &path)?;
742
743 if planned.iter().any(|update| update.file == path)
744 || updates.iter().any(|update: &FileUpdate| update.file == path)
745 {
746 continue;
747 }
748
749 let updated_content = fs::read_to_string(&path)
750 .with_context(|| format!("failed to read release file '{}'", path.display()))?;
751 updates.push(FileUpdate { file: path, updated_content });
752 }
753
754 Ok(updates)
755}
756
757fn validate_release_branch(branch: &str) -> Result<()> {
758 if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
759 && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
760 {
761 anyhow::bail!(
762 "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
763 );
764 }
765 Ok(())
766}
767
768#[cfg(test)]
771#[path = "release_tests.rs"]
772#[allow(clippy::significant_drop_tightening)]
773mod tests;