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 remove_release_worktree(env.repo, &wt).await?;
307 }
308 result
309}
310
311async fn remove_release_worktree(repo: &Path, wt: &Path) -> Result<()> {
315 if !wt.exists() {
316 return Ok(());
317 }
318 if !std::fs::symlink_metadata(wt)?.is_dir() {
319 bail!("release checkout is not a directory: {}", wt.display());
320 }
321 if git::worktree_remove(repo, wt)
322 .await
323 .context("remove release checkout")?
324 {
325 return Ok(());
326 }
327 let listing = git::git(repo, &["worktree", "list", "--porcelain", "-z"])
330 .await
331 .context("check release checkout registration")?;
332 let target = std::fs::canonicalize(wt)?;
333 for entry in listing.split('\0') {
334 if let Some(path) = entry.strip_prefix("worktree ") {
335 let registered = Path::new(path);
336 if registered == wt || std::fs::canonicalize(registered)? == target {
337 bail!("release checkout removal refused: {}", wt.display());
338 }
339 }
340 }
341 if wt.join(".git").exists() && git::git_raw(wt, &["rev-parse", "--git-dir"]).await?.ok() {
342 bail!(
343 "release checkout contains an unregistered repository: {}",
344 wt.display()
345 );
346 }
347 std::fs::remove_dir_all(wt).context("remove unregistered release checkout")?;
348 Ok(())
349}
350
351async fn drive(env: &Env<'_>, dir: &Path, wt: &Path, job: &mut Job, save: Save<'_>) -> Result<()> {
352 let (repo, remote) = (env.repo, env.remote);
353 if job.commit.is_empty() {
354 bail!("the merge commit of {} is unknown", job.pr_url);
355 }
356 let progressed = job.tag_done || job.done > 0;
359 let reusable = progressed
360 && wt.exists()
361 && git::rev_parse(wt, "HEAD").await.ok().as_deref() == Some(job.commit.as_str());
362 if !reusable {
363 if wt.exists() {
364 remove_release_worktree(repo, wt).await?;
365 }
366 let fetched = git::git_raw(repo, &["fetch", "--quiet", remote]).await?;
369 if !git::rev_exists(repo, &format!("{}^{{commit}}", job.commit)).await {
370 bail!(
371 "the merge commit {} is not available locally (git fetch {remote}: {})",
372 job.commit,
373 fetched.stderr
374 );
375 }
376 git::worktree_add_detached(repo, wt, &job.commit)
377 .await
378 .context("check out the merge commit")?;
379 let head = git::rev_parse(wt, "HEAD").await?;
380 if head != job.commit {
381 bail!(
382 "the release checkout is at {head}, not the merge commit {}",
383 job.commit
384 );
385 }
386 if !git::is_clean(wt).await? {
387 bail!("the release checkout is not clean");
388 }
389 }
390 if let Ok(toml) = std::fs::read_to_string(wt.join("Cargo.toml")) {
391 let before = job.version.clone();
392 match crate::bump::current_version(&toml) {
393 Ok(v) if v != job.version && !job.tag_done && job.done == 0 => {
394 let bumped = bumped_by(wt, &job.commit, &v).await;
395 if !reconcile_version(&v, bumped, job) {
396 bail!(
397 "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
398 job.version
399 );
400 }
401 if job.version != before && !save(job) {
403 bail!("could not record progress; nothing was run");
404 }
405 }
406 Ok(v) if v == job.version => {}
407 Ok(v) => bail!(
408 "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
409 job.version
410 ),
411 Err(e) => bail!("cannot read the version at the merge commit: {e:#}"),
412 }
413 }
414
415 if !job.tag_done {
416 job.running = Some("tag".to_owned());
417 if !save(job) {
418 bail!("could not record progress; nothing was run");
419 }
420 let note = tag(wt, remote, job).await?;
421 job.log.push(StepLog {
422 name: "tag".to_owned(),
423 code: Some(0),
424 tail: note,
425 output: None,
426 });
427 job.tag_done = true;
428 job.running = None;
429 if !save(job) {
430 bail!("the tag was pushed but progress could not be recorded");
431 }
432 }
433
434 let release = env.release;
435 let timeout = Duration::from_secs(release.timeout_minutes.max(1) * 60);
436 while job.done < release.commands.len() {
437 let n = job.done;
438 let command = &release.commands[n];
439 job.running = Some(format!("command {}", n + 1));
440 if !save(job) {
441 bail!("could not record progress; nothing was run");
442 }
443 let (code, output) = run_command(wt, env.shell, command, job, timeout).await;
444 let _ = std::fs::create_dir_all(dir);
445 let out_path = dir.join(format!("release-{}.out", n + 1));
446 let kept = std::fs::write(&out_path, &output).is_ok();
447 job.log.push(StepLog {
448 name: command.clone(),
449 code,
450 tail: tail(&output, TAIL),
451 output: kept.then(|| out_path.display().to_string()),
452 });
453 if code != Some(0) {
454 let why = match code {
455 Some(c) => format!("command {} `{command}` exited {c}", n + 1),
456 None => format!(
457 "command {} `{command}` did not finish (timeout is {} minute(s))",
458 n + 1,
459 release.timeout_minutes
460 ),
461 };
462 job.failed = Some(why.clone());
465 job.running = None;
466 let _ = save(job);
467 bail!("{why}");
468 }
469 job.running = None;
470 job.done += 1;
471 if !save(job) {
472 bail!(
473 "command {} succeeded but progress could not be recorded",
474 n + 1
475 );
476 }
477 }
478 job.finished = true;
479 let _ = save(job);
480 Ok(())
481}
482
483async fn tag(wt: &Path, remote: &str, job: &Job) -> Result<String> {
485 let name = job.tag();
486 let listed = git::git_raw(
487 wt,
488 &[
489 "ls-remote",
490 "--tags",
491 remote,
492 &format!("refs/tags/{name}"),
493 &format!("refs/tags/{name}^{{}}"),
494 ],
495 )
496 .await?;
497 let remote_tag = if listed.ok() {
498 Ok(parse_ls_remote(&listed.stdout, &name))
499 } else {
500 Err(listed.stderr.clone())
501 };
502 let local = git::git_raw(
503 wt,
504 &[
505 "rev-parse",
506 "-q",
507 "--verify",
508 &format!("refs/tags/{name}^{{commit}}"),
509 ],
510 )
511 .await?;
512 let local = local.ok().then(|| local.stdout.clone());
513 let step = tag_step(
514 &name,
515 &job.commit,
516 remote_tag
517 .as_ref()
518 .map(|o| o.as_deref())
519 .map_err(String::as_str),
520 local.as_deref(),
521 );
522 match step {
523 TagStep::Present => Ok(format!("{name} already on the remote at the merge commit")),
524 TagStep::Foreign(why) | TagStep::Unreadable(why) => bail!("{why}"),
525 TagStep::Create | TagStep::PushExisting => {
526 if step == TagStep::Create {
527 let has_ident = git::git_raw(wt, &["var", "GIT_COMMITTER_IDENT"])
531 .await?
532 .ok();
533 let mut args = vec![];
534 if !has_ident {
535 args.extend(["-c", "user.name=magi", "-c", "user.email=magi@localhost"]);
536 }
537 args.extend(["tag", "-a", name.as_str(), "-m", name.as_str(), &job.commit]);
538 let out = git::git_raw(wt, &args).await?;
539 if !out.ok() {
540 bail!("git tag {name}: {}", out.stderr);
541 }
542 }
543 let refspec = format!("refs/tags/{name}:refs/tags/{name}");
545 let out = git::git_raw(wt, &["push", remote, &refspec]).await?;
546 if !out.ok() {
547 bail!("git push {remote} {refspec}: {}", out.stderr);
548 }
549 Ok(format!("pushed {name} at {}", job.commit))
550 }
551 }
552}
553
554async fn run_command(
556 wt: &Path,
557 shell: &[String],
558 command: &str,
559 job: &Job,
560 timeout: Duration,
561) -> (Option<i32>, String) {
562 let Some((prog, args)) = shell.split_first() else {
563 return (None, "no shell is configured".to_owned());
564 };
565 let mut cmd = tokio::process::Command::new(prog);
566 cmd.args(args)
567 .arg(command)
568 .current_dir(wt)
569 .kill_on_drop(true)
570 .stdin(std::process::Stdio::null());
571 for (k, v) in env_of(job) {
572 cmd.env(k, v);
573 }
574 cmd.quiet();
575 match tokio::time::timeout(timeout, cmd.output()).await {
576 Err(_) => (None, "timed out".to_owned()),
577 Ok(Err(e)) => (None, format!("could not run: {e}")),
578 Ok(Ok(out)) => {
579 let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
580 text.push_str(&String::from_utf8_lossy(&out.stderr));
581 (out.status.code(), text)
582 }
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 const C: &str = "aaaa";
591
592 #[test]
593 fn the_tag_decision_never_touches_an_existing_tag() {
594 let t = "v1.0.0";
595 assert_eq!(tag_step(t, C, Ok(None), None), TagStep::Create);
596 assert_eq!(tag_step(t, C, Ok(None), Some(C)), TagStep::PushExisting);
597 assert_eq!(tag_step(t, C, Ok(Some(C)), None), TagStep::Present);
598 assert_eq!(tag_step(t, C, Ok(Some(C)), Some("bbbb")), TagStep::Present);
599 assert!(matches!(
600 tag_step(t, C, Ok(Some("bbbb")), Some(C)),
601 TagStep::Foreign(_)
602 ));
603 assert!(matches!(
604 tag_step(t, C, Ok(None), Some("bbbb")),
605 TagStep::Foreign(_)
606 ));
607 assert!(matches!(
609 tag_step(t, C, Err("boom"), None),
610 TagStep::Unreadable(_)
611 ));
612 }
613
614 #[test]
615 fn ls_remote_prefers_the_peeled_commit() {
616 let out = "1111\trefs/tags/v1.0.0\n2222\trefs/tags/v1.0.0^{}\n3333\trefs/tags/v1.0.0-rc\n";
617 assert_eq!(parse_ls_remote(out, "v1.0.0").as_deref(), Some("2222"));
618 assert_eq!(
619 parse_ls_remote("1111\trefs/tags/v1.0.0\n", "v1.0.0").as_deref(),
620 Some("1111")
621 );
622 assert_eq!(parse_ls_remote("", "v1.0.0"), None);
623 }
624
625 #[test]
626 fn version_comes_from_the_release_branch() {
627 assert_eq!(
628 version_from_branch("chore/release-v1.2.3").as_deref(),
629 Some("1.2.3")
630 );
631 assert_eq!(version_from_branch("chore/release-v"), None);
632 assert_eq!(version_from_branch("feat/x"), None);
633 }
634
635 #[test]
636 fn a_started_and_unfinished_step_is_interrupted_not_retried() {
637 let mut j = Job::new("1.0.0", "u", C);
638 assert!(!j.interrupted());
639 j.running = Some("command 1".to_owned());
640 assert!(j.interrupted());
641 j.failed = Some("x".to_owned());
642 assert!(!j.interrupted());
643 j.resume();
644 assert!(!j.interrupted() && j.failed.is_none());
645 }
646
647 #[test]
648 fn tail_keeps_the_end_on_a_char_boundary() {
649 assert_eq!(tail("abc", 10), "abc");
650 assert_eq!(tail("abcdef", 3), "...def");
651 assert_eq!(tail("aéb", 2), "...b");
653 }
654
655 fn g(dir: &Path, args: &[&str]) -> String {
657 let out = std::process::Command::new("git")
658 .args(args)
659 .current_dir(dir)
660 .quiet()
661 .env("GIT_AUTHOR_NAME", "t")
662 .env("GIT_AUTHOR_EMAIL", "t@t")
663 .env("GIT_COMMITTER_NAME", "t")
664 .env("GIT_COMMITTER_EMAIL", "t@t")
665 .output()
666 .unwrap();
667 assert!(
668 out.status.success(),
669 "git {args:?}: {}",
670 String::from_utf8_lossy(&out.stderr)
671 );
672 String::from_utf8_lossy(&out.stdout).trim().to_owned()
673 }
674
675 struct Fixture {
676 _dir: tempfile::TempDir,
677 repo: PathBuf,
678 remote: PathBuf,
679 home: PathBuf,
680 commit: String,
681 }
682
683 fn fixture() -> Fixture {
684 let dir = tempfile::tempdir().unwrap();
685 let remote = dir.path().join("remote.git");
686 let repo = dir.path().join("repo");
687 let home = dir.path().join("home");
688 std::fs::create_dir_all(&remote).unwrap();
689 std::fs::create_dir_all(&repo).unwrap();
690 g(&remote, &["init", "--bare", "-q"]);
691 g(&repo, &["init", "-q", "-b", "main"]);
692 std::fs::write(
693 repo.join("Cargo.toml"),
694 "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
695 )
696 .unwrap();
697 g(&repo, &["add", "."]);
698 g(&repo, &["commit", "-q", "-m", "init"]);
699 g(
700 &repo,
701 &["remote", "add", "origin", remote.to_str().unwrap()],
702 );
703 g(&repo, &["push", "-q", "origin", "main"]);
704 let commit = g(&repo, &["rev-parse", "HEAD"]);
705 Fixture {
706 _dir: dir,
707 repo,
708 remote,
709 home,
710 commit,
711 }
712 }
713
714 fn release(commands: &[&str]) -> Release {
715 Release {
716 mode: crate::config::ReleaseMode::Local,
717 commands: commands.iter().map(|s| (*s).to_owned()).collect(),
718 timeout_minutes: 1,
719 }
720 }
721
722 async fn go(f: &Fixture, rel: &Release, job: &mut Job) -> Result<()> {
723 let shell = vec!["sh".to_owned(), "-c".to_owned()];
724 let env = Env {
725 repo: &f.repo,
726 home: &f.home,
727 key: "o/r#1",
728 remote: "origin",
729 shell: &shell,
730 release: rel,
731 };
732 run_job(&env, job, &mut |_: &Job| true).await
733 }
734
735 #[tokio::test]
736 async fn a_release_tags_once_runs_every_command_and_keeps_the_output() {
737 let f = fixture();
738 let mut job = Job::new("1.0.0", "https://github.com/o/r/pull/1", &f.commit);
739 let rel = release(&["echo tag=$MAGI_RELEASE_TAG", "true"]);
740 go(&f, &rel, &mut job).await.unwrap();
741 assert!(job.finished && job.tag_done);
742 assert_eq!(job.done, 2);
743 assert!(job.log[1].tail.contains("tag=v1.0.0"), "{:?}", job.log);
744 assert_eq!(
745 g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
746 f.commit
747 );
748 let out = job_dir(&f.home, "o/r#1").join("release-1.out");
749 assert!(std::fs::read_to_string(out).unwrap().contains("tag=v1.0.0"));
750 }
751
752 #[tokio::test]
753 async fn the_first_failure_stops_the_list_and_a_resume_skips_what_succeeded() {
754 let f = fixture();
755 let mut job = Job::new("1.0.0", "u", &f.commit);
756 let rel = release(&["true", "exit 3", "echo never"]);
757 let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
758 assert!(err.contains("exited 3"), "{err}");
759 assert_eq!(job.done, 1);
760 assert!(job.tag_done && !job.finished);
761 assert!(!job.log.iter().any(|l| l.tail.contains("never")));
762
763 job.resume();
766 let rel = release(&["true", "true", "echo now"]);
767 let tags_before = g(&f.remote, &["tag", "-l"]);
768 go(&f, &rel, &mut job).await.unwrap();
769 assert!(job.finished);
770 assert_eq!(job.done, 3);
771 assert_eq!(tags_before, g(&f.remote, &["tag", "-l"]));
772 assert_eq!(job.log.iter().filter(|l| l.name == "true").count(), 2);
773 }
774
775 #[tokio::test]
776 async fn a_failure_is_never_saved_as_a_plain_unmarked_job_and_the_checkout_survives() {
777 let f = fixture();
778 let mut job = Job::new("1.0.0", "u", &f.commit);
779 let rel = release(&["touch built.txt", "exit 1"]);
780 let shell = vec!["sh".to_owned(), "-c".to_owned()];
781 let env = Env {
782 repo: &f.repo,
783 home: &f.home,
784 key: "o/r#1",
785 remote: "origin",
786 shell: &shell,
787 release: &rel,
788 };
789 let mut seen: Vec<Job> = Vec::new();
790 run_job(&env, &mut job, &mut |j: &Job| {
791 seen.push(j.clone());
792 true
793 })
794 .await
795 .unwrap_err();
796 assert!(
798 seen.iter()
799 .all(|j| j.running.is_some() || j.failed.is_some() || j.done > 0 || j.tag_done)
800 );
801 assert!(seen.last().unwrap().failed.is_some());
802 let wt = job_dir(&f.home, "o/r#1").join("wt");
804 assert!(wt.join("built.txt").exists());
805 job.resume();
806 let rel = release(&["touch built.txt", "test -f built.txt"]);
807 go(&f, &rel, &mut job).await.unwrap();
808 assert!(job.finished);
809 assert!(!wt.exists(), "a finished release removes its checkout");
810 }
811
812 #[tokio::test]
813 async fn release_preclean_recovers_an_unregistered_leftover() {
814 let f = fixture();
815 let wt = job_dir(&f.home, "o/r#1").join("wt");
816 std::fs::create_dir_all(&wt).unwrap();
817 std::fs::write(wt.join("leftover"), "interrupted setup").unwrap();
818 let mut job = Job::new("1.0.0", "u", &f.commit);
819 go(&f, &release(&["test ! -e leftover"]), &mut job)
820 .await
821 .unwrap();
822 assert!(job.finished);
823 assert!(!wt.exists());
824 }
825
826 #[tokio::test]
827 async fn release_preclean_recovers_a_pruned_checkout_with_a_stale_git_link() {
828 let f = fixture();
829 let wt = job_dir(&f.home, "o/r#1").join("wt");
830 git::worktree_add_detached(&f.repo, &wt, &f.commit)
831 .await
832 .unwrap();
833 let moved = wt.with_file_name("moved");
834 std::fs::rename(&wt, &moved).unwrap();
835 g(&f.repo, &["worktree", "prune", "--expire", "now"]);
836 std::fs::rename(&moved, &wt).unwrap();
837 let mut job = Job::new("1.0.0", "u", &f.commit);
838 go(&f, &release(&["true"]), &mut job).await.unwrap();
839 assert!(job.finished);
840 assert!(!wt.exists());
841 }
842
843 #[tokio::test]
844 async fn release_preclean_retains_an_unregistered_live_repository() {
845 let f = fixture();
846 let wt = job_dir(&f.home, "o/r#1").join("wt");
847 std::fs::create_dir_all(&wt).unwrap();
848 g(&wt, &["init", "-q"]);
849 std::fs::write(wt.join("owned"), "retain").unwrap();
850 let mut job = Job::new("1.0.0", "u", &f.commit);
851 let err = go(&f, &release(&["true"]), &mut job).await.unwrap_err();
852 assert!(err.to_string().contains("unregistered repository"), "{err}");
853 assert!(wt.join("owned").exists());
854 assert!(!job.tag_done);
855 }
856
857 #[tokio::test]
858 async fn finished_release_reports_cleanup_refusal_and_keeps_the_checkout() {
859 let f = fixture();
860 let mut job = Job::new("1.0.0", "u", &f.commit);
861 let rel = release(&["touch built.txt", "git worktree lock ."]);
862 let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
863 assert!(err.contains("release checkout removal refused"), "{err}");
864 assert!(job.finished);
865 let wt = job_dir(&f.home, "o/r#1").join("wt");
866 assert!(wt.join("built.txt").exists());
867 g(&f.repo, &["worktree", "unlock", wt.to_str().unwrap()]);
868 go(&f, &rel, &mut job).await.unwrap();
870 assert_eq!(job.done, 2);
871 assert!(!wt.exists());
872 assert!(!g(&f.repo, &["worktree", "list", "--porcelain"]).contains(wt.to_str().unwrap()));
873 }
874
875 #[tokio::test]
876 async fn an_existing_tag_elsewhere_is_never_pushed_over_and_runs_nothing() {
877 let f = fixture();
878 std::fs::write(f.repo.join("x"), "x").unwrap();
880 g(&f.repo, &["add", "."]);
881 g(&f.repo, &["commit", "-q", "-m", "other"]);
882 let other = g(&f.repo, &["rev-parse", "HEAD"]);
883 g(&f.repo, &["tag", "v1.0.0", &other]);
884 g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
885
886 let mut job = Job::new("1.0.0", "u", &f.commit);
887 let rel = release(&["echo ran > ran.txt"]);
888 let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
889 assert!(err.contains("already exists"), "{err}");
890 assert!(!job.tag_done && job.done == 0);
891 assert_eq!(
892 g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
893 other
894 );
895 }
896
897 #[tokio::test]
898 async fn a_remote_tag_at_the_merge_commit_is_not_pushed_again() {
899 let f = fixture();
900 g(&f.repo, &["tag", "v1.0.0", &f.commit]);
901 g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
902 let mut job = Job::new("1.0.0", "u", &f.commit);
903 go(&f, &release(&["true"]), &mut job).await.unwrap();
904 assert!(job.finished);
905 assert!(job.log[0].tail.contains("already on the remote"));
906 }
907
908 #[tokio::test]
909 async fn a_version_that_disagrees_with_the_merge_commit_is_not_tagged() {
910 let f = fixture();
911 let mut job = Job::new("2.0.0", "u", &f.commit);
912 let err = go(&f, &release(&[]), &mut job)
913 .await
914 .unwrap_err()
915 .to_string();
916 assert!(err.contains("not tagging"), "{err}");
917 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
918 }
919
920 #[test]
921 fn the_title_names_the_escalated_version() {
922 assert_eq!(
923 version_from_title("chore: release v0.2.0 (minor bump)").as_deref(),
924 Some("0.2.0")
925 );
926 assert_eq!(version_from_title("feat: x"), None);
927 }
928
929 #[test]
930 fn only_a_version_the_pull_request_named_replaces_the_job_version() {
931 let mut job = Job::new("1.0.0", "u", "c");
932 job.accepted = vec!["2.0.0".to_owned()];
933 assert!(!reconcile_version("3.0.0", false, &mut job));
934 assert_eq!(job.version, "1.0.0");
935 job.tag_done = true;
936 assert!(!reconcile_version("2.0.0", false, &mut job));
937 job.tag_done = false;
938 assert!(reconcile_version("2.0.0", false, &mut job));
939 assert_eq!(job.version, "2.0.0");
940 }
941
942 #[tokio::test]
943 async fn an_escalated_version_named_by_the_title_is_tagged() {
944 let f = fixture();
945 let mut job = Job::new("0.9.0", "u", &f.commit);
946 job.accepted = vec!["1.0.0".to_owned()];
947 go(&f, &release(&[]), &mut job).await.unwrap();
948 assert_eq!(job.version, "1.0.0");
949 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.0.0");
950 }
951
952 #[tokio::test]
953 async fn a_manifest_matching_no_candidate_is_not_tagged() {
954 let f = fixture();
955 let mut job = Job::new("0.9.0", "u", &f.commit);
956 job.accepted = vec!["0.8.0".to_owned()];
957 let err = go(&f, &release(&[]), &mut job)
958 .await
959 .unwrap_err()
960 .to_string();
961 assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
962 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
963 }
964
965 #[tokio::test]
966 async fn a_merge_that_bumped_the_manifest_recovers_a_stale_branch_version() {
967 let f = fixture();
968 std::fs::write(
969 f.repo.join("Cargo.toml"),
970 "[package]\nname = \"x\"\nversion = \"1.1.0\"\n",
971 )
972 .unwrap();
973 g(&f.repo, &["commit", "-q", "-am", "bump"]);
974 g(&f.repo, &["push", "-q", "origin", "main"]);
975 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
976 let mut job = Job::new("1.0.1", "u", &commit);
978 job.failed = Some(format!(
979 "{VERSION_MISMATCH_PREFIX}1.1.0, but the release is 1.0.1; not tagging"
980 ));
981 job.resume();
982 go(&f, &release(&[]), &mut job).await.unwrap();
983 assert_eq!(job.version, "1.1.0");
984 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.1.0");
985 }
986
987 #[tokio::test]
988 async fn a_commit_that_left_the_version_alone_is_not_tagged_under_a_stale_one() {
989 let f = fixture();
990 std::fs::write(f.repo.join("a.txt"), "x").unwrap();
991 g(&f.repo, &["add", "."]);
992 g(&f.repo, &["commit", "-q", "-m", "other"]);
993 g(&f.repo, &["push", "-q", "origin", "main"]);
994 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
995 let mut job = Job::new("0.9.0", "u", &commit);
996 let err = go(&f, &release(&[]), &mut job)
997 .await
998 .unwrap_err()
999 .to_string();
1000 assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
1001 assert_eq!(g(&f.remote, &["tag", "-l"]), "");
1002 }
1003
1004 #[tokio::test]
1005 async fn a_shallow_boundary_merge_commit_still_recovers_a_stale_branch_version() {
1006 let mut f = fixture();
1007 std::fs::write(
1008 f.repo.join("Cargo.toml"),
1009 "[package]\nname = \"x\"\nversion = \"1.1.0\"\n",
1010 )
1011 .unwrap();
1012 g(&f.repo, &["commit", "-q", "-am", "bump"]);
1013 g(&f.repo, &["push", "-q", "origin", "main"]);
1014 let commit = g(&f.repo, &["rev-parse", "HEAD"]);
1015 let shallow = f.repo.parent().unwrap().join("shallow-clone");
1016 let url = format!("file://{}", f.remote.display());
1017 g(
1018 &f.repo,
1019 &[
1020 "clone",
1021 "-q",
1022 "--branch",
1023 "main",
1024 "--depth",
1025 "1",
1026 &url,
1027 shallow.to_str().unwrap(),
1028 ],
1029 );
1030 assert!(shallow.join(".git/shallow").exists());
1031 let probe = std::process::Command::new("git")
1032 .current_dir(&shallow)
1033 .quiet()
1034 .args(["rev-parse", "--verify", "--quiet", &format!("{commit}^1")])
1035 .output()
1036 .unwrap();
1037 assert!(!probe.status.success(), "the parent must be hidden");
1038 f.repo = shallow;
1039 let mut job = Job::new("1.0.1", "u", &commit);
1040 job.failed = Some(format!(
1041 "{VERSION_MISMATCH_PREFIX}1.1.0, but the release is 1.0.1; not tagging"
1042 ));
1043 job.resume();
1044 go(&f, &release(&[]), &mut job).await.unwrap();
1045 assert_eq!(job.version, "1.1.0");
1046 assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.1.0");
1047 }
1048}