1use 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#[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)]
99pub struct ReleaseReport {
100 pub outcome: ReleaseOutcome,
101 pub current_version: String,
102 pub next_version: Option<String>,
103 pub bump: Option<BumpLevel>,
104 pub tag: Option<String>,
105 pub major_alias: Option<String>,
106 pub commit_sha: Option<String>,
107 pub release_url: Option<String>,
108 pub pull_request_number: Option<u64>,
109 pub pull_request_url: Option<String>,
110 pub release_branch: Option<String>,
111 pub notes: Option<String>,
112 pub commits_analyzed: usize,
113 pub commit_range_truncated: bool,
114 pub files_updated: Vec<PathBuf>,
115}
116
117impl ReleaseReport {
118 pub fn github_outputs(&self, notes_file: &Path) -> String {
121 let notes_path =
122 if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
123 format!(
124 "released={}\nversion={}\ntag={}\ncommit={}\nrelease-url={}\nrelease-pr-number={}\nrelease-pr-url={}\nrelease-branch={}\nnotes-file={notes_path}\n",
125 self.outcome == ReleaseOutcome::Released,
126 self.next_version.as_deref().unwrap_or_default(),
127 self.tag.as_deref().unwrap_or_default(),
128 self.commit_sha.as_deref().unwrap_or_default(),
129 self.release_url.as_deref().unwrap_or_default(),
130 self.pull_request_number.map(|number| number.to_string()).unwrap_or_default(),
131 self.pull_request_url.as_deref().unwrap_or_default(),
132 self.release_branch.as_deref().unwrap_or_default(),
133 )
134 }
135}
136
137#[derive(Debug)]
138struct Analysis {
139 branch: String,
140 head_sha: String,
141 current_version: String,
142 current: Version,
143 last_released: Option<Version>,
145 commits: Vec<ConventionalCommit>,
146 truncated: bool,
147}
148
149impl Analysis {
150 fn manifest_unreleased(&self) -> bool {
156 self.last_released.as_ref().is_none_or(|released| self.current > *released)
157 }
158}
159
160#[derive(Debug)]
161struct PreparedRelease {
162 branch: String,
163 head_sha: String,
164 next_version: Version,
165 tag: String,
166 file_updates: Vec<FileUpdate>,
167 via_pull_request: bool,
171}
172
173#[derive(Debug, Clone)]
174pub struct ReleasePublisher {
175 github: GitHubClient,
176}
177
178impl ReleasePublisher {
179 #[must_use]
180 pub const fn new(github: GitHubClient) -> Self {
181 Self { github }
182 }
183
184 pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
185 self.github.ensure_token()?;
186 if options.create_pr {
187 if options.phase != ReleasePhase::All {
188 anyhow::bail!(
189 "--create-pr cannot be combined with --phase: the release pull request already separates the version bump from the tag"
190 );
191 }
192 validate_release_branch(&options.release_branch)?;
193 }
194 let analysis = self.analyze(options)?;
195 if options.phase == ReleasePhase::Tag {
196 return self.release_manifest_version(options, analysis);
197 }
198 if options.create_pr && analysis.manifest_unreleased() {
203 return self.release_manifest_version(options, analysis);
204 }
205 self.release_bumped_version(options, analysis)
206 }
207
208 fn release_bumped_version(
211 &self,
212 options: &ReleaseOptions,
213 analysis: Analysis,
214 ) -> Result<ReleaseReport> {
215 let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
216 let mut report = initial_report(&analysis, bump);
217
218 let Some(bump_level) = bump else {
219 return Ok(report);
220 };
221
222 let next_version = conventional::bump_version(&analysis.current, bump_level);
223 let next = next_version.to_string();
224 let tag = format!("{}{next}", options.tag_prefix);
225 report.notes =
226 Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
227 report.next_version = Some(next);
228 report.tag = Some(tag.clone());
229
230 if self.tag_exists(options, &tag)? {
231 report.outcome = ReleaseOutcome::SkippedTagExists;
232 return Ok(report);
233 }
234
235 let plan = versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
236 let mut file_updates = plan.file_updates;
237 let extra = extra_file_updates(&options.repo_root, &options.extra_files, &file_updates)?;
238 file_updates.extend(extra);
239
240 self.prepare_and_publish(
241 options,
242 analysis,
243 next_version,
244 tag,
245 file_updates,
246 options.create_pr,
247 report,
248 )
249 }
250
251 fn release_manifest_version(
256 &self,
257 options: &ReleaseOptions,
258 analysis: Analysis,
259 ) -> Result<ReleaseReport> {
260 let mut report = initial_report(&analysis, None);
261 let version = analysis.current.clone();
262 let tag = format!("{}{version}", options.tag_prefix);
263 report.notes =
264 Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
265 report.next_version = Some(version.to_string());
266 report.tag = Some(tag.clone());
267
268 if self.tag_exists(options, &tag)? {
269 report.outcome = ReleaseOutcome::SkippedTagExists;
270 return Ok(report);
271 }
272
273 let file_updates = extra_file_updates(&options.repo_root, &options.extra_files, &[])?;
277
278 self.prepare_and_publish(options, analysis, version, tag, file_updates, false, report)
279 }
280
281 fn prepare_and_publish(
282 &self,
283 options: &ReleaseOptions,
284 analysis: Analysis,
285 next_version: Version,
286 tag: String,
287 file_updates: Vec<FileUpdate>,
288 via_pull_request: bool,
289 mut report: ReleaseReport,
290 ) -> Result<ReleaseReport> {
291 report.files_updated = file_updates.iter().map(|update| update.file.clone()).collect();
292
293 if options.dry_run {
294 report.outcome = ReleaseOutcome::DryRun;
295 return Ok(report);
296 }
297
298 let prepared = PreparedRelease {
299 branch: analysis.branch,
300 head_sha: analysis.head_sha,
301 next_version,
302 tag,
303 file_updates,
304 via_pull_request,
305 };
306 self.publish(options, &prepared, &mut report)?;
307 Ok(report)
308 }
309
310 fn tag_exists(&self, options: &ReleaseOptions, tag: &str) -> Result<bool> {
311 Ok(self
312 .github
313 .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
314 .is_some())
315 }
316
317 fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
318 let owner = &options.owner;
319 let repo = &options.repo;
320 let branch = match options.base_branch.as_deref().map(str::trim) {
321 Some(branch) if !branch.is_empty() => branch.to_owned(),
322 _ => self.github.default_branch(owner, repo)?,
323 };
324 let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
325
326 let current_version = versioning::current_version(&options.repo_root)?;
327 let current = Version::parse(¤t_version).with_context(|| {
328 format!("invalid current version '{current_version}' in Cargo.toml")
329 })?;
330
331 let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
332 let last_released = last_tag
333 .as_ref()
334 .and_then(|tag| tag.name.strip_prefix(options.tag_prefix.as_str()))
335 .and_then(|version| Version::parse(version).ok());
336 let range = match &last_tag {
337 Some(tag) => {
338 self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
339 }
340 None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
341 };
342
343 Ok(Analysis {
344 branch,
345 head_sha,
346 current_version,
347 current,
348 last_released,
349 commits: conventional::classify_commits(&range.commits),
350 truncated: range.truncated,
351 })
352 }
353
354 fn publish(
358 &self,
359 options: &ReleaseOptions,
360 prepared: &PreparedRelease,
361 report: &mut ReleaseReport,
362 ) -> Result<()> {
363 let staged = !prepared.file_updates.is_empty();
364 let commit_sha =
365 if staged { self.build_commit(options, prepared)? } else { prepared.head_sha.clone() };
366
367 let current_head =
368 self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
369 if current_head != prepared.head_sha {
370 report.outcome = ReleaseOutcome::SkippedRace;
371 return Ok(());
372 }
373 report.commit_sha = Some(commit_sha.clone());
374
375 if prepared.via_pull_request {
376 return self.publish_pull_request(options, prepared, &commit_sha, report);
377 }
378
379 if staged
380 && !self.github.update_ref_fast_forward(
381 &options.owner,
382 &options.repo,
383 &format!("heads/{}", prepared.branch),
384 &commit_sha,
385 )?
386 {
387 report.outcome = ReleaseOutcome::SkippedRace;
388 return Ok(());
389 }
390
391 if options.phase == ReleasePhase::Bump {
392 report.outcome = ReleaseOutcome::VersionCommitted;
393 return Ok(());
394 }
395
396 self.finalize(options, prepared, &commit_sha, report)
397 }
398
399 fn publish_pull_request(
400 &self,
401 options: &ReleaseOptions,
402 prepared: &PreparedRelease,
403 commit_sha: &str,
404 report: &mut ReleaseReport,
405 ) -> Result<()> {
406 let owner = &options.owner;
407 let repo = &options.repo;
408 let branch_ref = format!("heads/{}", options.release_branch);
409 self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
410
411 let title = format!("chore(release): {}", prepared.tag);
412 let notes = report.notes.clone().unwrap_or_default();
413 let body = format!(
414 "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: {} -->",
415 prepared.tag,
416 report.current_version,
417 prepared.next_version,
418 prepared.branch,
419 notes,
420 options.release_branch,
421 );
422 let pull_request = self.upsert_release_pull_request(
423 owner,
424 repo,
425 &options.release_branch,
426 &prepared.branch,
427 &title,
428 &body,
429 report,
430 )?;
431 report.pull_request_number = Some(pull_request.number);
432 report.pull_request_url = Some(pull_request.url);
433 report.release_branch = Some(options.release_branch.clone());
434 Ok(())
435 }
436
437 fn update_release_branch(
438 &self,
439 owner: &str,
440 repo: &str,
441 branch_ref: &str,
442 commit_sha: &str,
443 ) -> Result<()> {
444 if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
445 self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
446 } else {
447 self.github.create_ref(owner, repo, branch_ref, commit_sha)
448 }
449 }
450
451 fn upsert_release_pull_request(
452 &self,
453 owner: &str,
454 repo: &str,
455 head: &str,
456 base: &str,
457 title: &str,
458 body: &str,
459 report: &mut ReleaseReport,
460 ) -> Result<crate::github::PullRequestInfo> {
461 let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
462 if let Some(existing) = existing {
463 let updated =
464 self.github.update_pull_request(owner, repo, existing.number, title, body)?;
465 report.outcome = ReleaseOutcome::PullRequestUpdated;
466 Ok(updated)
467 } else {
468 let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
469 report.outcome = ReleaseOutcome::PullRequestCreated;
470 Ok(created)
471 }
472 }
473
474 fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
475 let owner = &options.owner;
476 let repo = &options.repo;
477 let repo_root = options.repo_root.canonicalize().with_context(|| {
478 format!("failed to resolve repository root '{}'", options.repo_root.display())
479 })?;
480
481 let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
482 let mut tree_entries = Vec::new();
483 for update in &prepared.file_updates {
484 let path = remote::relative_repository_path(&repo_root, &update.file)?;
485 let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
486 tree_entries.push(TreeEntry { path, sha: blob_sha });
487 }
488 let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
489
490 let message =
491 options.commit_message.replace("{version}", &prepared.next_version.to_string());
492 self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
493 }
494
495 fn finalize(
496 &self,
497 options: &ReleaseOptions,
498 prepared: &PreparedRelease,
499 commit_sha: &str,
500 report: &mut ReleaseReport,
501 ) -> Result<()> {
502 let owner = &options.owner;
503 let repo = &options.repo;
504 let tag_target = match options.tag_style {
505 TagStyle::Annotated => self.github.create_annotated_tag(
506 owner,
507 repo,
508 &prepared.tag,
509 &format!("Release {}", prepared.tag),
510 commit_sha,
511 )?,
512 TagStyle::Lightweight => commit_sha.to_owned(),
513 };
514 self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
515
516 if options.update_major_alias {
517 let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
518 let alias_ref = format!("tags/{alias}");
519 if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
520 self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
521 } else {
522 self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
523 }
524 report.major_alias = Some(alias);
525 }
526
527 let notes = report.notes.clone().unwrap_or_default();
528 let release = self.github.create_release(
529 owner,
530 repo,
531 &prepared.tag,
532 &format!("Release {}", prepared.tag),
533 ¬es,
534 commit_sha,
535 )?;
536 report.release_url = Some(release.url);
537 report.outcome = ReleaseOutcome::Released;
538 Ok(())
539 }
540}
541
542fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
543 ReleaseReport {
544 outcome: ReleaseOutcome::SkippedNoReleasableChanges,
545 current_version: analysis.current_version.clone(),
546 next_version: None,
547 bump,
548 tag: None,
549 major_alias: None,
550 commit_sha: None,
551 release_url: None,
552 pull_request_number: None,
553 pull_request_url: None,
554 release_branch: None,
555 notes: None,
556 commits_analyzed: analysis.commits.len(),
557 commit_range_truncated: analysis.truncated,
558 files_updated: Vec::new(),
559 }
560}
561
562fn extra_file_updates(
567 repo_root: &Path,
568 extra_files: &[PathBuf],
569 planned: &[FileUpdate],
570) -> Result<Vec<FileUpdate>> {
571 if extra_files.is_empty() {
572 return Ok(Vec::new());
573 }
574
575 let repo_root = repo_root
576 .canonicalize()
577 .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
578
579 let mut updates = Vec::new();
580 for extra_file in extra_files {
581 let path =
582 if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
583 let path = path.canonicalize().with_context(|| {
584 format!("failed to resolve release file '{}'", extra_file.display())
585 })?;
586 remote::relative_repository_path(&repo_root, &path)?;
589
590 if planned.iter().any(|update| update.file == path)
591 || updates.iter().any(|update: &FileUpdate| update.file == path)
592 {
593 continue;
594 }
595
596 let updated_content = fs::read_to_string(&path)
597 .with_context(|| format!("failed to read release file '{}'", path.display()))?;
598 updates.push(FileUpdate { file: path, updated_content });
599 }
600
601 Ok(updates)
602}
603
604fn validate_release_branch(branch: &str) -> Result<()> {
605 if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
606 && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
607 {
608 anyhow::bail!(
609 "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
610 );
611 }
612 Ok(())
613}
614
615#[cfg(test)]
618#[path = "release_tests.rs"]
619#[allow(clippy::significant_drop_tightening)]
620mod tests;