1use 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#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
34pub enum TagStyle {
35 #[default]
36 Annotated,
37 Lightweight,
38}
39
40#[derive(Debug, Clone, Copy, Eq, PartialEq, Default)]
50pub enum ReleasePhase {
51 #[default]
53 All,
54 Bump,
56 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 pub bump: Option<BumpLevel>,
68 pub tag_prefix: String,
69 pub tag_style: TagStyle,
70 pub update_major_alias: bool,
71 pub commit_message: String,
73 pub create_pr: bool,
76 pub release_branch: String,
77 pub dry_run: bool,
78 pub extra_files: Vec<PathBuf>,
82 pub phase: ReleasePhase,
83}
84
85#[derive(Debug, Clone, Copy, Eq, PartialEq)]
86pub enum ReleaseOutcome {
87 Released,
88 VersionCommitted,
90 PullRequestCreated,
91 PullRequestUpdated,
92 DryRun,
93 SkippedNoReleasableChanges,
94 SkippedRace,
95 SkippedTagExists,
96}
97
98#[derive(Debug, Clone, Eq, PartialEq)]
102pub enum ReleaseAction {
103 CommitAndRelease { branch: String },
106 CommitBump { branch: String },
109 TagManifestVersion { branch: String },
113 ProposeReleasePullRequest { release_branch: String, base: String },
117}
118
119#[derive(Debug, Clone, Eq, PartialEq)]
120pub struct ReleaseReport {
121 pub outcome: ReleaseOutcome,
122 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 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 last_tag: Option<String>,
171 last_released: Option<Version>,
172 commits: Vec<ConventionalCommit>,
173 truncated: bool,
174}
175
176impl Analysis {
177 fn manifest_unreleased(&self) -> bool {
183 self.last_released.as_ref().is_none_or(|released| self.current > *released)
184 }
185
186 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 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 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 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 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 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 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 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 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(¤t_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 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 ¬es,
595 commit_sha,
596 )?;
597 report.release_url = Some(release.url);
598 report.outcome = ReleaseOutcome::Released;
599 Ok(())
600 }
601}
602
603fn 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
625fn 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 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#[cfg(test)]
681#[path = "release_tests.rs"]
682#[allow(clippy::significant_drop_tightening)]
683mod tests;