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