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