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