Skip to main content

magi/
release_local.rs

1//! Releasing without GitHub Actions (`[release] mode = "local"`).
2//!
3//! On a repository whose Actions never run, `auto-tag.yml` and `release.yml`
4//! never fire, so nothing tags or publishes after the release pull request
5//! merges. In local mode [`crate::release_watch`] merges that pull request on
6//! the owner's approval and then drives the [`Job`] defined here, from a clean
7//! detached checkout of the merge commit:
8//!
9//! 1. create and push the `vX.Y.Z` tag - **never over a tag that exists**
10//!    ([`tag_step`] is the whole decision);
11//! 2. run `[release] commands` one at a time, stopping at the first failure.
12//!
13//! - **Progress is saved before every step**, through the caller's `save`. A
14//!   step that was started and never finished (a crash, a kill) is
15//!   [`Job::interrupted`]: magi cannot know whether `gh release create` got as
16//!   far as the forge, so it holds for the owner instead of repeating it.
17//! - **A failure is a hold, not a retry.** [`run_job`] returns the reason; the
18//!   watcher records it, raises a notice and asks. Resuming skips the tag and
19//!   every command that already succeeded.
20//! - **Environment, not templates.** Commands see `MAGI_RELEASE_VERSION`,
21//!   `MAGI_RELEASE_TAG`, `MAGI_RELEASE_COMMIT` and `MAGI_RELEASE_PR`; teravars
22//!   renders the whole config before any release exists, so `{{ version }}`
23//!   cannot work.
24//! - Only `git` and the commands the operator wrote run; there is no API path.
25
26use 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
36/// Bytes of a command's output kept in the record; the whole of it goes to
37/// `release-<n>.out` beside the job.
38const TAIL: usize = 4_000;
39
40/// Head of a release branch name, before the version.
41pub const BRANCH_PREFIX: &str = "chore/release-v";
42
43/// What happened in one step, kept in the record.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(default)]
46pub struct StepLog {
47    /// `tag` or the command line.
48    pub name: String,
49    /// Exit code; `None` for a signal or a timeout.
50    pub code: Option<i32>,
51    /// Last bytes of the combined output.
52    pub tail: String,
53    /// Absolute path of the file holding the whole output, when one was kept.
54    pub output: Option<String>,
55}
56
57/// One release of one merged pull request, resumable.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(default)]
60pub struct Job {
61    /// `X.Y.Z`, without the `v`.
62    pub version: String,
63    /// The merged release pull request.
64    pub pr_url: String,
65    /// The commit the pull request merged as; what the tag points at.
66    pub commit: String,
67    /// The tag exists on the remote at `commit`.
68    pub tag_done: bool,
69    /// Commands that finished successfully.
70    pub done: usize,
71    /// The step started and not yet finished (`tag` or `command N`).
72    pub running: Option<String>,
73    /// Why the job stopped. Held until the owner decides.
74    pub failed: Option<String>,
75    /// Everything ran.
76    pub finished: bool,
77    /// What each step that ran said, in order.
78    pub log: Vec<StepLog>,
79    /// Other versions the pull request itself names (its title, which an
80    /// escalation rewrites while the branch keeps its first name). The merge
81    /// commit's manifest may replace `version` with one of these, never with
82    /// anything else.
83    pub accepted: Vec<String>,
84}
85
86impl Job {
87    /// A job that has not started.
88    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    /// The tag name, `vX.Y.Z`.
98    pub fn tag(&self) -> String {
99        format!("v{}", self.version)
100    }
101
102    /// Stopped in the middle of a step with nothing recorded about how it
103    /// ended: the outcome is unknown, so it is not retried on its own.
104    pub fn interrupted(&self) -> bool {
105        self.running.is_some() && self.failed.is_none() && !self.finished
106    }
107
108    /// Owner chose to retry: forget the stop and resume from the progress.
109    pub fn resume(&mut self) {
110        self.failed = None;
111        self.running = None;
112    }
113}
114
115/// Start of the message [`drive`] fails with when the manifest at the merge
116/// commit disagrees with the job; the watcher recognises a recoverable record
117/// by it.
118pub const VERSION_MISMATCH_PREFIX: &str = "Cargo.toml at the merge commit says ";
119
120/// `1.2.3` out of a title such as `chore: release v1.2.3 (minor bump)`.
121pub 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
127/// The manifest's version is the truth, but it may replace the job's only
128/// before anything has been tagged or run, and only when the pull request named
129/// it (title) or the merge commit itself changed the manifest's version
130/// (`bumped`: an escalation whose title edit failed still qualifies). A
131/// manifest the commit did not touch and nobody named stays a mismatch. Pure.
132pub 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
152/// Whether the merge commit itself moved the manifest to `version`: its first
153/// parent says something else. A root commit has no parent and never counts; a
154/// parent that exists but cannot be read (a shallow clone, a manifest that did
155/// not exist yet) is unknown, and then the commit's own manifest is trusted,
156/// since the commit is the one the pull request merged as.
157async 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
171/// `1.2.3` out of `chore/release-v1.2.3`.
172pub 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/// What to do about the tag. Pure.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum TagStep {
180    /// Neither the remote nor this checkout has it: create it and push.
181    Create,
182    /// A local tag already at the commit but not on the remote: push it.
183    PushExisting,
184    /// The remote already has it at the commit: nothing to do.
185    Present,
186    /// A tag of that name points elsewhere. Never touched.
187    Foreign(String),
188    /// The remote could not be read, so "does not exist" is not known.
189    Unreadable(String),
190}
191
192/// Decide the tag step from what was read. `remote` is the commit the remote's
193/// tag peels to (`Ok(None)`: absent), or the reason it could not be read;
194/// `local` is the commit of a local tag of that name.
195pub 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
217/// The commit `tag` peels to, out of `git ls-remote --tags <remote>
218/// refs/tags/<tag> refs/tags/<tag>^{}`. The peeled line wins over the tag
219/// object's own.
220pub 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
239/// Last `max` bytes of `s`, on a character boundary.
240pub 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
252/// Where a job keeps its worktree and full command output.
253pub 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
266/// Persists the job; `false` means it could not be, and nothing may run.
267pub type Save<'a> = &'a mut (dyn FnMut(&Job) -> bool + Send);
268
269/// Where and how a job runs.
270pub struct Env<'a> {
271    /// Primary checkout; only used to add and remove the detached worktree.
272    pub repo: &'a Path,
273    /// magi home, for the job's directory.
274    pub home: &'a Path,
275    /// Names the job's directory (the pull request key).
276    pub key: &'a str,
277    /// Remote the tag is pushed to (`[merge] remote`).
278    pub remote: &'a str,
279    /// [`crate::config::Config::shell`].
280    pub shell: &'a [String],
281    /// The `[release]` table.
282    pub release: &'a Release,
283}
284
285/// Drive `job` to the end or to the first failure. `Err` carries the reason it
286/// stopped; the job itself already says how far it got. Never retries.
287pub 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    // A held job keeps its checkout: a resumed command (say `gh release
292    // create`) needs what an earlier one built (`target/release/...`), and a
293    // fresh checkout would skip the build and attach nothing. Only a finished
294    // release cleans up.
295    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    // Resuming after progress reuses the checkout the finished steps ran in
307    // (it is no longer "clean": their output is there, which is the point).
308    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        // The merge commit exists on the remote; this checkout may not have it
318        // yet. A failed fetch is only fatal if the commit is still missing.
319        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                // Record an adopted version before anything is tagged.
353                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            // `failed` is saved together with the cleared step: a stop between
414            // the two must never read as an unmarked, retryable job.
415            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
434/// Read the tag's state and act on [`tag_step`]. Returns a note for the log.
435async 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                // An annotated tag needs a committer. Use the operator's own
479                // identity; only when git has none (a bare service account) fall
480                // back to a neutral one rather than failing the release.
481                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            // No `+`, no `--force`: an existing remote tag is refused by git.
495            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
505/// Run one command in `wt`; `(None, ..)` when it could not run or timed out.
506async 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        // Unreadable is not absent.
559        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        // `é` is two bytes: cutting inside it moves forward to the boundary.
603        assert_eq!(tail("aéb", 2), "...b");
604    }
605
606    /// Run git in `dir`, panicking on failure.
607    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        // The owner fixed it and retried: the tag is not redone, the first
715        // command is not repeated.
716        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        // Every saved state either is mid-step or carries the failure.
748        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        // The first command's output is still there for the resumed one.
754        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        // A foreign v1.0.0 on the remote, at a different commit.
767        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        // A record failed on the mismatch, nothing in `accepted`.
865        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}