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;
24
25#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct ReleaseOptions {
27 pub repo_root: PathBuf,
28 pub owner: String,
29 pub repo: String,
30 pub base_branch: Option<String>,
31 pub bump: Option<BumpLevel>,
33 pub tag_prefix: String,
34 pub update_major_alias: bool,
35 pub commit_message: String,
37 pub dry_run: bool,
38}
39
40#[derive(Debug, Clone, Copy, Eq, PartialEq)]
41pub enum ReleaseOutcome {
42 Released,
43 DryRun,
44 SkippedNoReleasableChanges,
45 SkippedRace,
46 SkippedTagExists,
47}
48
49#[derive(Debug, Clone, Eq, PartialEq)]
50pub struct ReleaseReport {
51 pub outcome: ReleaseOutcome,
52 pub current_version: String,
53 pub next_version: Option<String>,
54 pub bump: Option<BumpLevel>,
55 pub tag: Option<String>,
56 pub major_alias: Option<String>,
57 pub commit_sha: Option<String>,
58 pub release_url: Option<String>,
59 pub notes: Option<String>,
60 pub commits_analyzed: usize,
61 pub commit_range_truncated: bool,
62 pub files_updated: Vec<PathBuf>,
63}
64
65impl ReleaseReport {
66 pub fn github_outputs(&self, notes_file: &Path) -> String {
69 let notes_path =
70 if self.notes.is_some() { notes_file.display().to_string() } else { String::new() };
71 format!(
72 "released={}\nversion={}\ntag={}\nrelease-url={}\nnotes-file={notes_path}\n",
73 self.outcome == ReleaseOutcome::Released,
74 self.next_version.as_deref().unwrap_or_default(),
75 self.tag.as_deref().unwrap_or_default(),
76 self.release_url.as_deref().unwrap_or_default(),
77 )
78 }
79}
80
81#[derive(Debug)]
82struct Analysis {
83 branch: String,
84 head_sha: String,
85 current_version: String,
86 current: Version,
87 commits: Vec<ConventionalCommit>,
88 truncated: bool,
89}
90
91#[derive(Debug)]
92struct PreparedRelease {
93 branch: String,
94 head_sha: String,
95 next_version: Version,
96 tag: String,
97 plan: versioning::VersionRewritePlan,
98}
99
100#[derive(Debug, Clone)]
101pub struct ReleasePublisher {
102 github: GitHubClient,
103}
104
105impl ReleasePublisher {
106 #[must_use]
107 pub const fn new(github: GitHubClient) -> Self {
108 Self { github }
109 }
110
111 pub fn release(&self, options: &ReleaseOptions) -> Result<ReleaseReport> {
112 self.github.ensure_token()?;
113 let analysis = self.analyze(options)?;
114 let bump = options.bump.or_else(|| conventional::required_bump(&analysis.commits));
115 let mut report = initial_report(&analysis, bump);
116
117 let Some(bump_level) = bump else {
118 return Ok(report);
119 };
120
121 let next_version = conventional::bump_version(&analysis.current, bump_level);
122 let next = next_version.to_string();
123 let tag = format!("{}{next}", options.tag_prefix);
124 report.notes =
125 Some(conventional::release_notes(&tag, &analysis.commits, analysis.truncated));
126 report.next_version = Some(next);
127 report.tag = Some(tag.clone());
128
129 if self
130 .github
131 .reference_sha(&options.owner, &options.repo, &format!("tags/{tag}"))?
132 .is_some()
133 {
134 report.outcome = ReleaseOutcome::SkippedTagExists;
135 return Ok(report);
136 }
137
138 let plan = versioning::plan_version_rewrite(&options.repo_root, &next_version.to_string())?;
139 report.files_updated = plan.file_updates.iter().map(|update| update.file.clone()).collect();
140
141 if options.dry_run {
142 report.outcome = ReleaseOutcome::DryRun;
143 return Ok(report);
144 }
145
146 let prepared = PreparedRelease {
147 branch: analysis.branch,
148 head_sha: analysis.head_sha,
149 next_version,
150 tag,
151 plan,
152 };
153 self.publish(options, &prepared, &mut report)?;
154 Ok(report)
155 }
156
157 fn analyze(&self, options: &ReleaseOptions) -> Result<Analysis> {
158 let owner = &options.owner;
159 let repo = &options.repo;
160 let branch = match options.base_branch.as_deref().map(str::trim) {
161 Some(branch) if !branch.is_empty() => branch.to_owned(),
162 _ => self.github.default_branch(owner, repo)?,
163 };
164 let head_sha = self.github.branch_head_sha(owner, repo, &branch)?;
165
166 let current_version = versioning::current_version(&options.repo_root)?;
167 let current = Version::parse(¤t_version).with_context(|| {
168 format!("invalid current version '{current_version}' in Cargo.toml")
169 })?;
170
171 let last_tag = self.github.latest_semver_tag(owner, repo, &options.tag_prefix)?;
172 let range = match &last_tag {
173 Some(tag) => {
174 self.github.compare_commits(owner, repo, &tag.name, &head_sha, MAX_COMPARE_PAGES)?
175 }
176 None => self.github.list_commits(owner, repo, &head_sha, MAX_FIRST_RELEASE_PAGES)?,
177 };
178
179 Ok(Analysis {
180 branch,
181 head_sha,
182 current_version,
183 current,
184 commits: conventional::classify_commits(&range.commits),
185 truncated: range.truncated,
186 })
187 }
188
189 fn publish(
193 &self,
194 options: &ReleaseOptions,
195 prepared: &PreparedRelease,
196 report: &mut ReleaseReport,
197 ) -> Result<()> {
198 let commit_sha = self.build_commit(options, prepared)?;
199
200 let current_head =
201 self.github.branch_head_sha(&options.owner, &options.repo, &prepared.branch)?;
202 if current_head != prepared.head_sha
203 || !self.github.update_ref_fast_forward(
204 &options.owner,
205 &options.repo,
206 &format!("heads/{}", prepared.branch),
207 &commit_sha,
208 )?
209 {
210 report.outcome = ReleaseOutcome::SkippedRace;
211 return Ok(());
212 }
213 report.commit_sha = Some(commit_sha.clone());
214
215 self.finalize(options, prepared, &commit_sha, report)
216 }
217
218 fn build_commit(&self, options: &ReleaseOptions, prepared: &PreparedRelease) -> Result<String> {
219 let owner = &options.owner;
220 let repo = &options.repo;
221 let repo_root = options.repo_root.canonicalize().with_context(|| {
222 format!("failed to resolve repository root '{}'", options.repo_root.display())
223 })?;
224
225 let base_tree_sha = self.github.commit_tree_sha(owner, repo, &prepared.head_sha)?;
226 let mut tree_entries = Vec::new();
227 for update in &prepared.plan.file_updates {
228 let path = remote::relative_repository_path(&repo_root, &update.file)?;
229 let blob_sha = self.github.create_blob(owner, repo, &update.updated_content)?;
230 tree_entries.push(TreeEntry { path, sha: blob_sha });
231 }
232 let tree_sha = self.github.create_tree(owner, repo, &base_tree_sha, &tree_entries)?;
233
234 let message =
235 options.commit_message.replace("{version}", &prepared.next_version.to_string());
236 self.github.create_commit(owner, repo, &message, &tree_sha, &prepared.head_sha)
237 }
238
239 fn finalize(
240 &self,
241 options: &ReleaseOptions,
242 prepared: &PreparedRelease,
243 commit_sha: &str,
244 report: &mut ReleaseReport,
245 ) -> Result<()> {
246 let owner = &options.owner;
247 let repo = &options.repo;
248 self.github.create_ref(owner, repo, &format!("tags/{}", prepared.tag), commit_sha)?;
249
250 if options.update_major_alias {
251 let alias = format!("{}{}", options.tag_prefix, prepared.next_version.major);
252 let alias_ref = format!("tags/{alias}");
253 if self.github.reference_sha(owner, repo, &alias_ref)?.is_some() {
254 self.github.update_ref(owner, repo, &alias_ref, commit_sha, true)?;
255 } else {
256 self.github.create_ref(owner, repo, &alias_ref, commit_sha)?;
257 }
258 report.major_alias = Some(alias);
259 }
260
261 let notes = report.notes.clone().unwrap_or_default();
262 let release = self.github.create_release(
263 owner,
264 repo,
265 &prepared.tag,
266 &format!("Release {}", prepared.tag),
267 ¬es,
268 commit_sha,
269 )?;
270 report.release_url = Some(release.url);
271 report.outcome = ReleaseOutcome::Released;
272 Ok(())
273 }
274}
275
276fn initial_report(analysis: &Analysis, bump: Option<BumpLevel>) -> ReleaseReport {
277 ReleaseReport {
278 outcome: ReleaseOutcome::SkippedNoReleasableChanges,
279 current_version: analysis.current_version.clone(),
280 next_version: None,
281 bump,
282 tag: None,
283 major_alias: None,
284 commit_sha: None,
285 release_url: None,
286 notes: None,
287 commits_analyzed: analysis.commits.len(),
288 commit_range_truncated: analysis.truncated,
289 files_updated: Vec::new(),
290 }
291}
292
293#[cfg(test)]
296#[path = "release_tests.rs"]
297#[allow(clippy::significant_drop_tightening)]
298mod tests;