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 {
158 let parent = format!("{commit}^1");
159 let shown = git::git_raw(wt, &["show", &format!("{parent}:Cargo.toml")]).await;
160 if let Ok(out) = &shown
161 && out.code == Some(0)
162 && let Ok(v) = crate::bump::current_version(&out.stdout)
163 {
164 return v != version;
165 }
166 git::git_raw(wt, &["rev-parse", "--verify", "--quiet", &parent])
167 .await
168 .is_ok_and(|o| o.code == Some(0))
169}
170
171pub fn version_from_branch(branch: &str) -> Option<String> {
173 let v = branch.strip_prefix(BRANCH_PREFIX)?;
174 (!v.is_empty()).then(|| v.to_owned())
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum TagStep {
180 Create,
182 PushExisting,
184 Present,
186 Foreign(String),
188 Unreadable(String),
190}
191
192pub fn tag_step(
196 tag: &str,
197 commit: &str,
198 remote: std::result::Result<Option<&str>, &str>,
199 local: Option<&str>,
200) -> TagStep {
201 match remote {
202 Err(e) => TagStep::Unreadable(format!("could not read the remote's {tag}: {e}")),
203 Ok(Some(r)) if r == commit => TagStep::Present,
204 Ok(Some(r)) => TagStep::Foreign(format!(
205 "{tag} already exists on the remote at {r}, not at the merge commit {commit}"
206 )),
207 Ok(None) => match local {
208 Some(l) if l == commit => TagStep::PushExisting,
209 Some(l) => TagStep::Foreign(format!(
210 "{tag} already exists locally at {l}, not at the merge commit {commit}"
211 )),
212 None => TagStep::Create,
213 },
214 }
215}
216
217pub fn parse_ls_remote(out: &str, tag: &str) -> Option<String> {
221 let direct = format!("refs/tags/{tag}");
222 let peeled = format!("{direct}^{{}}");
223 let mut found = None;
224 for line in out.lines() {
225 let mut it = line.split_whitespace();
226 let (Some(oid), Some(name)) = (it.next(), it.next()) else {
227 continue;
228 };
229 if name == peeled {
230 return Some(oid.to_owned());
231 }
232 if name == direct {
233 found = Some(oid.to_owned());
234 }
235 }
236 found
237}
238
239pub fn tail(s: &str, max: usize) -> String {
241 let s = s.trim_end();
242 if s.len() <= max {
243 return s.to_owned();
244 }
245 let mut cut = s.len() - max;
246 while !s.is_char_boundary(cut) {
247 cut += 1;
248 }
249 format!("...{}", &s[cut..])
250}
251
252pub fn job_dir(home: &Path, key: &str) -> PathBuf {
254 home.join("release-local").join(crate::notices::id_of(key))
255}
256
257fn env_of(job: &Job) -> Vec<(&'static str, String)> {
258 vec![
259 ("MAGI_RELEASE_VERSION", job.version.clone()),
260 ("MAGI_RELEASE_TAG", job.tag()),
261 ("MAGI_RELEASE_COMMIT", job.commit.clone()),
262 ("MAGI_RELEASE_PR", job.pr_url.clone()),
263 ]
264}
265
266pub type Save<'a> = &'a mut (dyn FnMut(&Job) -> bool + Send);
268
269pub struct Env<'a> {
271 pub repo: &'a Path,
273 pub home: &'a Path,
275 pub key: &'a str,
277 pub remote: &'a str,
279 pub shell: &'a [String],
281 pub release: &'a Release,
283}
284
285pub async fn run_job(env: &Env<'_>, job: &mut Job, save: Save<'_>) -> Result<()> {
288 let dir = job_dir(env.home, env.key);
289 let wt = dir.join("wt");
290 let result = drive(env, &dir, &wt, job, save).await;
291 if job.finished {
296 let _ = git::worktree_remove(env.repo, &wt).await;
297 }
298 result
299}
300
301async fn drive(env: &Env<'_>, dir: &Path, wt: &Path, job: &mut Job, save: Save<'_>) -> Result<()> {
302 let (repo, remote) = (env.repo, env.remote);
303 if job.commit.is_empty() {
304 bail!("the merge commit of {} is unknown", job.pr_url);
305 }
306 let progressed = job.tag_done || job.done > 0;
309 let reusable = progressed
310 && wt.exists()
311 && git::rev_parse(wt, "HEAD").await.ok().as_deref() == Some(job.commit.as_str());
312 if !reusable {
313 if wt.exists() {
314 let _ = git::worktree_remove(repo, wt).await;
315 let _ = std::fs::remove_dir_all(wt);
316 }
317 let fetched = git::git_raw(repo, &["fetch", "--quiet", remote]).await?;
320 if !git::rev_exists(repo, &format!("{}^{{commit}}", job.commit)).await {
321 bail!(
322 "the merge commit {} is not available locally (git fetch {remote}: {})",
323 job.commit,
324 fetched.stderr
325 );
326 }
327 git::worktree_add_detached(repo, wt, &job.commit)
328 .await
329 .context("check out the merge commit")?;
330 let head = git::rev_parse(wt, "HEAD").await?;
331 if head != job.commit {
332 bail!(
333 "the release checkout is at {head}, not the merge commit {}",
334 job.commit
335 );
336 }
337 if !git::is_clean(wt).await? {
338 bail!("the release checkout is not clean");
339 }
340 }
341 if let Ok(toml) = std::fs::read_to_string(wt.join("Cargo.toml")) {
342 let before = job.version.clone();
343 match crate::bump::current_version(&toml) {
344 Ok(v) if v != job.version && !job.tag_done && job.done == 0 => {
345 let bumped = bumped_by(wt, &job.commit, &v).await;
346 if !reconcile_version(&v, bumped, job) {
347 bail!(
348 "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
349 job.version
350 );
351 }
352 if job.version != before && !save(job) {
354 bail!("could not record progress; nothing was run");
355 }
356 }
357 Ok(v) if v == job.version => {}
358 Ok(v) => bail!(
359 "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
360 job.version
361 ),
362 Err(e) => bail!("cannot read the version at the merge commit: {e:#}"),
363 }
364 }
365
366 if !job.tag_done {
367 job.running = Some("tag".to_owned());
368 if !save(job) {
369 bail!("could not record progress; nothing was run");
370 }
371 let note = tag(wt, remote, job).await?;
372 job.log.push(StepLog {
373 name: "tag".to_owned(),
374 code: Some(0),
375 tail: note,
376 output: None,
377 });
378 job.tag_done = true;
379 job.running = None;
380 if !save(job) {
381 bail!("the tag was pushed but progress could not be recorded");
382 }
383 }
384
385 let release = env.release;
386 let timeout = Duration::from_secs(release.timeout_minutes.max(1) * 60);
387 while job.done < release.commands.len() {
388 let n = job.done;
389 let command = &release.commands[n];
390 job.running = Some(format!("command {}", n + 1));
391 if !save(job) {
392 bail!("could not record progress; nothing was run");
393 }
394 let (code, output) = run_command(wt, env.shell, command, job, timeout).await;
395 let _ = std::fs::create_dir_all(dir);
396 let out_path = dir.join(format!("release-{}.out", n + 1));
397 let kept = std::fs::write(&out_path, &output).is_ok();
398 job.log.push(StepLog {
399 name: command.clone(),
400 code,
401 tail: tail(&output, TAIL),
402 output: kept.then(|| out_path.display().to_string()),
403 });
404 if code != Some(0) {
405 let why = match code {
406 Some(c) => format!("command {} `{command}` exited {c}", n + 1),
407 None => format!(
408 "command {} `{command}` did not finish (timeout is {} minute(s))",
409 n + 1,
410 release.timeout_minutes
411 ),
412 };
413 job.failed = Some(why.clone());
416 job.running = None;
417 let _ = save(job);
418 bail!("{why}");
419 }
420 job.running = None;
421 job.done += 1;
422 if !save(job) {
423 bail!(
424 "command {} succeeded but progress could not be recorded",
425 n + 1
426 );
427 }
428 }
429 job.finished = true;
430 let _ = save(job);
431 Ok(())
432}
433
434async fn tag(wt: &Path, remote: &str, job: &Job) -> Result<String> {
436 let name = job.tag();
437 let listed = git::git_raw(
438 wt,
439 &[
440 "ls-remote",
441 "--tags",
442 remote,
443 &format!("refs/tags/{name}"),
444 &format!("refs/tags/{name}^{{}}"),
445 ],
446 )
447 .await?;
448 let remote_tag = if listed.ok() {
449 Ok(parse_ls_remote(&listed.stdout, &name))
450 } else {
451 Err(listed.stderr.clone())
452 };
453 let local = git::git_raw(
454 wt,
455 &[
456 "rev-parse",
457 "-q",
458 "--verify",
459 &format!("refs/tags/{name}^{{commit}}"),
460 ],
461 )
462 .await?;
463 let local = local.ok().then(|| local.stdout.clone());
464 let step = tag_step(
465 &name,
466 &job.commit,
467 remote_tag
468 .as_ref()
469 .map(|o| o.as_deref())
470 .map_err(String::as_str),
471 local.as_deref(),
472 );
473 match step {
474 TagStep::Present => Ok(format!("{name} already on the remote at the merge commit")),
475 TagStep::Foreign(why) | TagStep::Unreadable(why) => bail!("{why}"),
476 TagStep::Create | TagStep::PushExisting => {
477 if step == TagStep::Create {
478 let has_ident = git::git_raw(wt, &["var", "GIT_COMMITTER_IDENT"])
482 .await?
483 .ok();
484 let mut args = vec![];
485 if !has_ident {
486 args.extend(["-c", "user.name=magi", "-c", "user.email=magi@localhost"]);
487 }
488 args.extend(["tag", "-a", name.as_str(), "-m", name.as_str(), &job.commit]);
489 let out = git::git_raw(wt, &args).await?;
490 if !out.ok() {
491 bail!("git tag {name}: {}", out.stderr);
492 }
493 }
494 let refspec = format!("refs/tags/{name}:refs/tags/{name}");
496 let out = git::git_raw(wt, &["push", remote, &refspec]).await?;
497 if !out.ok() {
498 bail!("git push {remote} {refspec}: {}", out.stderr);
499 }
500 Ok(format!("pushed {name} at {}", job.commit))
501 }
502 }
503}
504
505async fn run_command(
507 wt: &Path,
508 shell: &[String],
509 command: &str,
510 job: &Job,
511 timeout: Duration,
512) -> (Option<i32>, String) {
513 let Some((prog, args)) = shell.split_first() else {
514 return (None, "no shell is configured".to_owned());
515 };
516 let mut cmd = tokio::process::Command::new(prog);
517 cmd.args(args)
518 .arg(command)
519 .current_dir(wt)
520 .kill_on_drop(true)
521 .stdin(std::process::Stdio::null());
522 for (k, v) in env_of(job) {
523 cmd.env(k, v);
524 }
525 cmd.quiet();
526 match tokio::time::timeout(timeout, cmd.output()).await {
527 Err(_) => (None, "timed out".to_owned()),
528 Ok(Err(e)) => (None, format!("could not run: {e}")),
529 Ok(Ok(out)) => {
530 let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
531 text.push_str(&String::from_utf8_lossy(&out.stderr));
532 (out.status.code(), text)
533 }
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 const C: &str = "aaaa";
542
543 #[test]
544 fn the_tag_decision_never_touches_an_existing_tag() {
545 let t = "v1.0.0";
546 assert_eq!(tag_step(t, C, Ok(None), None), TagStep::Create);
547 assert_eq!(tag_step(t, C, Ok(None), Some(C)), TagStep::PushExisting);
548 assert_eq!(tag_step(t, C, Ok(Some(C)), None), TagStep::Present);
549 assert_eq!(tag_step(t, C, Ok(Some(C)), Some("bbbb")), TagStep::Present);
550 assert!(matches!(
551 tag_step(t, C, Ok(Some("bbbb")), Some(C)),
552 TagStep::Foreign(_)
553 ));
554 assert!(matches!(
555 tag_step(t, C, Ok(None), Some("bbbb")),
556 TagStep::Foreign(_)
557 ));
558 assert!(matches!(
560 tag_step(t, C, Err("boom"), None),
561 TagStep::Unreadable(_)
562 ));
563 }
564
565 #[test]
566 fn ls_remote_prefers_the_peeled_commit() {
567 let out = "1111\trefs/tags/v1.0.0\n2222\trefs/tags/v1.0.0^{}\n3333\trefs/tags/v1.0.0-rc\n";
568 assert_eq!(parse_ls_remote(out, "v1.0.0").as_deref(), Some("2222"));
569 assert_eq!(
570 parse_ls_remote("1111\trefs/tags/v1.0.0\n", "v1.0.0").as_deref(),
571 Some("1111")
572 );
573 assert_eq!(parse_ls_remote("", "v1.0.0"), None);
574 }
575
576 #[test]
577 fn version_comes_from_the_release_branch() {
578 assert_eq!(
579 version_from_branch("chore/release-v1.2.3").as_deref(),
580 Some("1.2.3")
581 );
582 assert_eq!(version_from_branch("chore/release-v"), None);
583 assert_eq!(version_from_branch("feat/x"), None);
584 }
585
586 #[test]
587 fn a_started_and_unfinished_step_is_interrupted_not_retried() {
588 let mut j = Job::new("1.0.0", "u", C);
589 assert!(!j.interrupted());
590 j.running = Some("command 1".to_owned());
591 assert!(j.interrupted());
592 j.failed = Some("x".to_owned());
593 assert!(!j.interrupted());
594 j.resume();
595 assert!(!j.interrupted() && j.failed.is_none());
596 }
597
598 #[test]
599 fn tail_keeps_the_end_on_a_char_boundary() {
600 assert_eq!(tail("abc", 10), "abc");
601 assert_eq!(tail("abcdef", 3), "...def");
602 assert_eq!(tail("aéb", 2), "...b");
604 }
605
606 fn g(dir: &Path, args: &[&str]) -> String {
608 let out = std::process::Command::new("git")
609 .args(args)
610 .current_dir(dir)
611 .quiet()
612 .env("GIT_AUTHOR_NAME", "t")
613 .env("GIT_AUTHOR_EMAIL", "t@t")
614 .env("GIT_COMMITTER_NAME", "t")
615 .env("GIT_COMMITTER_EMAIL", "t@t")
616 .output()
617 .unwrap();
618 assert!(
619 out.status.success(),
620 "git {args:?}: {}",
621 String::from_utf8_lossy(&out.stderr)
622 );
623 String::from_utf8_lossy(&out.stdout).trim().to_owned()
624 }
625
626 struct Fixture {
627 _dir: tempfile::TempDir,
628 repo: PathBuf,
629 remote: PathBuf,
630 home: PathBuf,
631 commit: String,
632 }
633
634 fn fixture() -> Fixture {
635 let dir = tempfile::tempdir().unwrap();
636 let remote = dir.path().join("remote.git");
637 let repo = dir.path().join("repo");
638 let home = dir.path().join("home");
639 std::fs::create_dir_all(&remote).unwrap();
640 std::fs::create_dir_all(&repo).unwrap();
641 g(&remote, &["init", "--bare", "-q"]);
642 g(&repo, &["init", "-q", "-b", "main"]);
643 std::fs::write(
644 repo.join("Cargo.toml"),
645 "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
646 )
647 .unwrap();
648 g(&repo, &["add", "."]);
649 g(&repo, &["commit", "-q", "-m", "init"]);
650 g(
651 &repo,
652 &["remote", "add", "origin", remote.to_str().unwrap()],
653 );
654 g(&repo, &["push", "-q", "origin", "main"]);
655 let commit = g(&repo, &["rev-parse", "HEAD"]);
656 Fixture {
657 _dir: dir,
658 repo,
659 remote,
660 home,
661 commit,
662 }
663 }
664
665 fn release(commands: &[&str]) -> Release {
666 Release {
667 mode: crate::config::ReleaseMode::Local,
668 commands: commands.iter().map(|s| (*s).to_owned()).collect(),
669 timeout_minutes: 1,
670 }
671 }
672
673 async fn go(f: &Fixture, rel: &Release, job: &mut Job) -> Result<()> {
674 let shell = vec!["sh".to_owned(), "-c".to_owned()];
675 let env = Env {
676 repo: &f.repo,
677 home: &f.home,
678 key: "o/r#1",
679 remote: "origin",
680 shell: &shell,
681 release: rel,
682 };
683 run_job(&env, job, &mut |_: &Job| true).await
684 }
685
686 #[tokio::test]
687 async fn a_release_tags_once_runs_every_command_and_keeps_the_output() {
688 let f = fixture();
689 let mut job = Job::new("1.0.0", "https://github.com/o/r/pull/1", &f.commit);
690 let rel = release(&["echo tag=$MAGI_RELEASE_TAG", "true"]);
691 go(&f, &rel, &mut job).await.unwrap();
692 assert!(job.finished && job.tag_done);
693 assert_eq!(job.done, 2);
694 assert!(job.log[1].tail.contains("tag=v1.0.0"), "{:?}", job.log);
695 assert_eq!(
696 g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
697 f.commit
698 );
699 let out = job_dir(&f.home, "o/r#1").join("release-1.out");
700 assert!(std::fs::read_to_string(out).unwrap().contains("tag=v1.0.0"));
701 }
702
703 #[tokio::test]
704 async fn the_first_failure_stops_the_list_and_a_resume_skips_what_succeeded() {
705 let f = fixture();
706 let mut job = Job::new("1.0.0", "u", &f.commit);
707 let rel = release(&["true", "exit 3", "echo never"]);
708 let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
709 assert!(err.contains("exited 3"), "{err}");
710 assert_eq!(job.done, 1);
711 assert!(job.tag_done && !job.finished);
712 assert!(!job.log.iter().any(|l| l.tail.contains("never")));
713
714 job.resume();
717 let rel = release(&["true", "true", "echo now"]);
718 let tags_before = g(&f.remote, &["tag", "-l"]);
719 go(&f, &rel, &mut job).await.unwrap();
720 assert!(job.finished);
721 assert_eq!(job.done, 3);
722 assert_eq!(tags_before, g(&f.remote, &["tag", "-l"]));
723 assert_eq!(job.log.iter().filter(|l| l.name == "true").count(), 2);
724 }
725
726 #[tokio::test]
727 async fn a_failure_is_never_saved_as_a_plain_unmarked_job_and_the_checkout_survives() {
728 let f = fixture();
729 let mut job = Job::new("1.0.0", "u", &f.commit);
730 let rel = release(&["touch built.txt", "exit 1"]);
731 let shell = vec!["sh".to_owned(), "-c".to_owned()];
732 let env = Env {
733 repo: &f.repo,
734 home: &f.home,
735 key: "o/r#1",
736 remote: "origin",
737 shell: &shell,
738 release: &rel,
739 };
740 let mut seen: Vec<Job> = Vec::new();
741 run_job(&env, &mut job, &mut |j: &Job| {
742 seen.push(j.clone());
743 true
744 })
745 .await
746 .unwrap_err();
747 assert!(
749 seen.iter()
750 .all(|j| j.running.is_some() || j.failed.is_some() || j.done > 0 || j.tag_done)
751 );
752 assert!(seen.last().unwrap().failed.is_some());
753 let wt = job_dir(&f.home, "o/r#1").join("wt");
755 assert!(wt.join("built.txt").exists());
756 job.resume();
757 let rel = release(&["touch built.txt", "test -f built.txt"]);
758 go(&f, &rel, &mut job).await.unwrap();
759 assert!(job.finished);
760 assert!(!wt.exists(), "a finished release removes its checkout");
761 }
762
763 #[tokio::test]
764 async fn an_existing_tag_elsewhere_is_never_pushed_over_and_runs_nothing() {
765 let f = fixture();
766 std::fs::write(f.repo.join("x"), "x").unwrap();
768 g(&f.repo, &["add", "."]);
769 g(&f.repo, &["commit", "-q", "-m", "other"]);
770 let other = g(&f.repo, &["rev-parse", "HEAD"]);
771 g(&f.repo, &["tag", "v1.0.0", &other]);
772 g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
773
774 let mut job = Job::new("1.0.0", "u", &f.commit);
775 let rel = release(&["echo ran > ran.txt"]);
776 let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
777 assert!(err.contains("already exists"), "{err}");
778 assert!(!job.tag_done && job.done == 0);
779 assert_eq!(
780 g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
781 other
782 );
783 }
784
785 #[tokio::test]
786 async fn a_remote_tag_at_the_merge_commit_is_not_pushed_again() {
787 let f = fixture();
788 g(&f.repo, &["tag", "v1.0.0", &f.commit]);
789 g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
790 let mut job = Job::new("1.0.0", "u", &f.commit);
791 go(&f, &release(&["true"]), &mut job).await.unwrap();
792 assert!(job.finished);
793 assert!(job.log[0].tail.contains("already on the remote"));
794 }
795
796 #[tokio::test]
797 async fn a_version_that_disagrees_with_the_merge_commit_is_not_tagged() {
798 let f = fixture();
799 let mut job = Job::new("2.0.0", "u", &f.commit);
800 let err = go(&f, &release(&[]), &mut job)
801 .await
802 .unwrap_err()
803 .to_string();
804 assert!(err.contains("not tagging"), "{err}");
805 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
806 }
807
808 #[test]
809 fn the_title_names_the_escalated_version() {
810 assert_eq!(
811 version_from_title("chore: release v0.2.0 (minor bump)").as_deref(),
812 Some("0.2.0")
813 );
814 assert_eq!(version_from_title("feat: x"), None);
815 }
816
817 #[test]
818 fn only_a_version_the_pull_request_named_replaces_the_job_version() {
819 let mut job = Job::new("1.0.0", "u", "c");
820 job.accepted = vec!["2.0.0".to_owned()];
821 assert!(!reconcile_version("3.0.0", false, &mut job));
822 assert_eq!(job.version, "1.0.0");
823 job.tag_done = true;
824 assert!(!reconcile_version("2.0.0", false, &mut job));
825 job.tag_done = false;
826 assert!(reconcile_version("2.0.0", false, &mut job));
827 assert_eq!(job.version, "2.0.0");
828 }
829
830 #[tokio::test]
831 async fn an_escalated_version_named_by_the_title_is_tagged() {
832 let f = fixture();
833 let mut job = Job::new("0.9.0", "u", &f.commit);
834 job.accepted = vec!["1.0.0".to_owned()];
835 go(&f, &release(&[]), &mut job).await.unwrap();
836 assert_eq!(job.version, "1.0.0");
837 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.0.0");
838 }
839
840 #[tokio::test]
841 async fn a_manifest_matching_no_candidate_is_not_tagged() {
842 let f = fixture();
843 let mut job = Job::new("0.9.0", "u", &f.commit);
844 job.accepted = vec!["0.8.0".to_owned()];
845 let err = go(&f, &release(&[]), &mut job)
846 .await
847 .unwrap_err()
848 .to_string();
849 assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
850 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
851 }
852
853 #[tokio::test]
854 async fn a_merge_that_bumped_the_manifest_recovers_a_stale_branch_version() {
855 let f = fixture();
856 std::fs::write(
857 f.repo.join("Cargo.toml"),
858 "[package]\nname = \"x\"\nversion = \"1.1.0\"\n",
859 )
860 .unwrap();
861 g(&f.repo, &["commit", "-q", "-am", "bump"]);
862 g(&f.repo, &["push", "-q", "origin", "main"]);
863 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
864 let mut job = Job::new("1.0.1", "u", &commit);
866 job.failed = Some(format!(
867 "{VERSION_MISMATCH_PREFIX}1.1.0, but the release is 1.0.1; not tagging"
868 ));
869 job.resume();
870 go(&f, &release(&[]), &mut job).await.unwrap();
871 assert_eq!(job.version, "1.1.0");
872 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.1.0");
873 }
874
875 #[tokio::test]
876 async fn a_commit_that_left_the_version_alone_is_not_tagged_under_a_stale_one() {
877 let f = fixture();
878 std::fs::write(f.repo.join("a.txt"), "x").unwrap();
879 g(&f.repo, &["add", "."]);
880 g(&f.repo, &["commit", "-q", "-m", "other"]);
881 g(&f.repo, &["push", "-q", "origin", "main"]);
882 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
883 let mut job = Job::new("0.9.0", "u", &commit);
884 let err = go(&f, &release(&[]), &mut job)
885 .await
886 .unwrap_err()
887 .to_string();
888 assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
889 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
890 }
891}