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