1use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29use anyhow::{Context, Result, bail};
30use serde::{Deserialize, Serialize};
31
32use crate::config::Release;
33use crate::git;
34use crate::proc::Quiet as _;
35
36const TAIL: usize = 4_000;
39
40pub const BRANCH_PREFIX: &str = "chore/release-v";
42
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(default)]
46pub struct StepLog {
47 pub name: String,
49 pub code: Option<i32>,
51 pub tail: String,
53 pub output: Option<String>,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(default)]
60pub struct Job {
61 pub version: String,
63 pub pr_url: String,
65 pub commit: String,
67 pub tag_done: bool,
69 pub done: usize,
71 pub running: Option<String>,
73 pub failed: Option<String>,
75 pub finished: bool,
77 pub log: Vec<StepLog>,
79 pub accepted: Vec<String>,
84}
85
86impl Job {
87 pub fn new(version: &str, pr_url: &str, commit: &str) -> Self {
89 Self {
90 version: version.to_owned(),
91 pr_url: pr_url.to_owned(),
92 commit: commit.to_owned(),
93 ..Self::default()
94 }
95 }
96
97 pub fn tag(&self) -> String {
99 format!("v{}", self.version)
100 }
101
102 pub fn interrupted(&self) -> bool {
105 self.running.is_some() && self.failed.is_none() && !self.finished
106 }
107
108 pub fn resume(&mut self) {
110 self.failed = None;
111 self.running = None;
112 }
113}
114
115pub const VERSION_MISMATCH_PREFIX: &str = "Cargo.toml at the merge commit says ";
119
120pub fn version_from_title(title: &str) -> Option<String> {
122 let rest = title.split_once("release v")?.1;
123 let v: String = rest.chars().take_while(|c| !c.is_whitespace()).collect();
124 (!v.is_empty()).then_some(v)
125}
126
127pub fn reconcile_version(manifest: &str, bumped: bool, job: &mut Job) -> bool {
133 if manifest == job.version {
134 return true;
135 }
136 if job.tag_done || job.done > 0 || !(bumped || job.accepted.iter().any(|a| a == manifest)) {
137 return false;
138 }
139 job.log.push(StepLog {
140 name: "version".to_owned(),
141 code: Some(0),
142 tail: format!(
143 "the release is {manifest} (the manifest), not {} (the branch name)",
144 job.version
145 ),
146 output: None,
147 });
148 job.version = manifest.to_owned();
149 true
150}
151
152async fn bumped_by(wt: &Path, commit: &str, version: &str) -> bool {
160 let parent = format!("{commit}^1");
161 let shown = git::git_raw(wt, &["show", &format!("{parent}:Cargo.toml")]).await;
162 if let Ok(out) = &shown
163 && out.code == Some(0)
164 && let Ok(v) = crate::bump::current_version(&out.stdout)
165 {
166 return v != version;
167 }
168 git::git_raw(wt, &["cat-file", "commit", commit])
169 .await
170 .is_ok_and(|o| o.code == Some(0) && has_parent_header(&o.stdout))
171}
172
173fn has_parent_header(raw: &str) -> bool {
176 raw.lines()
177 .take_while(|l| !l.is_empty())
178 .any(|l| l.starts_with("parent "))
179}
180
181pub fn version_from_branch(branch: &str) -> Option<String> {
183 let v = branch.strip_prefix(BRANCH_PREFIX)?;
184 (!v.is_empty()).then(|| v.to_owned())
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum TagStep {
190 Create,
192 PushExisting,
194 Present,
196 Foreign(String),
198 Unreadable(String),
200}
201
202pub fn tag_step(
206 tag: &str,
207 commit: &str,
208 remote: std::result::Result<Option<&str>, &str>,
209 local: Option<&str>,
210) -> TagStep {
211 match remote {
212 Err(e) => TagStep::Unreadable(format!("could not read the remote's {tag}: {e}")),
213 Ok(Some(r)) if r == commit => TagStep::Present,
214 Ok(Some(r)) => TagStep::Foreign(format!(
215 "{tag} already exists on the remote at {r}, not at the merge commit {commit}"
216 )),
217 Ok(None) => match local {
218 Some(l) if l == commit => TagStep::PushExisting,
219 Some(l) => TagStep::Foreign(format!(
220 "{tag} already exists locally at {l}, not at the merge commit {commit}"
221 )),
222 None => TagStep::Create,
223 },
224 }
225}
226
227pub fn parse_ls_remote(out: &str, tag: &str) -> Option<String> {
231 let direct = format!("refs/tags/{tag}");
232 let peeled = format!("{direct}^{{}}");
233 let mut found = None;
234 for line in out.lines() {
235 let mut it = line.split_whitespace();
236 let (Some(oid), Some(name)) = (it.next(), it.next()) else {
237 continue;
238 };
239 if name == peeled {
240 return Some(oid.to_owned());
241 }
242 if name == direct {
243 found = Some(oid.to_owned());
244 }
245 }
246 found
247}
248
249pub fn tail(s: &str, max: usize) -> String {
251 let s = s.trim_end();
252 if s.len() <= max {
253 return s.to_owned();
254 }
255 let mut cut = s.len() - max;
256 while !s.is_char_boundary(cut) {
257 cut += 1;
258 }
259 format!("...{}", &s[cut..])
260}
261
262pub fn job_dir(home: &Path, key: &str) -> PathBuf {
264 home.join("release-local").join(crate::notices::id_of(key))
265}
266
267fn env_of(job: &Job) -> Vec<(&'static str, String)> {
268 vec![
269 ("MAGI_RELEASE_VERSION", job.version.clone()),
270 ("MAGI_RELEASE_TAG", job.tag()),
271 ("MAGI_RELEASE_COMMIT", job.commit.clone()),
272 ("MAGI_RELEASE_PR", job.pr_url.clone()),
273 ]
274}
275
276pub type Save<'a> = &'a mut (dyn FnMut(&Job) -> bool + Send);
278
279pub struct Env<'a> {
281 pub repo: &'a Path,
283 pub home: &'a Path,
285 pub key: &'a str,
287 pub remote: &'a str,
289 pub shell: &'a [String],
291 pub release: &'a Release,
293}
294
295pub async fn run_job(env: &Env<'_>, job: &mut Job, save: Save<'_>) -> Result<()> {
298 let dir = job_dir(env.home, env.key);
299 let wt = dir.join("wt");
300 let result = drive(env, &dir, &wt, job, save).await;
301 if job.finished {
306 let _ = git::worktree_remove(env.repo, &wt).await;
307 }
308 result
309}
310
311async fn drive(env: &Env<'_>, dir: &Path, wt: &Path, job: &mut Job, save: Save<'_>) -> Result<()> {
312 let (repo, remote) = (env.repo, env.remote);
313 if job.commit.is_empty() {
314 bail!("the merge commit of {} is unknown", job.pr_url);
315 }
316 let progressed = job.tag_done || job.done > 0;
319 let reusable = progressed
320 && wt.exists()
321 && git::rev_parse(wt, "HEAD").await.ok().as_deref() == Some(job.commit.as_str());
322 if !reusable {
323 if wt.exists() {
324 let _ = git::worktree_remove(repo, wt).await;
325 let _ = std::fs::remove_dir_all(wt);
326 }
327 let fetched = git::git_raw(repo, &["fetch", "--quiet", remote]).await?;
330 if !git::rev_exists(repo, &format!("{}^{{commit}}", job.commit)).await {
331 bail!(
332 "the merge commit {} is not available locally (git fetch {remote}: {})",
333 job.commit,
334 fetched.stderr
335 );
336 }
337 git::worktree_add_detached(repo, wt, &job.commit)
338 .await
339 .context("check out the merge commit")?;
340 let head = git::rev_parse(wt, "HEAD").await?;
341 if head != job.commit {
342 bail!(
343 "the release checkout is at {head}, not the merge commit {}",
344 job.commit
345 );
346 }
347 if !git::is_clean(wt).await? {
348 bail!("the release checkout is not clean");
349 }
350 }
351 if let Ok(toml) = std::fs::read_to_string(wt.join("Cargo.toml")) {
352 let before = job.version.clone();
353 match crate::bump::current_version(&toml) {
354 Ok(v) if v != job.version && !job.tag_done && job.done == 0 => {
355 let bumped = bumped_by(wt, &job.commit, &v).await;
356 if !reconcile_version(&v, bumped, job) {
357 bail!(
358 "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
359 job.version
360 );
361 }
362 if job.version != before && !save(job) {
364 bail!("could not record progress; nothing was run");
365 }
366 }
367 Ok(v) if v == job.version => {}
368 Ok(v) => bail!(
369 "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
370 job.version
371 ),
372 Err(e) => bail!("cannot read the version at the merge commit: {e:#}"),
373 }
374 }
375
376 if !job.tag_done {
377 job.running = Some("tag".to_owned());
378 if !save(job) {
379 bail!("could not record progress; nothing was run");
380 }
381 let note = tag(wt, remote, job).await?;
382 job.log.push(StepLog {
383 name: "tag".to_owned(),
384 code: Some(0),
385 tail: note,
386 output: None,
387 });
388 job.tag_done = true;
389 job.running = None;
390 if !save(job) {
391 bail!("the tag was pushed but progress could not be recorded");
392 }
393 }
394
395 let release = env.release;
396 let timeout = Duration::from_secs(release.timeout_minutes.max(1) * 60);
397 while job.done < release.commands.len() {
398 let n = job.done;
399 let command = &release.commands[n];
400 job.running = Some(format!("command {}", n + 1));
401 if !save(job) {
402 bail!("could not record progress; nothing was run");
403 }
404 let (code, output) = run_command(wt, env.shell, command, job, timeout).await;
405 let _ = std::fs::create_dir_all(dir);
406 let out_path = dir.join(format!("release-{}.out", n + 1));
407 let kept = std::fs::write(&out_path, &output).is_ok();
408 job.log.push(StepLog {
409 name: command.clone(),
410 code,
411 tail: tail(&output, TAIL),
412 output: kept.then(|| out_path.display().to_string()),
413 });
414 if code != Some(0) {
415 let why = match code {
416 Some(c) => format!("command {} `{command}` exited {c}", n + 1),
417 None => format!(
418 "command {} `{command}` did not finish (timeout is {} minute(s))",
419 n + 1,
420 release.timeout_minutes
421 ),
422 };
423 job.failed = Some(why.clone());
426 job.running = None;
427 let _ = save(job);
428 bail!("{why}");
429 }
430 job.running = None;
431 job.done += 1;
432 if !save(job) {
433 bail!(
434 "command {} succeeded but progress could not be recorded",
435 n + 1
436 );
437 }
438 }
439 job.finished = true;
440 let _ = save(job);
441 Ok(())
442}
443
444async fn tag(wt: &Path, remote: &str, job: &Job) -> Result<String> {
446 let name = job.tag();
447 let listed = git::git_raw(
448 wt,
449 &[
450 "ls-remote",
451 "--tags",
452 remote,
453 &format!("refs/tags/{name}"),
454 &format!("refs/tags/{name}^{{}}"),
455 ],
456 )
457 .await?;
458 let remote_tag = if listed.ok() {
459 Ok(parse_ls_remote(&listed.stdout, &name))
460 } else {
461 Err(listed.stderr.clone())
462 };
463 let local = git::git_raw(
464 wt,
465 &[
466 "rev-parse",
467 "-q",
468 "--verify",
469 &format!("refs/tags/{name}^{{commit}}"),
470 ],
471 )
472 .await?;
473 let local = local.ok().then(|| local.stdout.clone());
474 let step = tag_step(
475 &name,
476 &job.commit,
477 remote_tag
478 .as_ref()
479 .map(|o| o.as_deref())
480 .map_err(String::as_str),
481 local.as_deref(),
482 );
483 match step {
484 TagStep::Present => Ok(format!("{name} already on the remote at the merge commit")),
485 TagStep::Foreign(why) | TagStep::Unreadable(why) => bail!("{why}"),
486 TagStep::Create | TagStep::PushExisting => {
487 if step == TagStep::Create {
488 let has_ident = git::git_raw(wt, &["var", "GIT_COMMITTER_IDENT"])
492 .await?
493 .ok();
494 let mut args = vec![];
495 if !has_ident {
496 args.extend(["-c", "user.name=magi", "-c", "user.email=magi@localhost"]);
497 }
498 args.extend(["tag", "-a", name.as_str(), "-m", name.as_str(), &job.commit]);
499 let out = git::git_raw(wt, &args).await?;
500 if !out.ok() {
501 bail!("git tag {name}: {}", out.stderr);
502 }
503 }
504 let refspec = format!("refs/tags/{name}:refs/tags/{name}");
506 let out = git::git_raw(wt, &["push", remote, &refspec]).await?;
507 if !out.ok() {
508 bail!("git push {remote} {refspec}: {}", out.stderr);
509 }
510 Ok(format!("pushed {name} at {}", job.commit))
511 }
512 }
513}
514
515async fn run_command(
517 wt: &Path,
518 shell: &[String],
519 command: &str,
520 job: &Job,
521 timeout: Duration,
522) -> (Option<i32>, String) {
523 let Some((prog, args)) = shell.split_first() else {
524 return (None, "no shell is configured".to_owned());
525 };
526 let mut cmd = tokio::process::Command::new(prog);
527 cmd.args(args)
528 .arg(command)
529 .current_dir(wt)
530 .kill_on_drop(true)
531 .stdin(std::process::Stdio::null());
532 for (k, v) in env_of(job) {
533 cmd.env(k, v);
534 }
535 cmd.quiet();
536 match tokio::time::timeout(timeout, cmd.output()).await {
537 Err(_) => (None, "timed out".to_owned()),
538 Ok(Err(e)) => (None, format!("could not run: {e}")),
539 Ok(Ok(out)) => {
540 let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
541 text.push_str(&String::from_utf8_lossy(&out.stderr));
542 (out.status.code(), text)
543 }
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 const C: &str = "aaaa";
552
553 #[test]
554 fn the_tag_decision_never_touches_an_existing_tag() {
555 let t = "v1.0.0";
556 assert_eq!(tag_step(t, C, Ok(None), None), TagStep::Create);
557 assert_eq!(tag_step(t, C, Ok(None), Some(C)), TagStep::PushExisting);
558 assert_eq!(tag_step(t, C, Ok(Some(C)), None), TagStep::Present);
559 assert_eq!(tag_step(t, C, Ok(Some(C)), Some("bbbb")), TagStep::Present);
560 assert!(matches!(
561 tag_step(t, C, Ok(Some("bbbb")), Some(C)),
562 TagStep::Foreign(_)
563 ));
564 assert!(matches!(
565 tag_step(t, C, Ok(None), Some("bbbb")),
566 TagStep::Foreign(_)
567 ));
568 assert!(matches!(
570 tag_step(t, C, Err("boom"), None),
571 TagStep::Unreadable(_)
572 ));
573 }
574
575 #[test]
576 fn ls_remote_prefers_the_peeled_commit() {
577 let out = "1111\trefs/tags/v1.0.0\n2222\trefs/tags/v1.0.0^{}\n3333\trefs/tags/v1.0.0-rc\n";
578 assert_eq!(parse_ls_remote(out, "v1.0.0").as_deref(), Some("2222"));
579 assert_eq!(
580 parse_ls_remote("1111\trefs/tags/v1.0.0\n", "v1.0.0").as_deref(),
581 Some("1111")
582 );
583 assert_eq!(parse_ls_remote("", "v1.0.0"), None);
584 }
585
586 #[test]
587 fn version_comes_from_the_release_branch() {
588 assert_eq!(
589 version_from_branch("chore/release-v1.2.3").as_deref(),
590 Some("1.2.3")
591 );
592 assert_eq!(version_from_branch("chore/release-v"), None);
593 assert_eq!(version_from_branch("feat/x"), None);
594 }
595
596 #[test]
597 fn a_started_and_unfinished_step_is_interrupted_not_retried() {
598 let mut j = Job::new("1.0.0", "u", C);
599 assert!(!j.interrupted());
600 j.running = Some("command 1".to_owned());
601 assert!(j.interrupted());
602 j.failed = Some("x".to_owned());
603 assert!(!j.interrupted());
604 j.resume();
605 assert!(!j.interrupted() && j.failed.is_none());
606 }
607
608 #[test]
609 fn tail_keeps_the_end_on_a_char_boundary() {
610 assert_eq!(tail("abc", 10), "abc");
611 assert_eq!(tail("abcdef", 3), "...def");
612 assert_eq!(tail("aéb", 2), "...b");
614 }
615
616 fn g(dir: &Path, args: &[&str]) -> String {
618 let out = std::process::Command::new("git")
619 .args(args)
620 .current_dir(dir)
621 .quiet()
622 .env("GIT_AUTHOR_NAME", "t")
623 .env("GIT_AUTHOR_EMAIL", "t@t")
624 .env("GIT_COMMITTER_NAME", "t")
625 .env("GIT_COMMITTER_EMAIL", "t@t")
626 .output()
627 .unwrap();
628 assert!(
629 out.status.success(),
630 "git {args:?}: {}",
631 String::from_utf8_lossy(&out.stderr)
632 );
633 String::from_utf8_lossy(&out.stdout).trim().to_owned()
634 }
635
636 struct Fixture {
637 _dir: tempfile::TempDir,
638 repo: PathBuf,
639 remote: PathBuf,
640 home: PathBuf,
641 commit: String,
642 }
643
644 fn fixture() -> Fixture {
645 let dir = tempfile::tempdir().unwrap();
646 let remote = dir.path().join("remote.git");
647 let repo = dir.path().join("repo");
648 let home = dir.path().join("home");
649 std::fs::create_dir_all(&remote).unwrap();
650 std::fs::create_dir_all(&repo).unwrap();
651 g(&remote, &["init", "--bare", "-q"]);
652 g(&repo, &["init", "-q", "-b", "main"]);
653 std::fs::write(
654 repo.join("Cargo.toml"),
655 "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
656 )
657 .unwrap();
658 g(&repo, &["add", "."]);
659 g(&repo, &["commit", "-q", "-m", "init"]);
660 g(
661 &repo,
662 &["remote", "add", "origin", remote.to_str().unwrap()],
663 );
664 g(&repo, &["push", "-q", "origin", "main"]);
665 let commit = g(&repo, &["rev-parse", "HEAD"]);
666 Fixture {
667 _dir: dir,
668 repo,
669 remote,
670 home,
671 commit,
672 }
673 }
674
675 fn release(commands: &[&str]) -> Release {
676 Release {
677 mode: crate::config::ReleaseMode::Local,
678 commands: commands.iter().map(|s| (*s).to_owned()).collect(),
679 timeout_minutes: 1,
680 }
681 }
682
683 async fn go(f: &Fixture, rel: &Release, job: &mut Job) -> Result<()> {
684 let shell = vec!["sh".to_owned(), "-c".to_owned()];
685 let env = Env {
686 repo: &f.repo,
687 home: &f.home,
688 key: "o/r#1",
689 remote: "origin",
690 shell: &shell,
691 release: rel,
692 };
693 run_job(&env, job, &mut |_: &Job| true).await
694 }
695
696 #[tokio::test]
697 async fn a_release_tags_once_runs_every_command_and_keeps_the_output() {
698 let f = fixture();
699 let mut job = Job::new("1.0.0", "https://github.com/o/r/pull/1", &f.commit);
700 let rel = release(&["echo tag=$MAGI_RELEASE_TAG", "true"]);
701 go(&f, &rel, &mut job).await.unwrap();
702 assert!(job.finished && job.tag_done);
703 assert_eq!(job.done, 2);
704 assert!(job.log[1].tail.contains("tag=v1.0.0"), "{:?}", job.log);
705 assert_eq!(
706 g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
707 f.commit
708 );
709 let out = job_dir(&f.home, "o/r#1").join("release-1.out");
710 assert!(std::fs::read_to_string(out).unwrap().contains("tag=v1.0.0"));
711 }
712
713 #[tokio::test]
714 async fn the_first_failure_stops_the_list_and_a_resume_skips_what_succeeded() {
715 let f = fixture();
716 let mut job = Job::new("1.0.0", "u", &f.commit);
717 let rel = release(&["true", "exit 3", "echo never"]);
718 let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
719 assert!(err.contains("exited 3"), "{err}");
720 assert_eq!(job.done, 1);
721 assert!(job.tag_done && !job.finished);
722 assert!(!job.log.iter().any(|l| l.tail.contains("never")));
723
724 job.resume();
727 let rel = release(&["true", "true", "echo now"]);
728 let tags_before = g(&f.remote, &["tag", "-l"]);
729 go(&f, &rel, &mut job).await.unwrap();
730 assert!(job.finished);
731 assert_eq!(job.done, 3);
732 assert_eq!(tags_before, g(&f.remote, &["tag", "-l"]));
733 assert_eq!(job.log.iter().filter(|l| l.name == "true").count(), 2);
734 }
735
736 #[tokio::test]
737 async fn a_failure_is_never_saved_as_a_plain_unmarked_job_and_the_checkout_survives() {
738 let f = fixture();
739 let mut job = Job::new("1.0.0", "u", &f.commit);
740 let rel = release(&["touch built.txt", "exit 1"]);
741 let shell = vec!["sh".to_owned(), "-c".to_owned()];
742 let env = Env {
743 repo: &f.repo,
744 home: &f.home,
745 key: "o/r#1",
746 remote: "origin",
747 shell: &shell,
748 release: &rel,
749 };
750 let mut seen: Vec<Job> = Vec::new();
751 run_job(&env, &mut job, &mut |j: &Job| {
752 seen.push(j.clone());
753 true
754 })
755 .await
756 .unwrap_err();
757 assert!(
759 seen.iter()
760 .all(|j| j.running.is_some() || j.failed.is_some() || j.done > 0 || j.tag_done)
761 );
762 assert!(seen.last().unwrap().failed.is_some());
763 let wt = job_dir(&f.home, "o/r#1").join("wt");
765 assert!(wt.join("built.txt").exists());
766 job.resume();
767 let rel = release(&["touch built.txt", "test -f built.txt"]);
768 go(&f, &rel, &mut job).await.unwrap();
769 assert!(job.finished);
770 assert!(!wt.exists(), "a finished release removes its checkout");
771 }
772
773 #[tokio::test]
774 async fn an_existing_tag_elsewhere_is_never_pushed_over_and_runs_nothing() {
775 let f = fixture();
776 std::fs::write(f.repo.join("x"), "x").unwrap();
778 g(&f.repo, &["add", "."]);
779 g(&f.repo, &["commit", "-q", "-m", "other"]);
780 let other = g(&f.repo, &["rev-parse", "HEAD"]);
781 g(&f.repo, &["tag", "v1.0.0", &other]);
782 g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
783
784 let mut job = Job::new("1.0.0", "u", &f.commit);
785 let rel = release(&["echo ran > ran.txt"]);
786 let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
787 assert!(err.contains("already exists"), "{err}");
788 assert!(!job.tag_done && job.done == 0);
789 assert_eq!(
790 g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
791 other
792 );
793 }
794
795 #[tokio::test]
796 async fn a_remote_tag_at_the_merge_commit_is_not_pushed_again() {
797 let f = fixture();
798 g(&f.repo, &["tag", "v1.0.0", &f.commit]);
799 g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
800 let mut job = Job::new("1.0.0", "u", &f.commit);
801 go(&f, &release(&["true"]), &mut job).await.unwrap();
802 assert!(job.finished);
803 assert!(job.log[0].tail.contains("already on the remote"));
804 }
805
806 #[tokio::test]
807 async fn a_version_that_disagrees_with_the_merge_commit_is_not_tagged() {
808 let f = fixture();
809 let mut job = Job::new("2.0.0", "u", &f.commit);
810 let err = go(&f, &release(&[]), &mut job)
811 .await
812 .unwrap_err()
813 .to_string();
814 assert!(err.contains("not tagging"), "{err}");
815 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
816 }
817
818 #[test]
819 fn the_title_names_the_escalated_version() {
820 assert_eq!(
821 version_from_title("chore: release v0.2.0 (minor bump)").as_deref(),
822 Some("0.2.0")
823 );
824 assert_eq!(version_from_title("feat: x"), None);
825 }
826
827 #[test]
828 fn only_a_version_the_pull_request_named_replaces_the_job_version() {
829 let mut job = Job::new("1.0.0", "u", "c");
830 job.accepted = vec!["2.0.0".to_owned()];
831 assert!(!reconcile_version("3.0.0", false, &mut job));
832 assert_eq!(job.version, "1.0.0");
833 job.tag_done = true;
834 assert!(!reconcile_version("2.0.0", false, &mut job));
835 job.tag_done = false;
836 assert!(reconcile_version("2.0.0", false, &mut job));
837 assert_eq!(job.version, "2.0.0");
838 }
839
840 #[tokio::test]
841 async fn an_escalated_version_named_by_the_title_is_tagged() {
842 let f = fixture();
843 let mut job = Job::new("0.9.0", "u", &f.commit);
844 job.accepted = vec!["1.0.0".to_owned()];
845 go(&f, &release(&[]), &mut job).await.unwrap();
846 assert_eq!(job.version, "1.0.0");
847 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.0.0");
848 }
849
850 #[tokio::test]
851 async fn a_manifest_matching_no_candidate_is_not_tagged() {
852 let f = fixture();
853 let mut job = Job::new("0.9.0", "u", &f.commit);
854 job.accepted = vec!["0.8.0".to_owned()];
855 let err = go(&f, &release(&[]), &mut job)
856 .await
857 .unwrap_err()
858 .to_string();
859 assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
860 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
861 }
862
863 #[tokio::test]
864 async fn a_merge_that_bumped_the_manifest_recovers_a_stale_branch_version() {
865 let f = fixture();
866 std::fs::write(
867 f.repo.join("Cargo.toml"),
868 "[package]\nname = \"x\"\nversion = \"1.1.0\"\n",
869 )
870 .unwrap();
871 g(&f.repo, &["commit", "-q", "-am", "bump"]);
872 g(&f.repo, &["push", "-q", "origin", "main"]);
873 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
874 let mut job = Job::new("1.0.1", "u", &commit);
876 job.failed = Some(format!(
877 "{VERSION_MISMATCH_PREFIX}1.1.0, but the release is 1.0.1; not tagging"
878 ));
879 job.resume();
880 go(&f, &release(&[]), &mut job).await.unwrap();
881 assert_eq!(job.version, "1.1.0");
882 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.1.0");
883 }
884
885 #[tokio::test]
886 async fn a_commit_that_left_the_version_alone_is_not_tagged_under_a_stale_one() {
887 let f = fixture();
888 std::fs::write(f.repo.join("a.txt"), "x").unwrap();
889 g(&f.repo, &["add", "."]);
890 g(&f.repo, &["commit", "-q", "-m", "other"]);
891 g(&f.repo, &["push", "-q", "origin", "main"]);
892 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
893 let mut job = Job::new("0.9.0", "u", &commit);
894 let err = go(&f, &release(&[]), &mut job)
895 .await
896 .unwrap_err()
897 .to_string();
898 assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
899 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
900 }
901
902 #[tokio::test]
903 async fn a_shallow_boundary_merge_commit_still_recovers_a_stale_branch_version() {
904 let mut f = fixture();
905 std::fs::write(
906 f.repo.join("Cargo.toml"),
907 "[package]\nname = \"x\"\nversion = \"1.1.0\"\n",
908 )
909 .unwrap();
910 g(&f.repo, &["commit", "-q", "-am", "bump"]);
911 g(&f.repo, &["push", "-q", "origin", "main"]);
912 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
913 let shallow = f.repo.parent().unwrap().join("shallow-clone");
914 let url = format!("file://{}", f.remote.display());
915 g(
916 &f.repo,
917 &[
918 "clone",
919 "-q",
920 "--branch",
921 "main",
922 "--depth",
923 "1",
924 &url,
925 shallow.to_str().unwrap(),
926 ],
927 );
928 assert!(shallow.join(".git/shallow").exists());
929 let probe = std::process::Command::new("git")
930 .current_dir(&shallow)
931 .quiet()
932 .args(["rev-parse", "--verify", "--quiet", &format!("{commit}^1")])
933 .output()
934 .unwrap();
935 assert!(!probe.status.success(), "the parent must be hidden");
936 f.repo = shallow;
937 let mut job = Job::new("1.0.1", "u", &commit);
938 job.failed = Some(format!(
939 "{VERSION_MISMATCH_PREFIX}1.1.0, but the release is 1.0.1; not tagging"
940 ));
941 job.resume();
942 go(&f, &release(&[]), &mut job).await.unwrap();
943 assert_eq!(job.version, "1.1.0");
944 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.1.0");
945 }
946}