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