1use semver::Version;
2use serde::Serialize;
3
4use crate::changelog::{ChangelogEntry, ChangelogFormatter};
5use crate::commit::{CommitParser, ConventionalCommit, DefaultCommitClassifier};
6use crate::config::{Config, PackageConfig};
7use crate::error::ReleaseError;
8use crate::git::GitRepository;
9use crate::stages::{StageContext, default_pipeline};
10use crate::version::{BumpLevel, apply_bump, apply_prerelease_bump, determine_bump};
11
12#[derive(Debug, Serialize)]
14pub struct ReleasePlan {
15 pub current_version: Option<Version>,
16 pub next_version: Version,
17 pub bump: BumpLevel,
18 pub commits: Vec<ConventionalCommit>,
19 pub tag_name: String,
20 pub floating_tag_name: Option<String>,
21 pub prerelease: bool,
22}
23
24pub trait ReleaseStrategy: Send + Sync {
26 fn plan(&self) -> Result<ReleasePlan, ReleaseError>;
28
29 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError>;
31}
32
33pub trait VcsProvider: Send + Sync {
35 fn create_release(
37 &self,
38 tag: &str,
39 name: &str,
40 body: &str,
41 prerelease: bool,
42 draft: bool,
43 ) -> Result<String, ReleaseError>;
44
45 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError>;
47
48 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError>;
50
51 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError>;
53
54 fn repo_url(&self) -> Option<String> {
56 None
57 }
58
59 fn update_release(
62 &self,
63 _tag: &str,
64 _name: &str,
65 _body: &str,
66 _prerelease: bool,
67 _draft: bool,
68 ) -> Result<String, ReleaseError> {
69 Err(ReleaseError::Vcs(
70 "update_release not implemented for this provider".into(),
71 ))
72 }
73
74 fn upload_assets(&self, _tag: &str, _files: &[&str]) -> Result<(), ReleaseError> {
76 Ok(())
77 }
78
79 fn list_assets(&self, _tag: &str) -> Result<Vec<String>, ReleaseError> {
83 Ok(Vec::new())
84 }
85
86 fn fetch_asset(&self, _tag: &str, _name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
90 Ok(None)
91 }
92
93 fn verify_release(&self, _tag: &str) -> Result<(), ReleaseError> {
95 Ok(())
96 }
97}
98
99pub struct NoopVcsProvider;
102
103impl VcsProvider for NoopVcsProvider {
104 fn create_release(
105 &self,
106 _tag: &str,
107 _name: &str,
108 _body: &str,
109 _prerelease: bool,
110 _draft: bool,
111 ) -> Result<String, ReleaseError> {
112 Ok(String::new())
113 }
114
115 fn compare_url(&self, _base: &str, _head: &str) -> Result<String, ReleaseError> {
116 Ok(String::new())
117 }
118
119 fn release_exists(&self, _tag: &str) -> Result<bool, ReleaseError> {
120 Ok(false)
121 }
122
123 fn delete_release(&self, _tag: &str) -> Result<(), ReleaseError> {
124 Ok(())
125 }
126}
127
128pub struct TrunkReleaseStrategy<G, V, C, F> {
130 pub git: G,
131 pub vcs: V,
132 pub parser: C,
133 pub formatter: F,
134 pub config: Config,
135 pub force: bool,
137 pub prerelease_id: Option<String>,
139 pub draft: bool,
141}
142
143impl<G, V, C, F> TrunkReleaseStrategy<G, V, C, F>
144where
145 G: GitRepository,
146 V: VcsProvider,
147 C: CommitParser,
148 F: ChangelogFormatter,
149{
150 fn format_changelog(&self, plan: &ReleasePlan) -> Result<String, ReleaseError> {
151 let today = today_string();
152 let compare_url = match &plan.current_version {
153 Some(v) => {
154 let base = format!("{}{v}", self.config.git.tag_prefix);
155 self.vcs
156 .compare_url(&base, &plan.tag_name)
157 .ok()
158 .filter(|s| !s.is_empty())
159 }
160 None => None,
161 };
162 let entry = ChangelogEntry {
163 version: plan.next_version.to_string(),
164 date: today,
165 commits: plan.commits.clone(),
166 compare_url,
167 repo_url: self.vcs.repo_url(),
168 };
169 self.formatter.format(&[entry])
170 }
171
172 fn release_name(&self, plan: &ReleasePlan) -> String {
174 if let Some(ref template_str) = self.config.vcs.github.release_name_template {
175 let mut env = minijinja::Environment::new();
176 if env.add_template("release_name", template_str).is_ok()
177 && let Ok(tmpl) = env.get_template("release_name")
178 && let Ok(rendered) = tmpl.render(minijinja::context! {
179 version => plan.next_version.to_string(),
180 tag_name => &plan.tag_name,
181 tag_prefix => &self.config.git.tag_prefix,
182 })
183 {
184 return rendered;
185 }
186 eprintln!("warning: invalid release_name_template, falling back to tag name");
187 }
188 plan.tag_name.clone()
189 }
190
191 fn active_package(&self) -> Option<&PackageConfig> {
194 self.config
195 .packages
196 .iter()
197 .find(|p| p.path == ".")
198 .or_else(|| self.config.packages.first())
199 }
200}
201
202impl<G, V, C, F> ReleaseStrategy for TrunkReleaseStrategy<G, V, C, F>
203where
204 G: GitRepository,
205 V: VcsProvider,
206 C: CommitParser,
207 F: ChangelogFormatter,
208{
209 fn plan(&self) -> Result<ReleasePlan, ReleaseError> {
210 let is_prerelease = self.prerelease_id.is_some();
211
212 let all_tags = self.git.all_tags(&self.config.git.tag_prefix)?;
215 let latest_stable = all_tags.iter().rev().find(|t| t.version.pre.is_empty());
216 let latest_any = all_tags.last();
217
218 if !self.force
224 && let Some(latest) = all_tags.last()
225 {
226 match crate::manifest::check_release_status(&self.vcs, &latest.name)? {
227 crate::manifest::ReleaseStatus::Incomplete {
228 missing_artifacts, ..
229 } => {
230 return Err(ReleaseError::Vcs(format!(
231 "previous release {tag} is incomplete: {} declared asset(s) missing ({}). \
232 Heal it first — `git checkout {tag} && sr release --force` — \
233 then re-run sr release.",
234 missing_artifacts.len(),
235 missing_artifacts.join(", "),
236 tag = latest.name,
237 )));
238 }
239 crate::manifest::ReleaseStatus::Complete(_)
240 | crate::manifest::ReleaseStatus::Unknown => {}
241 }
242 }
243
244 let tag_info = if is_prerelease {
246 latest_any
247 } else {
248 latest_stable.or(latest_any)
249 };
250
251 let (current_version, from_sha) = match tag_info {
252 Some(info) => (Some(info.version.clone()), Some(info.sha.as_str())),
253 None => (None, None),
254 };
255
256 let default_pkg = PackageConfig::default();
257 let pkg = self.active_package().unwrap_or(&default_pkg);
258 let path_filter = if pkg.path != "." {
259 Some(pkg.path.as_str())
260 } else {
261 None
262 };
263
264 let raw_commits = if let Some(path) = path_filter {
265 self.git.commits_since_in_path(from_sha, path)?
266 } else {
267 self.git.commits_since(from_sha)?
268 };
269
270 if raw_commits.is_empty() {
271 if self.force
273 && let Some(info) = tag_info
274 {
275 let head = self.git.head_sha()?;
276 if head == info.sha {
277 let floating_tag_name = if self.config.git.floating_tag {
278 Some(format!(
279 "{}{}",
280 self.config.git.tag_prefix, info.version.major
281 ))
282 } else {
283 None
284 };
285 return Ok(ReleasePlan {
286 current_version: Some(info.version.clone()),
287 next_version: info.version.clone(),
288 bump: BumpLevel::Patch,
289 commits: vec![],
290 tag_name: info.name.clone(),
291 floating_tag_name,
292 prerelease: is_prerelease,
293 });
294 }
295 }
296 let (tag, sha) = match tag_info {
297 Some(info) => (info.name.clone(), info.sha.clone()),
298 None => ("(none)".into(), "(none)".into()),
299 };
300 return Err(ReleaseError::NoCommits { tag, sha });
301 }
302
303 let skip_patterns = &self.config.git.skip_patterns;
304 let conventional_commits: Vec<ConventionalCommit> = raw_commits
305 .iter()
306 .filter(|c| !c.message.starts_with("chore(release):"))
307 .filter(|c| !skip_patterns.iter().any(|p| c.message.contains(p.as_str())))
308 .filter_map(|c| self.parser.parse(c).ok())
309 .collect();
310
311 let classifier = DefaultCommitClassifier::new(self.config.commit.types.into_commit_types());
312 let tag_for_err = tag_info
313 .map(|i| i.name.clone())
314 .unwrap_or_else(|| "(none)".into());
315 let commit_count = conventional_commits.len();
316 let bump = match determine_bump(&conventional_commits, &classifier) {
317 Some(b) => b,
318 None if self.force => BumpLevel::Patch,
319 None => {
320 return Err(ReleaseError::NoBump {
321 tag: tag_for_err,
322 commit_count,
323 });
324 }
325 };
326
327 let base_version = if is_prerelease {
329 latest_stable
330 .map(|t| t.version.clone())
331 .or(current_version.clone())
332 .unwrap_or(Version::new(0, 0, 0))
333 } else {
334 current_version.clone().unwrap_or(Version::new(0, 0, 0))
335 };
336
337 let bump =
340 if base_version.major == 0 && bump == BumpLevel::Major && self.config.git.v0_protection
341 {
342 eprintln!(
343 "v0 protection: breaking change detected at v{base_version}, \
344 downshifting major → minor (set git.v0_protection: false to bump to v1)"
345 );
346 BumpLevel::Minor
347 } else {
348 bump
349 };
350
351 let next_version = if let Some(ref prerelease_id) = self.prerelease_id {
352 let existing_versions: Vec<Version> =
353 all_tags.iter().map(|t| t.version.clone()).collect();
354 apply_prerelease_bump(&base_version, bump, prerelease_id, &existing_versions)
355 } else {
356 apply_bump(&base_version, bump)
357 };
358
359 let tag_name = format!("{}{next_version}", self.config.git.tag_prefix);
360
361 let floating_tag_name = if self.config.git.floating_tag && !is_prerelease {
363 Some(format!(
364 "{}{}",
365 self.config.git.tag_prefix, next_version.major
366 ))
367 } else {
368 None
369 };
370
371 Ok(ReleasePlan {
372 current_version,
373 next_version,
374 bump,
375 commits: conventional_commits,
376 tag_name,
377 floating_tag_name,
378 prerelease: is_prerelease,
379 })
380 }
381
382 fn execute(&self, plan: &ReleasePlan, dry_run: bool) -> Result<(), ReleaseError> {
383 let version_str = plan.next_version.to_string();
384 let changelog_body = self.format_changelog(plan)?;
385 let release_name = self.release_name(plan);
386
387 let env = release_env(&version_str, &plan.tag_name);
388 let env_refs: Vec<(&str, &str)> =
389 env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
390
391 let default_pkg = PackageConfig::default();
392 let active_package = self.active_package().unwrap_or(&default_pkg);
393
394 let mut ctx = StageContext {
395 plan,
396 config: &self.config,
397 git: &self.git,
398 vcs: &self.vcs,
399 active_package,
400 changelog_body: &changelog_body,
401 release_name: &release_name,
402 version_str: &version_str,
403 hooks_env: &env_refs,
404 dry_run,
405 sign_tags: self.config.git.sign_tags,
406 draft: self.draft,
407 bumped_files: Vec::new(),
408 };
409
410 for stage in default_pipeline() {
411 if !stage.is_complete(&ctx)? {
412 stage.run(&mut ctx)?;
413 }
414 }
415
416 if dry_run {
417 eprintln!("[dry-run] Changelog:\n{changelog_body}");
418 } else {
419 eprintln!("Released {}", plan.tag_name);
420 }
421 Ok(())
422 }
423}
424
425fn release_env(version: &str, tag: &str) -> Vec<(String, String)> {
427 vec![
428 ("SR_VERSION".into(), version.into()),
429 ("SR_TAG".into(), tag.into()),
430 ]
431}
432
433pub(crate) fn resolve_globs(patterns: &[String]) -> Result<Vec<String>, String> {
435 let mut files = std::collections::BTreeSet::new();
436 for pattern in patterns {
437 let paths =
438 glob::glob(pattern).map_err(|e| format!("invalid glob pattern '{pattern}': {e}"))?;
439 for entry in paths {
440 match entry {
441 Ok(path) if path.is_file() => {
442 files.insert(path.to_string_lossy().into_owned());
443 }
444 Ok(_) => {}
445 Err(e) => {
446 return Err(format!("glob error for pattern '{pattern}': {e}"));
447 }
448 }
449 }
450 }
451 Ok(files.into_iter().collect())
452}
453
454pub fn today_string() -> String {
455 let secs = std::time::SystemTime::now()
458 .duration_since(std::time::UNIX_EPOCH)
459 .unwrap_or_default()
460 .as_secs() as i64;
461
462 let z = secs / 86400 + 719468;
463 let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
464 let doe = (z - era * 146097) as u32;
465 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
466 let y = yoe as i64 + era * 400;
467 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
468 let mp = (5 * doy + 2) / 153;
469 let d = doy - (153 * mp + 2) / 5 + 1;
470 let m = if mp < 10 { mp + 3 } else { mp - 9 };
471 let y = if m <= 2 { y + 1 } else { y };
472
473 format!("{y:04}-{m:02}-{d:02}")
474}
475
476#[cfg(test)]
477mod tests {
478 use std::sync::Mutex;
479
480 use super::*;
481 use crate::changelog::DefaultChangelogFormatter;
482 use crate::commit::{Commit, TypedCommitParser};
483 use crate::config::{
484 ChangelogConfig, Config, GitConfig, HooksConfig, PackageConfig, default_changelog_groups,
485 };
486 use crate::git::{GitRepository, TagInfo};
487
488 struct FakeGit {
491 tags: Vec<TagInfo>,
492 commits: Vec<Commit>,
493 path_commits: Option<Vec<Commit>>,
495 head: String,
496 created_tags: Mutex<Vec<String>>,
497 pushed_tags: Mutex<Vec<String>>,
498 committed: Mutex<Vec<(Vec<String>, String)>>,
499 push_count: Mutex<u32>,
500 force_created_tags: Mutex<Vec<String>>,
501 force_pushed_tags: Mutex<Vec<String>>,
502 }
503
504 impl FakeGit {
505 fn new(tags: Vec<TagInfo>, commits: Vec<Commit>) -> Self {
506 let head = tags
507 .last()
508 .map(|t| t.sha.clone())
509 .unwrap_or_else(|| "0".repeat(40));
510 Self {
511 tags,
512 commits,
513 path_commits: None,
514 head,
515 created_tags: Mutex::new(Vec::new()),
516 pushed_tags: Mutex::new(Vec::new()),
517 committed: Mutex::new(Vec::new()),
518 push_count: Mutex::new(0),
519 force_created_tags: Mutex::new(Vec::new()),
520 force_pushed_tags: Mutex::new(Vec::new()),
521 }
522 }
523 }
524
525 impl GitRepository for FakeGit {
526 fn latest_tag(&self, _prefix: &str) -> Result<Option<TagInfo>, ReleaseError> {
527 Ok(self.tags.last().cloned())
528 }
529
530 fn commits_since(&self, _from: Option<&str>) -> Result<Vec<Commit>, ReleaseError> {
531 Ok(self.commits.clone())
532 }
533
534 fn create_tag(&self, name: &str, _message: &str, _sign: bool) -> Result<(), ReleaseError> {
535 self.created_tags.lock().unwrap().push(name.to_string());
536 Ok(())
537 }
538
539 fn push_tag(&self, name: &str) -> Result<(), ReleaseError> {
540 self.pushed_tags.lock().unwrap().push(name.to_string());
541 Ok(())
542 }
543
544 fn stage_and_commit(&self, paths: &[&str], message: &str) -> Result<bool, ReleaseError> {
545 self.committed.lock().unwrap().push((
546 paths.iter().map(|s| s.to_string()).collect(),
547 message.to_string(),
548 ));
549 Ok(true)
550 }
551
552 fn push(&self) -> Result<(), ReleaseError> {
553 *self.push_count.lock().unwrap() += 1;
554 Ok(())
555 }
556
557 fn tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
558 Ok(self
559 .created_tags
560 .lock()
561 .unwrap()
562 .contains(&name.to_string()))
563 }
564
565 fn remote_tag_exists(&self, name: &str) -> Result<bool, ReleaseError> {
566 Ok(self.pushed_tags.lock().unwrap().contains(&name.to_string()))
567 }
568
569 fn all_tags(&self, _prefix: &str) -> Result<Vec<TagInfo>, ReleaseError> {
570 Ok(self.tags.clone())
571 }
572
573 fn commits_between(
574 &self,
575 _from: Option<&str>,
576 _to: &str,
577 ) -> Result<Vec<Commit>, ReleaseError> {
578 Ok(self.commits.clone())
579 }
580
581 fn tag_date(&self, _tag_name: &str) -> Result<String, ReleaseError> {
582 Ok("2026-01-01".into())
583 }
584
585 fn force_create_tag(&self, name: &str) -> Result<(), ReleaseError> {
586 self.force_created_tags
587 .lock()
588 .unwrap()
589 .push(name.to_string());
590 Ok(())
591 }
592
593 fn force_push_tag(&self, name: &str) -> Result<(), ReleaseError> {
594 self.force_pushed_tags
595 .lock()
596 .unwrap()
597 .push(name.to_string());
598 Ok(())
599 }
600
601 fn head_sha(&self) -> Result<String, ReleaseError> {
602 Ok(self.head.clone())
603 }
604
605 fn commits_since_in_path(
606 &self,
607 _from: Option<&str>,
608 _path: &str,
609 ) -> Result<Vec<Commit>, ReleaseError> {
610 Ok(self
611 .path_commits
612 .clone()
613 .unwrap_or_else(|| self.commits.clone()))
614 }
615 }
616
617 struct FakeVcs {
618 releases: Mutex<Vec<(String, String)>>,
619 deleted_releases: Mutex<Vec<String>>,
620 uploaded_assets: Mutex<Vec<(String, Vec<String>)>>,
621 stored_assets: Mutex<Vec<(String, String, Vec<u8>)>>,
625 }
626
627 impl FakeVcs {
628 fn new() -> Self {
629 Self {
630 releases: Mutex::new(Vec::new()),
631 deleted_releases: Mutex::new(Vec::new()),
632 uploaded_assets: Mutex::new(Vec::new()),
633 stored_assets: Mutex::new(Vec::new()),
634 }
635 }
636
637 fn seed_asset(&self, tag: &str, name: &str, content: Vec<u8>) {
640 self.stored_assets
641 .lock()
642 .unwrap()
643 .push((tag.to_string(), name.to_string(), content));
644 }
645 }
646
647 impl VcsProvider for FakeVcs {
648 fn create_release(
649 &self,
650 tag: &str,
651 _name: &str,
652 body: &str,
653 _prerelease: bool,
654 _draft: bool,
655 ) -> Result<String, ReleaseError> {
656 self.releases
657 .lock()
658 .unwrap()
659 .push((tag.to_string(), body.to_string()));
660 Ok(format!("https://github.com/test/release/{tag}"))
661 }
662
663 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
664 Ok(format!("https://github.com/test/compare/{base}...{head}"))
665 }
666
667 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
668 Ok(self.releases.lock().unwrap().iter().any(|(t, _)| t == tag))
669 }
670
671 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
672 self.deleted_releases.lock().unwrap().push(tag.to_string());
673 self.releases.lock().unwrap().retain(|(t, _)| t != tag);
674 Ok(())
675 }
676
677 fn update_release(
678 &self,
679 tag: &str,
680 _name: &str,
681 body: &str,
682 _prerelease: bool,
683 _draft: bool,
684 ) -> Result<String, ReleaseError> {
685 let mut releases = self.releases.lock().unwrap();
686 if let Some(entry) = releases.iter_mut().find(|(t, _)| t == tag) {
687 entry.1 = body.to_string();
688 }
689 Ok(format!("https://github.com/test/release/{tag}"))
690 }
691
692 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
693 self.uploaded_assets.lock().unwrap().push((
694 tag.to_string(),
695 files.iter().map(|s| s.to_string()).collect(),
696 ));
697 for path in files {
699 let basename = std::path::Path::new(path)
700 .file_name()
701 .and_then(|n| n.to_str())
702 .unwrap_or(path)
703 .to_string();
704 let content = std::fs::read(path).unwrap_or_default();
705 self.stored_assets
706 .lock()
707 .unwrap()
708 .push((tag.to_string(), basename, content));
709 }
710 Ok(())
711 }
712
713 fn list_assets(&self, tag: &str) -> Result<Vec<String>, ReleaseError> {
714 Ok(self
715 .stored_assets
716 .lock()
717 .unwrap()
718 .iter()
719 .filter(|(t, _, _)| t == tag)
720 .map(|(_, n, _)| n.clone())
721 .collect())
722 }
723
724 fn fetch_asset(&self, tag: &str, name: &str) -> Result<Option<Vec<u8>>, ReleaseError> {
725 Ok(self
726 .stored_assets
727 .lock()
728 .unwrap()
729 .iter()
730 .find(|(t, n, _)| t == tag && n == name)
731 .map(|(_, _, b)| b.clone()))
732 }
733
734 fn repo_url(&self) -> Option<String> {
735 Some("https://github.com/test/repo".into())
736 }
737 }
738
739 type TestStrategy =
742 TrunkReleaseStrategy<FakeGit, FakeVcs, TypedCommitParser, DefaultChangelogFormatter>;
743
744 fn test_config() -> Config {
748 Config {
749 changelog: ChangelogConfig {
750 file: None,
751 ..Default::default()
752 },
753 packages: vec![PackageConfig {
754 path: ".".into(),
755 version_files: vec!["__sr_test_dummy_no_bump__".into()],
756 ..Default::default()
757 }],
758 ..Default::default()
759 }
760 }
761
762 fn config_with_git(git: GitConfig) -> Config {
764 Config {
765 git,
766 changelog: ChangelogConfig {
767 file: None,
768 ..Default::default()
769 },
770 packages: vec![PackageConfig {
771 path: ".".into(),
772 version_files: vec!["__sr_test_dummy_no_bump__".into()],
773 ..Default::default()
774 }],
775 ..Default::default()
776 }
777 }
778
779 fn make_strategy(tags: Vec<TagInfo>, commits: Vec<Commit>, config: Config) -> TestStrategy {
780 TrunkReleaseStrategy {
781 git: FakeGit::new(tags, commits),
782 vcs: FakeVcs::new(),
783 parser: TypedCommitParser::default(),
784 formatter: DefaultChangelogFormatter::new(None, default_changelog_groups()),
785 config,
786 force: false,
787 prerelease_id: None,
788 draft: false,
789 }
790 }
791
792 fn raw_commit(msg: &str) -> Commit {
793 Commit {
794 sha: "a".repeat(40),
795 message: msg.into(),
796 }
797 }
798
799 #[test]
802 fn plan_no_commits_returns_error() {
803 let s = make_strategy(vec![], vec![], Config::default());
804 let err = s.plan().unwrap_err();
805 assert!(matches!(err, ReleaseError::NoCommits { .. }));
806 }
807
808 #[test]
809 fn plan_no_releasable_returns_error() {
810 let s = make_strategy(
811 vec![],
812 vec![raw_commit("chore: tidy up")],
813 Config::default(),
814 );
815 let err = s.plan().unwrap_err();
816 assert!(matches!(err, ReleaseError::NoBump { .. }));
817 }
818
819 #[test]
820 fn force_releases_patch_when_no_releasable_commits() {
821 let tag = TagInfo {
822 name: "v1.2.3".into(),
823 version: Version::new(1, 2, 3),
824 sha: "d".repeat(40),
825 };
826 let mut s = make_strategy(
827 vec![tag],
828 vec![raw_commit("chore: rename package")],
829 Config::default(),
830 );
831 s.force = true;
832 let plan = s.plan().unwrap();
833 assert_eq!(plan.next_version, Version::new(1, 2, 4));
834 assert_eq!(plan.bump, BumpLevel::Patch);
835 }
836
837 #[test]
838 fn plan_first_release() {
839 let s = make_strategy(
840 vec![],
841 vec![raw_commit("feat: initial feature")],
842 Config::default(),
843 );
844 let plan = s.plan().unwrap();
845 assert_eq!(plan.next_version, Version::new(0, 1, 0));
846 assert_eq!(plan.tag_name, "v0.1.0");
847 assert!(plan.current_version.is_none());
848 }
849
850 #[test]
851 fn plan_skips_commits_matching_skip_patterns() {
852 let s = make_strategy(
853 vec![],
854 vec![
855 raw_commit("feat: real feature"),
856 raw_commit("feat: noisy experiment [skip release]"),
857 raw_commit("fix: swallowed fix [skip sr]"),
858 ],
859 test_config(),
860 );
861 let plan = s.plan().unwrap();
862 assert_eq!(plan.commits.len(), 1);
863 assert_eq!(plan.commits[0].description, "real feature");
864 }
865
866 #[test]
867 fn plan_custom_skip_patterns_override_defaults() {
868 let git = GitConfig {
869 skip_patterns: vec!["DO-NOT-RELEASE".into()],
870 ..Default::default()
871 };
872 let s = make_strategy(
873 vec![],
874 vec![
875 raw_commit("feat: shipped"),
876 raw_commit("feat: DO-NOT-RELEASE internal"),
877 raw_commit("feat: still here [skip release]"),
879 ],
880 config_with_git(git),
881 );
882 let plan = s.plan().unwrap();
883 assert_eq!(plan.commits.len(), 2);
884 }
885
886 #[test]
887 fn plan_increments_existing() {
888 let tag = TagInfo {
889 name: "v1.2.3".into(),
890 version: Version::new(1, 2, 3),
891 sha: "b".repeat(40),
892 };
893 let s = make_strategy(
894 vec![tag],
895 vec![raw_commit("fix: patch bug")],
896 Config::default(),
897 );
898 let plan = s.plan().unwrap();
899 assert_eq!(plan.next_version, Version::new(1, 2, 4));
900 }
901
902 #[test]
903 fn plan_breaking_bump() {
904 let tag = TagInfo {
905 name: "v1.2.3".into(),
906 version: Version::new(1, 2, 3),
907 sha: "c".repeat(40),
908 };
909 let s = make_strategy(
910 vec![tag],
911 vec![raw_commit("feat!: breaking change")],
912 Config::default(),
913 );
914 let plan = s.plan().unwrap();
915 assert_eq!(plan.next_version, Version::new(2, 0, 0));
916 }
917
918 #[test]
919 fn plan_v0_breaking_downshifts_to_minor() {
920 let tag = TagInfo {
921 name: "v0.5.0".into(),
922 version: Version::new(0, 5, 0),
923 sha: "c".repeat(40),
924 };
925 let s = make_strategy(
926 vec![tag],
927 vec![raw_commit("feat!: breaking change")],
928 Config::default(),
929 );
930 let plan = s.plan().unwrap();
931 assert_eq!(plan.next_version, Version::new(0, 6, 0));
933 assert_eq!(plan.bump, BumpLevel::Minor);
934 }
935
936 #[test]
937 fn plan_v0_breaking_with_protection_disabled_bumps_major() {
938 let tag = TagInfo {
939 name: "v0.5.0".into(),
940 version: Version::new(0, 5, 0),
941 sha: "c".repeat(40),
942 };
943 let mut config = Config::default();
944 config.git.v0_protection = false;
945 let s = make_strategy(
946 vec![tag],
947 vec![raw_commit("feat!: breaking change")],
948 config,
949 );
950 let plan = s.plan().unwrap();
951 assert_eq!(plan.next_version, Version::new(1, 0, 0));
953 assert_eq!(plan.bump, BumpLevel::Major);
954 }
955
956 #[test]
957 fn plan_v0_feat_stays_minor() {
958 let tag = TagInfo {
959 name: "v0.5.0".into(),
960 version: Version::new(0, 5, 0),
961 sha: "c".repeat(40),
962 };
963 let s = make_strategy(
964 vec![tag],
965 vec![raw_commit("feat: new feature")],
966 Config::default(),
967 );
968 let plan = s.plan().unwrap();
969 assert_eq!(plan.next_version, Version::new(0, 6, 0));
971 assert_eq!(plan.bump, BumpLevel::Minor);
972 }
973
974 #[test]
975 fn plan_v0_fix_stays_patch() {
976 let tag = TagInfo {
977 name: "v0.5.0".into(),
978 version: Version::new(0, 5, 0),
979 sha: "c".repeat(40),
980 };
981 let s = make_strategy(
982 vec![tag],
983 vec![raw_commit("fix: bug fix")],
984 Config::default(),
985 );
986 let plan = s.plan().unwrap();
987 assert_eq!(plan.next_version, Version::new(0, 5, 1));
989 assert_eq!(plan.bump, BumpLevel::Patch);
990 }
991
992 #[test]
995 fn execute_dry_run_no_side_effects() {
996 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
997 let plan = s.plan().unwrap();
998 s.execute(&plan, true).unwrap();
999
1000 assert!(s.git.created_tags.lock().unwrap().is_empty());
1001 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1002 }
1003
1004 #[test]
1005 fn execute_creates_and_pushes_tag() {
1006 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1007 let plan = s.plan().unwrap();
1008 s.execute(&plan, false).unwrap();
1009
1010 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1011 assert_eq!(*s.git.pushed_tags.lock().unwrap(), vec!["v0.1.0"]);
1012 }
1013
1014 #[test]
1015 fn execute_calls_vcs_create_release() {
1016 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1017 let plan = s.plan().unwrap();
1018 s.execute(&plan, false).unwrap();
1019
1020 let releases = s.vcs.releases.lock().unwrap();
1021 assert_eq!(releases.len(), 1);
1022 assert_eq!(releases[0].0, "v0.1.0");
1023 assert!(!releases[0].1.is_empty());
1024 }
1025
1026 #[test]
1027 fn execute_commits_changelog_before_tag() {
1028 let dir = tempfile::tempdir().unwrap();
1029 let changelog_path = dir.path().join("CHANGELOG.md");
1030
1031 let config = Config {
1033 changelog: ChangelogConfig {
1034 file: Some(changelog_path.to_str().unwrap().to_string()),
1035 ..Default::default()
1036 },
1037 packages: vec![PackageConfig {
1038 path: dir.path().to_str().unwrap().to_string(),
1039 ..Default::default()
1040 }],
1041 ..Default::default()
1042 };
1043
1044 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1045 let plan = s.plan().unwrap();
1046 s.execute(&plan, false).unwrap();
1047
1048 let committed = s.git.committed.lock().unwrap();
1050 assert_eq!(committed.len(), 1);
1051 assert_eq!(
1052 committed[0].0,
1053 vec![changelog_path.to_str().unwrap().to_string()]
1054 );
1055 assert!(committed[0].1.contains("chore(release): v0.1.0"));
1056
1057 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1059 }
1060
1061 #[test]
1062 fn execute_skips_existing_tag() {
1063 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1064 let plan = s.plan().unwrap();
1065
1066 s.git
1068 .created_tags
1069 .lock()
1070 .unwrap()
1071 .push("v0.1.0".to_string());
1072
1073 s.execute(&plan, false).unwrap();
1074
1075 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1077 }
1078
1079 #[test]
1080 fn execute_skips_existing_release() {
1081 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1082 let plan = s.plan().unwrap();
1083
1084 s.vcs
1086 .releases
1087 .lock()
1088 .unwrap()
1089 .push(("v0.1.0".to_string(), "old notes".to_string()));
1090
1091 s.execute(&plan, false).unwrap();
1092
1093 let deleted = s.vcs.deleted_releases.lock().unwrap();
1095 assert!(deleted.is_empty(), "update should not delete");
1096
1097 let releases = s.vcs.releases.lock().unwrap();
1098 assert_eq!(releases.len(), 1);
1099 assert_eq!(releases[0].0, "v0.1.0");
1100 assert_ne!(releases[0].1, "old notes");
1101 }
1102
1103 #[test]
1104 fn execute_idempotent_rerun() {
1105 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1106 let plan = s.plan().unwrap();
1107
1108 s.execute(&plan, false).unwrap();
1110
1111 s.execute(&plan, false).unwrap();
1113
1114 assert_eq!(s.git.created_tags.lock().unwrap().len(), 1);
1116
1117 assert_eq!(s.git.pushed_tags.lock().unwrap().len(), 1);
1119
1120 assert_eq!(*s.git.push_count.lock().unwrap(), 2);
1122
1123 let deleted = s.vcs.deleted_releases.lock().unwrap();
1125 assert!(deleted.is_empty(), "update should not delete");
1126
1127 let releases = s.vcs.releases.lock().unwrap();
1128 assert_eq!(releases.len(), 1);
1129 assert_eq!(releases[0].0, "v0.1.0");
1130 }
1131
1132 #[test]
1133 fn execute_bumps_version_files() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let cargo_path = dir.path().join("Cargo.toml");
1136 std::fs::write(
1137 &cargo_path,
1138 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1139 )
1140 .unwrap();
1141
1142 let config = Config {
1143 changelog: ChangelogConfig {
1144 file: None,
1145 ..Default::default()
1146 },
1147 packages: vec![PackageConfig {
1148 path: ".".into(),
1149 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1150 ..Default::default()
1151 }],
1152 ..Default::default()
1153 };
1154
1155 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1156 let plan = s.plan().unwrap();
1157 s.execute(&plan, false).unwrap();
1158
1159 let contents = std::fs::read_to_string(&cargo_path).unwrap();
1161 assert!(contents.contains("version = \"0.1.0\""));
1162
1163 let committed = s.git.committed.lock().unwrap();
1165 assert_eq!(committed.len(), 1);
1166 assert!(
1167 committed[0]
1168 .0
1169 .contains(&cargo_path.to_str().unwrap().to_string())
1170 );
1171 }
1172
1173 #[test]
1174 fn execute_stages_changelog_and_version_files_together() {
1175 let dir = tempfile::tempdir().unwrap();
1176 let cargo_path = dir.path().join("Cargo.toml");
1177 std::fs::write(
1178 &cargo_path,
1179 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1180 )
1181 .unwrap();
1182
1183 let changelog_path = dir.path().join("CHANGELOG.md");
1184
1185 let config = Config {
1186 changelog: ChangelogConfig {
1187 file: Some(changelog_path.to_str().unwrap().to_string()),
1188 ..Default::default()
1189 },
1190 packages: vec![PackageConfig {
1191 path: ".".into(),
1192 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1193 ..Default::default()
1194 }],
1195 ..Default::default()
1196 };
1197
1198 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1199 let plan = s.plan().unwrap();
1200 s.execute(&plan, false).unwrap();
1201
1202 let committed = s.git.committed.lock().unwrap();
1204 assert_eq!(committed.len(), 1);
1205 assert!(
1206 committed[0]
1207 .0
1208 .contains(&changelog_path.to_str().unwrap().to_string())
1209 );
1210 assert!(
1211 committed[0]
1212 .0
1213 .contains(&cargo_path.to_str().unwrap().to_string())
1214 );
1215 }
1216
1217 #[test]
1220 fn execute_uploads_artifacts() {
1221 let dir = tempfile::tempdir().unwrap();
1222 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1223 std::fs::write(dir.path().join("app.zip"), "fake zip").unwrap();
1224
1225 let config = Config {
1226 changelog: ChangelogConfig {
1227 file: None,
1228 ..Default::default()
1229 },
1230 packages: vec![PackageConfig {
1231 path: ".".into(),
1232 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1233 artifacts: vec![
1234 dir.path().join("*.tar.gz").to_str().unwrap().to_string(),
1235 dir.path().join("*.zip").to_str().unwrap().to_string(),
1236 ],
1237 ..Default::default()
1238 }],
1239 ..Default::default()
1240 };
1241
1242 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1243 let plan = s.plan().unwrap();
1244 s.execute(&plan, false).unwrap();
1245
1246 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1247 assert_eq!(uploaded.len(), 2);
1249 let artifact_call = uploaded
1250 .iter()
1251 .find(|(_tag, files)| files.iter().any(|f| f.ends_with("app.tar.gz")))
1252 .expect("expected an upload call containing user artifacts");
1253 assert_eq!(artifact_call.0, "v0.1.0");
1254 assert_eq!(artifact_call.1.len(), 2);
1255 assert!(artifact_call.1.iter().any(|f| f.ends_with("app.tar.gz")));
1256 assert!(artifact_call.1.iter().any(|f| f.ends_with("app.zip")));
1257 }
1258
1259 #[test]
1260 fn execute_dry_run_shows_artifacts() {
1261 let dir = tempfile::tempdir().unwrap();
1262 std::fs::write(dir.path().join("app.tar.gz"), "fake tarball").unwrap();
1263
1264 let config = Config {
1265 changelog: ChangelogConfig {
1266 file: None,
1267 ..Default::default()
1268 },
1269 packages: vec![PackageConfig {
1270 path: ".".into(),
1271 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1272 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1273 ..Default::default()
1274 }],
1275 ..Default::default()
1276 };
1277
1278 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1279 let plan = s.plan().unwrap();
1280 s.execute(&plan, true).unwrap();
1281
1282 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1284 assert!(uploaded.is_empty());
1285 }
1286
1287 #[test]
1288 fn execute_no_artifacts_skips_upload() {
1289 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1290 let plan = s.plan().unwrap();
1291 s.execute(&plan, false).unwrap();
1292
1293 let uploaded = s.vcs.uploaded_assets.lock().unwrap();
1296 let user_uploads: Vec<_> = uploaded
1297 .iter()
1298 .filter(|(_tag, files)| {
1299 !files
1300 .iter()
1301 .all(|f| f.ends_with(crate::manifest::MANIFEST_ASSET_NAME))
1302 })
1303 .collect();
1304 assert!(
1305 user_uploads.is_empty(),
1306 "unexpected non-manifest uploads: {user_uploads:?}"
1307 );
1308 }
1309
1310 #[test]
1311 fn resolve_globs_basic() {
1312 let dir = tempfile::tempdir().unwrap();
1313 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
1314 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
1315 std::fs::create_dir(dir.path().join("subdir")).unwrap();
1316
1317 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1318 let result = resolve_globs(&[pattern]).unwrap();
1319 assert_eq!(result.len(), 2);
1320 assert!(result.iter().any(|f: &String| f.ends_with("a.txt")));
1321 assert!(result.iter().any(|f: &String| f.ends_with("b.txt")));
1322 }
1323
1324 #[test]
1325 fn resolve_globs_deduplicates() {
1326 let dir = tempfile::tempdir().unwrap();
1327 std::fs::write(dir.path().join("file.txt"), "data").unwrap();
1328
1329 let pattern = dir.path().join("*.txt").to_str().unwrap().to_string();
1330 let result = resolve_globs(&[pattern.clone(), pattern]).unwrap();
1332 assert_eq!(result.len(), 1);
1333 }
1334
1335 #[test]
1338 fn plan_floating_tag_when_enabled() {
1339 let tag = TagInfo {
1340 name: "v3.2.0".into(),
1341 version: Version::new(3, 2, 0),
1342 sha: "d".repeat(40),
1343 };
1344 let config = config_with_git(GitConfig {
1345 floating_tag: true,
1346 ..Default::default()
1347 });
1348
1349 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1350 let plan = s.plan().unwrap();
1351 assert_eq!(plan.next_version, Version::new(3, 2, 1));
1352 assert_eq!(plan.floating_tag_name.as_deref(), Some("v3"));
1353 }
1354
1355 #[test]
1356 fn plan_no_floating_tag_when_disabled() {
1357 let s = make_strategy(
1358 vec![],
1359 vec![raw_commit("feat: something")],
1360 config_with_git(GitConfig {
1361 floating_tag: false,
1362 ..Default::default()
1363 }),
1364 );
1365 let plan = s.plan().unwrap();
1366 assert!(plan.floating_tag_name.is_none());
1367 }
1368
1369 #[test]
1370 fn plan_floating_tag_custom_prefix() {
1371 let tag = TagInfo {
1372 name: "release-2.5.0".into(),
1373 version: Version::new(2, 5, 0),
1374 sha: "e".repeat(40),
1375 };
1376 let config = config_with_git(GitConfig {
1377 floating_tag: true,
1378 tag_prefix: "release-".into(),
1379 ..Default::default()
1380 });
1381
1382 let s = make_strategy(vec![tag], vec![raw_commit("fix: patch")], config);
1383 let plan = s.plan().unwrap();
1384 assert_eq!(plan.floating_tag_name.as_deref(), Some("release-2"));
1385 }
1386
1387 #[test]
1388 fn execute_floating_tags_force_create_and_push() {
1389 let config = config_with_git(GitConfig {
1390 floating_tag: true,
1391 ..Default::default()
1392 });
1393
1394 let tag = TagInfo {
1395 name: "v1.2.3".into(),
1396 version: Version::new(1, 2, 3),
1397 sha: "f".repeat(40),
1398 };
1399 let s = make_strategy(vec![tag], vec![raw_commit("fix: a bug")], config);
1400 let plan = s.plan().unwrap();
1401 assert_eq!(plan.floating_tag_name.as_deref(), Some("v1"));
1402
1403 s.execute(&plan, false).unwrap();
1404
1405 assert_eq!(*s.git.force_created_tags.lock().unwrap(), vec!["v1"]);
1406 assert_eq!(*s.git.force_pushed_tags.lock().unwrap(), vec!["v1"]);
1407 }
1408
1409 #[test]
1410 fn execute_no_floating_tags_when_disabled() {
1411 let s = make_strategy(
1412 vec![],
1413 vec![raw_commit("feat: something")],
1414 config_with_git(GitConfig {
1415 floating_tag: false,
1416 ..Default::default()
1417 }),
1418 );
1419 let plan = s.plan().unwrap();
1420 assert!(plan.floating_tag_name.is_none());
1421
1422 s.execute(&plan, false).unwrap();
1423
1424 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1425 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1426 }
1427
1428 #[test]
1429 fn execute_floating_tags_dry_run_no_side_effects() {
1430 let config = config_with_git(GitConfig {
1431 floating_tag: true,
1432 ..Default::default()
1433 });
1434
1435 let tag = TagInfo {
1436 name: "v2.0.0".into(),
1437 version: Version::new(2, 0, 0),
1438 sha: "a".repeat(40),
1439 };
1440 let s = make_strategy(vec![tag], vec![raw_commit("fix: something")], config);
1441 let plan = s.plan().unwrap();
1442 assert_eq!(plan.floating_tag_name.as_deref(), Some("v2"));
1443
1444 s.execute(&plan, true).unwrap();
1445
1446 assert!(s.git.force_created_tags.lock().unwrap().is_empty());
1447 assert!(s.git.force_pushed_tags.lock().unwrap().is_empty());
1448 }
1449
1450 #[test]
1451 fn execute_floating_tags_idempotent() {
1452 let config = config_with_git(GitConfig {
1453 floating_tag: true,
1454 ..Default::default()
1455 });
1456
1457 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1458 let plan = s.plan().unwrap();
1459 assert_eq!(plan.floating_tag_name.as_deref(), Some("v0"));
1460
1461 s.execute(&plan, false).unwrap();
1463 s.execute(&plan, false).unwrap();
1464
1465 assert_eq!(s.git.force_created_tags.lock().unwrap().len(), 2);
1467 assert_eq!(s.git.force_pushed_tags.lock().unwrap().len(), 2);
1468 }
1469
1470 #[test]
1473 fn force_rerelease_when_tag_at_head() {
1474 let tag = TagInfo {
1475 name: "v1.2.3".into(),
1476 version: Version::new(1, 2, 3),
1477 sha: "a".repeat(40),
1478 };
1479 let mut s = make_strategy(vec![tag], vec![], Config::default());
1480 s.git.head = "a".repeat(40);
1482 s.force = true;
1483
1484 let plan = s.plan().unwrap();
1485 assert_eq!(plan.next_version, Version::new(1, 2, 3));
1486 assert_eq!(plan.tag_name, "v1.2.3");
1487 assert!(plan.commits.is_empty());
1488 assert_eq!(plan.current_version, Some(Version::new(1, 2, 3)));
1489 }
1490
1491 #[test]
1492 fn force_fails_when_tag_not_at_head() {
1493 let tag = TagInfo {
1494 name: "v1.2.3".into(),
1495 version: Version::new(1, 2, 3),
1496 sha: "a".repeat(40),
1497 };
1498 let mut s = make_strategy(vec![tag], vec![], Config::default());
1499 s.git.head = "b".repeat(40);
1501 s.force = true;
1502
1503 let err = s.plan().unwrap_err();
1504 assert!(matches!(err, ReleaseError::NoCommits { .. }));
1505 }
1506
1507 #[test]
1511 fn execute_runs_build_hook_with_version_env() {
1512 let dir = tempfile::tempdir().unwrap();
1513 let marker = dir.path().join("saw_version.txt");
1514 let cmd = format!("echo \"$SR_VERSION\" > {}", marker.display());
1515
1516 let config = Config {
1517 changelog: ChangelogConfig {
1518 file: None,
1519 ..Default::default()
1520 },
1521 packages: vec![PackageConfig {
1522 path: ".".into(),
1523 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1524 hooks: Some(HooksConfig {
1525 build: vec![cmd],
1526 ..Default::default()
1527 }),
1528 ..Default::default()
1529 }],
1530 ..Default::default()
1531 };
1532
1533 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1534 let plan = s.plan().unwrap();
1535 s.execute(&plan, false).unwrap();
1536
1537 let content = std::fs::read_to_string(&marker).unwrap();
1538 assert_eq!(content.trim(), "0.1.0");
1539 }
1540
1541 #[test]
1544 fn execute_build_sees_bumped_version_on_disk() {
1545 let dir = tempfile::tempdir().unwrap();
1546 let cargo_path = dir.path().join("Cargo.toml");
1547 std::fs::write(
1548 &cargo_path,
1549 "[package]\nname = \"test\"\nversion = \"0.0.0\"\n",
1550 )
1551 .unwrap();
1552
1553 let marker = dir.path().join("observed_version.txt");
1554 let cmd = format!(
1556 "grep '^version' {} > {}",
1557 cargo_path.display(),
1558 marker.display()
1559 );
1560
1561 let config = Config {
1562 changelog: ChangelogConfig {
1563 file: None,
1564 ..Default::default()
1565 },
1566 packages: vec![PackageConfig {
1567 path: ".".into(),
1568 version_files: vec![cargo_path.to_str().unwrap().to_string()],
1569 hooks: Some(HooksConfig {
1570 build: vec![cmd],
1571 ..Default::default()
1572 }),
1573 ..Default::default()
1574 }],
1575 ..Default::default()
1576 };
1577
1578 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1579 let plan = s.plan().unwrap();
1580 s.execute(&plan, false).unwrap();
1581
1582 let content = std::fs::read_to_string(&marker).unwrap();
1583 assert!(
1584 content.contains("0.1.0"),
1585 "build should see bumped version on disk, got: {content}"
1586 );
1587 }
1588
1589 #[test]
1592 fn execute_build_failure_leaves_no_tag_or_commit() {
1593 let config = Config {
1594 changelog: ChangelogConfig {
1595 file: None,
1596 ..Default::default()
1597 },
1598 packages: vec![PackageConfig {
1599 path: ".".into(),
1600 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1601 hooks: Some(HooksConfig {
1602 build: vec!["false".into()],
1603 ..Default::default()
1604 }),
1605 ..Default::default()
1606 }],
1607 ..Default::default()
1608 };
1609
1610 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1611 let plan = s.plan().unwrap();
1612 let err = s.execute(&plan, false).unwrap_err();
1613 assert!(matches!(err, ReleaseError::Hook(_)), "got {err:?}");
1614
1615 assert!(s.git.created_tags.lock().unwrap().is_empty());
1616 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1617 assert!(s.git.committed.lock().unwrap().is_empty());
1618 assert!(s.vcs.releases.lock().unwrap().is_empty());
1619 }
1620
1621 #[test]
1624 fn execute_validation_fails_when_declared_artifact_missing() {
1625 let config = Config {
1626 changelog: ChangelogConfig {
1627 file: None,
1628 ..Default::default()
1629 },
1630 packages: vec![PackageConfig {
1631 path: ".".into(),
1632 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1633 artifacts: vec!["/definitely/not/here/*.tar.gz".into()],
1634 hooks: Some(HooksConfig {
1635 build: vec!["true".into()],
1636 ..Default::default()
1637 }),
1638 ..Default::default()
1639 }],
1640 ..Default::default()
1641 };
1642
1643 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1644 let plan = s.plan().unwrap();
1645 let err = s.execute(&plan, false).unwrap_err();
1646
1647 match err {
1648 ReleaseError::Vcs(ref msg) => {
1649 assert!(
1650 msg.contains("matched no files"),
1651 "expected validation error, got: {msg}"
1652 );
1653 }
1654 other => panic!("expected Vcs error, got {other:?}"),
1655 }
1656
1657 assert!(s.git.created_tags.lock().unwrap().is_empty());
1658 assert!(s.git.pushed_tags.lock().unwrap().is_empty());
1659 assert!(s.vcs.releases.lock().unwrap().is_empty());
1660 }
1661
1662 #[test]
1664 fn execute_validation_passes_when_all_artifacts_present() {
1665 let dir = tempfile::tempdir().unwrap();
1666 std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1667
1668 let config = Config {
1669 changelog: ChangelogConfig {
1670 file: None,
1671 ..Default::default()
1672 },
1673 packages: vec![PackageConfig {
1674 path: ".".into(),
1675 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1676 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1677 hooks: Some(HooksConfig {
1678 build: vec!["true".into()],
1679 ..Default::default()
1680 }),
1681 ..Default::default()
1682 }],
1683 ..Default::default()
1684 };
1685
1686 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1687 let plan = s.plan().unwrap();
1688 s.execute(&plan, false).unwrap();
1689
1690 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1691 }
1692
1693 #[test]
1697 fn execute_validation_skipped_without_build_hooks() {
1698 let config = Config {
1699 changelog: ChangelogConfig {
1700 file: None,
1701 ..Default::default()
1702 },
1703 packages: vec![PackageConfig {
1704 path: ".".into(),
1705 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1706 artifacts: vec!["/still/not/here/*.tar.gz".into()],
1707 ..Default::default()
1709 }],
1710 ..Default::default()
1711 };
1712
1713 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1714 let plan = s.plan().unwrap();
1715 s.execute(&plan, false).unwrap();
1716
1717 assert_eq!(*s.git.created_tags.lock().unwrap(), vec!["v0.1.0"]);
1718 }
1719
1720 #[test]
1724 fn execute_uploads_manifest_as_final_asset() {
1725 let s = make_strategy(vec![], vec![raw_commit("feat: something")], test_config());
1726 let plan = s.plan().unwrap();
1727 s.execute(&plan, false).unwrap();
1728
1729 let assets = s.vcs.list_assets("v0.1.0").unwrap();
1730 assert!(
1731 assets.contains(&crate::manifest::MANIFEST_ASSET_NAME.to_string()),
1732 "manifest should be uploaded; got {assets:?}"
1733 );
1734 }
1735
1736 #[test]
1739 fn execute_manifest_contains_tag_and_artifacts() {
1740 let dir = tempfile::tempdir().unwrap();
1741 std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1742
1743 let config = Config {
1744 changelog: ChangelogConfig {
1745 file: None,
1746 ..Default::default()
1747 },
1748 packages: vec![PackageConfig {
1749 path: ".".into(),
1750 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1751 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1752 ..Default::default()
1753 }],
1754 ..Default::default()
1755 };
1756
1757 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1758 let plan = s.plan().unwrap();
1759 s.execute(&plan, false).unwrap();
1760
1761 let manifest_bytes = s
1762 .vcs
1763 .fetch_asset("v0.1.0", crate::manifest::MANIFEST_ASSET_NAME)
1764 .unwrap()
1765 .expect("manifest should be present");
1766 let manifest: crate::manifest::Manifest = serde_json::from_slice(&manifest_bytes).unwrap();
1767
1768 assert_eq!(manifest.tag, "v0.1.0");
1769 assert!(manifest.artifacts.iter().any(|a| a == "app.tar.gz"));
1770 assert!(!manifest.commit_sha.is_empty());
1771 assert!(!manifest.sr_version.is_empty());
1772 }
1773
1774 #[test]
1777 fn execute_skips_already_uploaded_artifacts() {
1778 let dir = tempfile::tempdir().unwrap();
1779 std::fs::write(dir.path().join("app.tar.gz"), "fake").unwrap();
1780
1781 let config = Config {
1782 changelog: ChangelogConfig {
1783 file: None,
1784 ..Default::default()
1785 },
1786 packages: vec![PackageConfig {
1787 path: ".".into(),
1788 version_files: vec!["__sr_test_dummy_no_bump__".into()],
1789 artifacts: vec![dir.path().join("*.tar.gz").to_str().unwrap().to_string()],
1790 ..Default::default()
1791 }],
1792 ..Default::default()
1793 };
1794
1795 let s = make_strategy(vec![], vec![raw_commit("feat: something")], config);
1796 let plan = s.plan().unwrap();
1797
1798 s.execute(&plan, false).unwrap();
1800 let uploads_after_first = s.vcs.uploaded_assets.lock().unwrap().len();
1801
1802 s.execute(&plan, false).unwrap();
1804 let uploads_after_second = s.vcs.uploaded_assets.lock().unwrap().len();
1805
1806 assert_eq!(
1807 uploads_after_first, uploads_after_second,
1808 "idempotent re-run should not re-upload existing assets"
1809 );
1810 }
1811
1812 #[test]
1815 fn plan_blocks_when_previous_release_incomplete() {
1816 let prev_tag = TagInfo {
1817 name: "v1.0.0".into(),
1818 version: Version::new(1, 0, 0),
1819 sha: "a".repeat(40),
1820 };
1821 let s = make_strategy(
1822 vec![prev_tag],
1823 vec![raw_commit("feat: new thing")],
1824 test_config(),
1825 );
1826
1827 let incomplete = crate::manifest::Manifest {
1830 sr_version: "7.1.0".into(),
1831 tag: "v1.0.0".into(),
1832 commit_sha: "a".repeat(40),
1833 artifacts: vec!["missing-binary.tar.gz".into()],
1834 completed_at: "2026-04-18T00:00:00Z".into(),
1835 };
1836 s.vcs.seed_asset(
1837 "v1.0.0",
1838 crate::manifest::MANIFEST_ASSET_NAME,
1839 serde_json::to_vec(&incomplete).unwrap(),
1840 );
1841
1842 let err = s.plan().unwrap_err();
1843 match err {
1844 ReleaseError::Vcs(ref msg) => {
1845 assert!(
1846 msg.contains("incomplete") && msg.contains("missing-binary.tar.gz"),
1847 "unexpected error: {msg}"
1848 );
1849 }
1850 other => panic!("expected Vcs error, got {other:?}"),
1851 }
1852 }
1853
1854 #[test]
1856 fn plan_passes_when_previous_release_complete() {
1857 let prev_tag = TagInfo {
1858 name: "v1.0.0".into(),
1859 version: Version::new(1, 0, 0),
1860 sha: "a".repeat(40),
1861 };
1862 let s = make_strategy(
1863 vec![prev_tag],
1864 vec![raw_commit("feat: next thing")],
1865 test_config(),
1866 );
1867
1868 let complete = crate::manifest::Manifest {
1869 sr_version: "7.1.0".into(),
1870 tag: "v1.0.0".into(),
1871 commit_sha: "a".repeat(40),
1872 artifacts: vec!["ok.tar.gz".into()],
1873 completed_at: "2026-04-18T00:00:00Z".into(),
1874 };
1875 s.vcs.seed_asset(
1876 "v1.0.0",
1877 crate::manifest::MANIFEST_ASSET_NAME,
1878 serde_json::to_vec(&complete).unwrap(),
1879 );
1880 s.vcs.seed_asset("v1.0.0", "ok.tar.gz", b"bin".to_vec());
1881
1882 let plan = s.plan().unwrap();
1883 assert_eq!(plan.next_version, Version::new(1, 1, 0));
1884 }
1885
1886 #[test]
1889 fn plan_passes_when_previous_release_has_no_manifest() {
1890 let prev_tag = TagInfo {
1891 name: "v1.0.0".into(),
1892 version: Version::new(1, 0, 0),
1893 sha: "a".repeat(40),
1894 };
1895 let s = make_strategy(
1896 vec![prev_tag],
1897 vec![raw_commit("feat: legacy compat")],
1898 test_config(),
1899 );
1900 let plan = s.plan().unwrap();
1903 assert_eq!(plan.next_version, Version::new(1, 1, 0));
1904 }
1905
1906 #[test]
1909 fn plan_with_force_bypasses_reconciliation_block() {
1910 let prev_tag = TagInfo {
1911 name: "v1.0.0".into(),
1912 version: Version::new(1, 0, 0),
1913 sha: "a".repeat(40),
1914 };
1915 let mut s = make_strategy(vec![prev_tag], vec![], test_config());
1916 s.git.head = "a".repeat(40); s.force = true;
1918
1919 let incomplete = crate::manifest::Manifest {
1920 sr_version: "7.1.0".into(),
1921 tag: "v1.0.0".into(),
1922 commit_sha: "a".repeat(40),
1923 artifacts: vec!["missing.tar.gz".into()],
1924 completed_at: "2026-04-18T00:00:00Z".into(),
1925 };
1926 s.vcs.seed_asset(
1927 "v1.0.0",
1928 crate::manifest::MANIFEST_ASSET_NAME,
1929 serde_json::to_vec(&incomplete).unwrap(),
1930 );
1931
1932 let plan = s.plan().unwrap();
1934 assert_eq!(plan.next_version, Version::new(1, 0, 0));
1935 }
1936}