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, Eq, PartialEq)]
41pub struct ReleaseOptions {
42 pub repo_root: PathBuf,
43 pub owner: String,
44 pub repo: String,
45 pub base_branch: Option<String>,
46 pub bump: Option<BumpLevel>,
48 pub tag_prefix: String,
49 pub tag_style: TagStyle,
50 pub update_major_alias: bool,
51 pub commit_message: String,
53 pub create_pr: bool,
56 pub release_branch: String,
57 pub dry_run: bool,
58 pub extra_files: Vec<PathBuf>,
62}
63
64#[derive(Debug, Clone, Copy, Eq, PartialEq)]
65pub enum ReleaseOutcome {
66 Released,
67 PullRequestCreated,
68 PullRequestUpdated,
69 DryRun,
70 SkippedNoReleasableChanges,
71 SkippedRace,
72 SkippedTagExists,
73}
74
75#[derive(Debug, Clone, Eq, PartialEq)]
76pub struct ReleaseReport {
77 pub outcome: ReleaseOutcome,
78 pub current_version: String,
79 pub next_version: Option<String>,
80 pub bump: Option<BumpLevel>,
81 pub tag: Option<String>,
82 pub major_alias: Option<String>,
83 pub commit_sha: Option<String>,
84 pub release_url: Option<String>,
85 pub pull_request_number: Option<u64>,
86 pub pull_request_url: Option<String>,
87 pub release_branch: Option<String>,
88 pub notes: Option<String>,
89 pub commits_analyzed: usize,
90 pub commit_range_truncated: bool,
91 pub files_updated: Vec<PathBuf>,
92}
93
94impl ReleaseReport {
95 pub fn github_outputs(&self, notes_file: &Path) -> String {
98 let notes_path =
99 if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
100 format!(
101 "released={}\nversion={}\ntag={}\nrelease-url={}\nrelease-pr-number={}\nrelease-pr-url={}\nrelease-branch={}\nnotes-file={notes_path}\n",
102 self.outcome == ReleaseOutcome::Released,
103 self.next_version.as_deref().unwrap_or_default(),
104 self.tag.as_deref().unwrap_or_default(),
105 self.release_url.as_deref().unwrap_or_default(),
106 self.pull_request_number.map(|number| number.to_string()).unwrap_or_default(),
107 self.pull_request_url.as_deref().unwrap_or_default(),
108 self.release_branch.as_deref().unwrap_or_default(),
109 )
110 }
111}
112
113#[derive(Debug)]
114struct Analysis {
115 branch: String,
116 head_sha: String,
117 current_version: String,
118 current: Version,
119 commits: Vec<ConventionalCommit>,
120 truncated: bool,
121}
122
123#[derive(Debug)]
124struct PreparedRelease {
125 branch: String,
126 head_sha: String,
127 next_version: Version,
128 tag: String,
129 plan: versioning::VersionRewritePlan,
130}
131
132#[derive(Debug, Clone)]
133pub struct ReleasePublisher {
134 github: GitHubClient,
135}
136
137impl ReleasePublisher {
138 #[must_use]
139 pub const fn new(github: GitHubClient) -> Self {
140 Self { github }
141 }
142
143 pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
144 self.github.ensure_token()?;
145 if options.create_pr {
146 validate_release_branch(&options.release_branch)?;
147 }
148 let analysis = self.analyze(options)?;
149 let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
150 let mut report = initial_report(&analysis, bump);
151
152 let Some(bump_level) = bump else {
153 return Ok(report);
154 };
155
156 let next_version = conventional::bump_version(&analysis.current, bump_level);
157 let next = next_version.to_string();
158 let tag = format!("{}{next}", options.tag_prefix);
159 report.notes =
160 Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
161 report.next_version = Some(next);
162 report.tag = Some(tag.clone());
163
164 if self
165 .github
166 .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
167 .is_some()
168 {
169 report.outcome = ReleaseOutcome::SkippedTagExists;
170 return Ok(report);
171 }
172
173 let mut plan =
174 versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
175 plan.file_updates.extend(extra_file_updates(
176 &options.repo_root,
177 &options.extra_files,
178 &plan.file_updates,
179 )?);
180 report.files_updated = plan.file_updates.iter().map(|update| update.file.clone()).collect();
181
182 if options.dry_run {
183 report.outcome = ReleaseOutcome::DryRun;
184 return Ok(report);
185 }
186
187 let prepared = PreparedRelease {
188 branch: analysis.branch,
189 head_sha: analysis.head_sha,
190 next_version,
191 tag,
192 plan,
193 };
194 self.publish(options, &prepared, &mut report)?;
195 Ok(report)
196 }
197
198 fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
199 let owner = &options.owner;
200 let repo = &options.repo;
201 let branch = match options.base_branch.as_deref().map(str::trim) {
202 Some(branch) if !branch.is_empty() => branch.to_owned(),
203 _ => self.github.default_branch(owner, repo)?,
204 };
205 let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
206
207 let current_version = versioning::current_version(&options.repo_root)?;
208 let current = Version::parse(¤t_version).with_context(|| {
209 format!("invalid current version '{current_version}' in Cargo.toml")
210 })?;
211
212 let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
213 let range = match &last_tag {
214 Some(tag) => {
215 self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
216 }
217 None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
218 };
219
220 Ok(Analysis {
221 branch,
222 head_sha,
223 current_version,
224 current,
225 commits: conventional::classify_commits(&range.commits),
226 truncated: range.truncated,
227 })
228 }
229
230 fn publish(
234 &self,
235 options: &ReleaseOptions,
236 prepared: &PreparedRelease,
237 report: &mut ReleaseReport,
238 ) -> Result<()> {
239 let commit_sha = self.build_commit(options, prepared)?;
240
241 let current_head =
242 self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
243 if current_head != prepared.head_sha {
244 report.outcome = ReleaseOutcome::SkippedRace;
245 return Ok(());
246 }
247 report.commit_sha = Some(commit_sha.clone());
248
249 if options.create_pr {
250 return self.publish_pull_request(options, prepared, &commit_sha, report);
251 }
252
253 if !self.github.update_ref_fast_forward(
254 &options.owner,
255 &options.repo,
256 &format!("heads/{}", prepared.branch),
257 &commit_sha,
258 )? {
259 report.outcome = ReleaseOutcome::SkippedRace;
260 return Ok(());
261 }
262
263 self.finalize(options, prepared, &commit_sha, report)
264 }
265
266 fn publish_pull_request(
267 &self,
268 options: &ReleaseOptions,
269 prepared: &PreparedRelease,
270 commit_sha: &str,
271 report: &mut ReleaseReport,
272 ) -> Result<()> {
273 let owner = &options.owner;
274 let repo = &options.repo;
275 let branch_ref = format!("heads/{}", options.release_branch);
276 self.update_release_branch(owner, repo, &branch_ref, commit_sha)?;
277
278 let title = format!("chore(release): {}", prepared.tag);
279 let notes = report.notes.clone().unwrap_or_default();
280 let body = format!(
281 "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: {} -->",
282 prepared.tag,
283 report.current_version,
284 prepared.next_version,
285 prepared.branch,
286 notes,
287 options.release_branch,
288 );
289 let pull_request = self.upsert_release_pull_request(
290 owner,
291 repo,
292 &options.release_branch,
293 &prepared.branch,
294 &title,
295 &body,
296 report,
297 )?;
298 report.pull_request_number = Some(pull_request.number);
299 report.pull_request_url = Some(pull_request.url);
300 report.release_branch = Some(options.release_branch.clone());
301 Ok(())
302 }
303
304 fn update_release_branch(
305 &self,
306 owner: &str,
307 repo: &str,
308 branch_ref: &str,
309 commit_sha: &str,
310 ) -> Result<()> {
311 if self.github.reference_sha(owner, repo, branch_ref)?.is_some() {
312 self.github.update_ref(owner, repo, branch_ref, commit_sha, true)
313 } else {
314 self.github.create_ref(owner, repo, branch_ref, commit_sha)
315 }
316 }
317
318 fn upsert_release_pull_request(
319 &self,
320 owner: &str,
321 repo: &str,
322 head: &str,
323 base: &str,
324 title: &str,
325 body: &str,
326 report: &mut ReleaseReport,
327 ) -> Result<crate::github::PullRequestInfo> {
328 let existing = self.github.find_open_pull_request(owner, repo, head, base)?;
329 if let Some(existing) = existing {
330 let updated =
331 self.github.update_pull_request(owner, repo, existing.number, title, body)?;
332 report.outcome = ReleaseOutcome::PullRequestUpdated;
333 Ok(updated)
334 } else {
335 let created = self.github.create_pull_request(owner, repo, title, body, head, base)?;
336 report.outcome = ReleaseOutcome::PullRequestCreated;
337 Ok(created)
338 }
339 }
340
341 fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
342 let owner = &options.owner;
343 let repo = &options.repo;
344 let repo_root = options.repo_root.canonicalize().with_context(|| {
345 format!("failed to resolve repository root '{}'", options.repo_root.display())
346 })?;
347
348 let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
349 let mut tree_entries = Vec::new();
350 for update in &prepared.plan.file_updates {
351 let path = remote::relative_repository_path(&repo_root, &update.file)?;
352 let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
353 tree_entries.push(TreeEntry { path, sha: blob_sha });
354 }
355 let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
356
357 let message =
358 options.commit_message.replace("{version}", &prepared.next_version.to_string());
359 self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
360 }
361
362 fn finalize(
363 &self,
364 options: &ReleaseOptions,
365 prepared: &PreparedRelease,
366 commit_sha: &str,
367 report: &mut ReleaseReport,
368 ) -> Result<()> {
369 let owner = &options.owner;
370 let repo = &options.repo;
371 let tag_target = match options.tag_style {
372 TagStyle::Annotated => self.github.create_annotated_tag(
373 owner,
374 repo,
375 &prepared.tag,
376 &format!("Release {}", prepared.tag),
377 commit_sha,
378 )?,
379 TagStyle::Lightweight => commit_sha.to_owned(),
380 };
381 self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), &tag_target)?;
382
383 if options.update_major_alias {
384 let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
385 let alias_ref = format!("tags/{alias}");
386 if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
387 self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
388 } else {
389 self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
390 }
391 report.major_alias = Some(alias);
392 }
393
394 let notes = report.notes.clone().unwrap_or_default();
395 let release = self.github.create_release(
396 owner,
397 repo,
398 &prepared.tag,
399 &format!("Release {}", prepared.tag),
400 ¬es,
401 commit_sha,
402 )?;
403 report.release_url = Some(release.url);
404 report.outcome = ReleaseOutcome::Released;
405 Ok(())
406 }
407}
408
409fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
410 ReleaseReport {
411 outcome: ReleaseOutcome::SkippedNoReleasableChanges,
412 current_version: analysis.current_version.clone(),
413 next_version: None,
414 bump,
415 tag: None,
416 major_alias: None,
417 commit_sha: None,
418 release_url: None,
419 pull_request_number: None,
420 pull_request_url: None,
421 release_branch: None,
422 notes: None,
423 commits_analyzed: analysis.commits.len(),
424 commit_range_truncated: analysis.truncated,
425 files_updated: Vec::new(),
426 }
427}
428
429fn extra_file_updates(
434 repo_root: &Path,
435 extra_files: &[PathBuf],
436 planned: &[FileUpdate],
437) -> Result<Vec<FileUpdate>> {
438 if extra_files.is_empty() {
439 return Ok(Vec::new());
440 }
441
442 let repo_root = repo_root
443 .canonicalize()
444 .with_context(|| format!("failed to resolve repository root '{}'", repo_root.display()))?;
445
446 let mut updates = Vec::new();
447 for extra_file in extra_files {
448 let path =
449 if extra_file.is_absolute() { extra_file.clone() } else { repo_root.join(extra_file) };
450 let path = path.canonicalize().with_context(|| {
451 format!("failed to resolve release file '{}'", extra_file.display())
452 })?;
453 remote::relative_repository_path(&repo_root, &path)?;
456
457 if planned.iter().any(|update| update.file == path)
458 || updates.iter().any(|update: &FileUpdate| update.file == path)
459 {
460 continue;
461 }
462
463 let updated_content = fs::read_to_string(&path)
464 .with_context(|| format!("failed to read release file '{}'", path.display()))?;
465 updates.push(FileUpdate { file: path, updated_content });
466 }
467
468 Ok(updates)
469}
470
471fn validate_release_branch(branch: &str) -> Result<()> {
472 if branch != AUTOMATED_RELEASE_BRANCH_PREFIX
473 && !branch.starts_with(&format!("{AUTOMATED_RELEASE_BRANCH_PREFIX}/"))
474 {
475 anyhow::bail!(
476 "automated release branch '{branch}' must use the reserved '{AUTOMATED_RELEASE_BRANCH_PREFIX}/' prefix"
477 );
478 }
479 Ok(())
480}
481
482#[cfg(test)]
485#[path = "release_tests.rs"]
486#[allow(clippy::significant_drop_tightening)]
487mod tests;