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. A shallow boundary
157/// hides its parent from every revision lookup, so existence is read from the
158/// commit object's own `parent` header, which no graft rewrites.
159async 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
173/// Whether a raw commit object names a parent: a `parent ` line in the header,
174/// which ends at the first blank line (a message line must not count).
175fn 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
181/// `1.2.3` out of `chore/release-v1.2.3`.
182pub 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/// What to do about the tag. Pure.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum TagStep {
190    /// Neither the remote nor this checkout has it: create it and push.
191    Create,
192    /// A local tag already at the commit but not on the remote: push it.
193    PushExisting,
194    /// The remote already has it at the commit: nothing to do.
195    Present,
196    /// A tag of that name points elsewhere. Never touched.
197    Foreign(String),
198    /// The remote could not be read, so "does not exist" is not known.
199    Unreadable(String),
200}
201
202/// Decide the tag step from what was read. `remote` is the commit the remote's
203/// tag peels to (`Ok(None)`: absent), or the reason it could not be read;
204/// `local` is the commit of a local tag of that name.
205pub 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
227/// The commit `tag` peels to, out of `git ls-remote --tags <remote>
228/// refs/tags/<tag> refs/tags/<tag>^{}`. The peeled line wins over the tag
229/// object's own.
230pub 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
249/// Last `max` bytes of `s`, on a character boundary.
250pub 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
262/// Where a job keeps its worktree and full command output.
263pub 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
276/// Persists the job; `false` means it could not be, and nothing may run.
277pub type Save<'a> = &'a mut (dyn FnMut(&Job) -> bool + Send);
278
279/// Where and how a job runs.
280pub struct Env<'a> {
281    /// Primary checkout; only used to add and remove the detached worktree.
282    pub repo: &'a Path,
283    /// magi home, for the job's directory.
284    pub home: &'a Path,
285    /// Names the job's directory (the pull request key).
286    pub key: &'a str,
287    /// Remote the tag is pushed to (`[merge] remote`).
288    pub remote: &'a str,
289    /// [`crate::config::Config::shell`].
290    pub shell: &'a [String],
291    /// The `[release]` table.
292    pub release: &'a Release,
293}
294
295/// Drive `job` to the end or to the first failure. `Err` carries the reason it
296/// stopped; the job itself already says how far it got. Never retries.
297pub 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    // A held job keeps its checkout: a resumed command (say `gh release
302    // create`) needs what an earlier one built (`target/release/...`), and a
303    // fresh checkout would skip the build and attach nothing. Only a finished
304    // release cleans up.
305    if job.finished {
306        let _ = git::worktree_remove(env.repo, &wt).await;
307    }
308    result
309}
310
311async fn drive(env: &Env<'_>, dir: &Path, wt: &Path, job: &mut Job, save: Save<'_>) -> Result<()> {
312    let (repo, remote) = (env.repo, env.remote);
313    if job.commit.is_empty() {
314        bail!("the merge commit of {} is unknown", job.pr_url);
315    }
316    // Resuming after progress reuses the checkout the finished steps ran in
317    // (it is no longer "clean": their output is there, which is the point).
318    let progressed = job.tag_done || job.done > 0;
319    let reusable = progressed
320        && wt.exists()
321        && git::rev_parse(wt, "HEAD").await.ok().as_deref() == Some(job.commit.as_str());
322    if !reusable {
323        if wt.exists() {
324            let _ = git::worktree_remove(repo, wt).await;
325            let _ = std::fs::remove_dir_all(wt);
326        }
327        // The merge commit exists on the remote; this checkout may not have it
328        // yet. A failed fetch is only fatal if the commit is still missing.
329        let fetched = git::git_raw(repo, &["fetch", "--quiet", remote]).await?;
330        if !git::rev_exists(repo, &format!("{}^{{commit}}", job.commit)).await {
331            bail!(
332                "the merge commit {} is not available locally (git fetch {remote}: {})",
333                job.commit,
334                fetched.stderr
335            );
336        }
337        git::worktree_add_detached(repo, wt, &job.commit)
338            .await
339            .context("check out the merge commit")?;
340        let head = git::rev_parse(wt, "HEAD").await?;
341        if head != job.commit {
342            bail!(
343                "the release checkout is at {head}, not the merge commit {}",
344                job.commit
345            );
346        }
347        if !git::is_clean(wt).await? {
348            bail!("the release checkout is not clean");
349        }
350    }
351    if let Ok(toml) = std::fs::read_to_string(wt.join("Cargo.toml")) {
352        let before = job.version.clone();
353        match crate::bump::current_version(&toml) {
354            Ok(v) if v != job.version && !job.tag_done && job.done == 0 => {
355                let bumped = bumped_by(wt, &job.commit, &v).await;
356                if !reconcile_version(&v, bumped, job) {
357                    bail!(
358                        "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
359                        job.version
360                    );
361                }
362                // Record an adopted version before anything is tagged.
363                if job.version != before && !save(job) {
364                    bail!("could not record progress; nothing was run");
365                }
366            }
367            Ok(v) if v == job.version => {}
368            Ok(v) => bail!(
369                "{VERSION_MISMATCH_PREFIX}{v}, but the release is {}; not tagging",
370                job.version
371            ),
372            Err(e) => bail!("cannot read the version at the merge commit: {e:#}"),
373        }
374    }
375
376    if !job.tag_done {
377        job.running = Some("tag".to_owned());
378        if !save(job) {
379            bail!("could not record progress; nothing was run");
380        }
381        let note = tag(wt, remote, job).await?;
382        job.log.push(StepLog {
383            name: "tag".to_owned(),
384            code: Some(0),
385            tail: note,
386            output: None,
387        });
388        job.tag_done = true;
389        job.running = None;
390        if !save(job) {
391            bail!("the tag was pushed but progress could not be recorded");
392        }
393    }
394
395    let release = env.release;
396    let timeout = Duration::from_secs(release.timeout_minutes.max(1) * 60);
397    while job.done < release.commands.len() {
398        let n = job.done;
399        let command = &release.commands[n];
400        job.running = Some(format!("command {}", n + 1));
401        if !save(job) {
402            bail!("could not record progress; nothing was run");
403        }
404        let (code, output) = run_command(wt, env.shell, command, job, timeout).await;
405        let _ = std::fs::create_dir_all(dir);
406        let out_path = dir.join(format!("release-{}.out", n + 1));
407        let kept = std::fs::write(&out_path, &output).is_ok();
408        job.log.push(StepLog {
409            name: command.clone(),
410            code,
411            tail: tail(&output, TAIL),
412            output: kept.then(|| out_path.display().to_string()),
413        });
414        if code != Some(0) {
415            let why = match code {
416                Some(c) => format!("command {} `{command}` exited {c}", n + 1),
417                None => format!(
418                    "command {} `{command}` did not finish (timeout is {} minute(s))",
419                    n + 1,
420                    release.timeout_minutes
421                ),
422            };
423            // `failed` is saved together with the cleared step: a stop between
424            // the two must never read as an unmarked, retryable job.
425            job.failed = Some(why.clone());
426            job.running = None;
427            let _ = save(job);
428            bail!("{why}");
429        }
430        job.running = None;
431        job.done += 1;
432        if !save(job) {
433            bail!(
434                "command {} succeeded but progress could not be recorded",
435                n + 1
436            );
437        }
438    }
439    job.finished = true;
440    let _ = save(job);
441    Ok(())
442}
443
444/// Read the tag's state and act on [`tag_step`]. Returns a note for the log.
445async fn tag(wt: &Path, remote: &str, job: &Job) -> Result<String> {
446    let name = job.tag();
447    let listed = git::git_raw(
448        wt,
449        &[
450            "ls-remote",
451            "--tags",
452            remote,
453            &format!("refs/tags/{name}"),
454            &format!("refs/tags/{name}^{{}}"),
455        ],
456    )
457    .await?;
458    let remote_tag = if listed.ok() {
459        Ok(parse_ls_remote(&listed.stdout, &name))
460    } else {
461        Err(listed.stderr.clone())
462    };
463    let local = git::git_raw(
464        wt,
465        &[
466            "rev-parse",
467            "-q",
468            "--verify",
469            &format!("refs/tags/{name}^{{commit}}"),
470        ],
471    )
472    .await?;
473    let local = local.ok().then(|| local.stdout.clone());
474    let step = tag_step(
475        &name,
476        &job.commit,
477        remote_tag
478            .as_ref()
479            .map(|o| o.as_deref())
480            .map_err(String::as_str),
481        local.as_deref(),
482    );
483    match step {
484        TagStep::Present => Ok(format!("{name} already on the remote at the merge commit")),
485        TagStep::Foreign(why) | TagStep::Unreadable(why) => bail!("{why}"),
486        TagStep::Create | TagStep::PushExisting => {
487            if step == TagStep::Create {
488                // An annotated tag needs a committer. Use the operator's own
489                // identity; only when git has none (a bare service account) fall
490                // back to a neutral one rather than failing the release.
491                let has_ident = git::git_raw(wt, &["var", "GIT_COMMITTER_IDENT"])
492                    .await?
493                    .ok();
494                let mut args = vec![];
495                if !has_ident {
496                    args.extend(["-c", "user.name=magi", "-c", "user.email=magi@localhost"]);
497                }
498                args.extend(["tag", "-a", name.as_str(), "-m", name.as_str(), &job.commit]);
499                let out = git::git_raw(wt, &args).await?;
500                if !out.ok() {
501                    bail!("git tag {name}: {}", out.stderr);
502                }
503            }
504            // No `+`, no `--force`: an existing remote tag is refused by git.
505            let refspec = format!("refs/tags/{name}:refs/tags/{name}");
506            let out = git::git_raw(wt, &["push", remote, &refspec]).await?;
507            if !out.ok() {
508                bail!("git push {remote} {refspec}: {}", out.stderr);
509            }
510            Ok(format!("pushed {name} at {}", job.commit))
511        }
512    }
513}
514
515/// Run one command in `wt`; `(None, ..)` when it could not run or timed out.
516async fn run_command(
517    wt: &Path,
518    shell: &[String],
519    command: &str,
520    job: &Job,
521    timeout: Duration,
522) -> (Option<i32>, String) {
523    let Some((prog, args)) = shell.split_first() else {
524        return (None, "no shell is configured".to_owned());
525    };
526    let mut cmd = tokio::process::Command::new(prog);
527    cmd.args(args)
528        .arg(command)
529        .current_dir(wt)
530        .kill_on_drop(true)
531        .stdin(std::process::Stdio::null());
532    for (k, v) in env_of(job) {
533        cmd.env(k, v);
534    }
535    cmd.quiet();
536    match tokio::time::timeout(timeout, cmd.output()).await {
537        Err(_) => (None, "timed out".to_owned()),
538        Ok(Err(e)) => (None, format!("could not run: {e}")),
539        Ok(Ok(out)) => {
540            let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
541            text.push_str(&String::from_utf8_lossy(&out.stderr));
542            (out.status.code(), text)
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    const C: &str = "aaaa";
552
553    #[test]
554    fn the_tag_decision_never_touches_an_existing_tag() {
555        let t = "v1.0.0";
556        assert_eq!(tag_step(t, C, Ok(None), None), TagStep::Create);
557        assert_eq!(tag_step(t, C, Ok(None), Some(C)), TagStep::PushExisting);
558        assert_eq!(tag_step(t, C, Ok(Some(C)), None), TagStep::Present);
559        assert_eq!(tag_step(t, C, Ok(Some(C)), Some("bbbb")), TagStep::Present);
560        assert!(matches!(
561            tag_step(t, C, Ok(Some("bbbb")), Some(C)),
562            TagStep::Foreign(_)
563        ));
564        assert!(matches!(
565            tag_step(t, C, Ok(None), Some("bbbb")),
566            TagStep::Foreign(_)
567        ));
568        // Unreadable is not absent.
569        assert!(matches!(
570            tag_step(t, C, Err("boom"), None),
571            TagStep::Unreadable(_)
572        ));
573    }
574
575    #[test]
576    fn ls_remote_prefers_the_peeled_commit() {
577        let out = "1111\trefs/tags/v1.0.0\n2222\trefs/tags/v1.0.0^{}\n3333\trefs/tags/v1.0.0-rc\n";
578        assert_eq!(parse_ls_remote(out, "v1.0.0").as_deref(), Some("2222"));
579        assert_eq!(
580            parse_ls_remote("1111\trefs/tags/v1.0.0\n", "v1.0.0").as_deref(),
581            Some("1111")
582        );
583        assert_eq!(parse_ls_remote("", "v1.0.0"), None);
584    }
585
586    #[test]
587    fn version_comes_from_the_release_branch() {
588        assert_eq!(
589            version_from_branch("chore/release-v1.2.3").as_deref(),
590            Some("1.2.3")
591        );
592        assert_eq!(version_from_branch("chore/release-v"), None);
593        assert_eq!(version_from_branch("feat/x"), None);
594    }
595
596    #[test]
597    fn a_started_and_unfinished_step_is_interrupted_not_retried() {
598        let mut j = Job::new("1.0.0", "u", C);
599        assert!(!j.interrupted());
600        j.running = Some("command 1".to_owned());
601        assert!(j.interrupted());
602        j.failed = Some("x".to_owned());
603        assert!(!j.interrupted());
604        j.resume();
605        assert!(!j.interrupted() && j.failed.is_none());
606    }
607
608    #[test]
609    fn tail_keeps_the_end_on_a_char_boundary() {
610        assert_eq!(tail("abc", 10), "abc");
611        assert_eq!(tail("abcdef", 3), "...def");
612        // `é` is two bytes: cutting inside it moves forward to the boundary.
613        assert_eq!(tail("aéb", 2), "...b");
614    }
615
616    /// Run git in `dir`, panicking on failure.
617    fn g(dir: &Path, args: &[&str]) -> String {
618        let out = std::process::Command::new("git")
619            .args(args)
620            .current_dir(dir)
621            .quiet()
622            .env("GIT_AUTHOR_NAME", "t")
623            .env("GIT_AUTHOR_EMAIL", "t@t")
624            .env("GIT_COMMITTER_NAME", "t")
625            .env("GIT_COMMITTER_EMAIL", "t@t")
626            .output()
627            .unwrap();
628        assert!(
629            out.status.success(),
630            "git {args:?}: {}",
631            String::from_utf8_lossy(&out.stderr)
632        );
633        String::from_utf8_lossy(&out.stdout).trim().to_owned()
634    }
635
636    struct Fixture {
637        _dir: tempfile::TempDir,
638        repo: PathBuf,
639        remote: PathBuf,
640        home: PathBuf,
641        commit: String,
642    }
643
644    fn fixture() -> Fixture {
645        let dir = tempfile::tempdir().unwrap();
646        let remote = dir.path().join("remote.git");
647        let repo = dir.path().join("repo");
648        let home = dir.path().join("home");
649        std::fs::create_dir_all(&remote).unwrap();
650        std::fs::create_dir_all(&repo).unwrap();
651        g(&remote, &["init", "--bare", "-q"]);
652        g(&repo, &["init", "-q", "-b", "main"]);
653        std::fs::write(
654            repo.join("Cargo.toml"),
655            "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
656        )
657        .unwrap();
658        g(&repo, &["add", "."]);
659        g(&repo, &["commit", "-q", "-m", "init"]);
660        g(
661            &repo,
662            &["remote", "add", "origin", remote.to_str().unwrap()],
663        );
664        g(&repo, &["push", "-q", "origin", "main"]);
665        let commit = g(&repo, &["rev-parse", "HEAD"]);
666        Fixture {
667            _dir: dir,
668            repo,
669            remote,
670            home,
671            commit,
672        }
673    }
674
675    fn release(commands: &[&str]) -> Release {
676        Release {
677            mode: crate::config::ReleaseMode::Local,
678            commands: commands.iter().map(|s| (*s).to_owned()).collect(),
679            timeout_minutes: 1,
680        }
681    }
682
683    async fn go(f: &Fixture, rel: &Release, job: &mut Job) -> Result<()> {
684        let shell = vec!["sh".to_owned(), "-c".to_owned()];
685        let env = Env {
686            repo: &f.repo,
687            home: &f.home,
688            key: "o/r#1",
689            remote: "origin",
690            shell: &shell,
691            release: rel,
692        };
693        run_job(&env, job, &mut |_: &Job| true).await
694    }
695
696    #[tokio::test]
697    async fn a_release_tags_once_runs_every_command_and_keeps_the_output() {
698        let f = fixture();
699        let mut job = Job::new("1.0.0", "https://github.com/o/r/pull/1", &f.commit);
700        let rel = release(&["echo tag=$MAGI_RELEASE_TAG", "true"]);
701        go(&f, &rel, &mut job).await.unwrap();
702        assert!(job.finished && job.tag_done);
703        assert_eq!(job.done, 2);
704        assert!(job.log[1].tail.contains("tag=v1.0.0"), "{:?}", job.log);
705        assert_eq!(
706            g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
707            f.commit
708        );
709        let out = job_dir(&f.home, "o/r#1").join("release-1.out");
710        assert!(std::fs::read_to_string(out).unwrap().contains("tag=v1.0.0"));
711    }
712
713    #[tokio::test]
714    async fn the_first_failure_stops_the_list_and_a_resume_skips_what_succeeded() {
715        let f = fixture();
716        let mut job = Job::new("1.0.0", "u", &f.commit);
717        let rel = release(&["true", "exit 3", "echo never"]);
718        let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
719        assert!(err.contains("exited 3"), "{err}");
720        assert_eq!(job.done, 1);
721        assert!(job.tag_done && !job.finished);
722        assert!(!job.log.iter().any(|l| l.tail.contains("never")));
723
724        // The owner fixed it and retried: the tag is not redone, the first
725        // command is not repeated.
726        job.resume();
727        let rel = release(&["true", "true", "echo now"]);
728        let tags_before = g(&f.remote, &["tag", "-l"]);
729        go(&f, &rel, &mut job).await.unwrap();
730        assert!(job.finished);
731        assert_eq!(job.done, 3);
732        assert_eq!(tags_before, g(&f.remote, &["tag", "-l"]));
733        assert_eq!(job.log.iter().filter(|l| l.name == "true").count(), 2);
734    }
735
736    #[tokio::test]
737    async fn a_failure_is_never_saved_as_a_plain_unmarked_job_and_the_checkout_survives() {
738        let f = fixture();
739        let mut job = Job::new("1.0.0", "u", &f.commit);
740        let rel = release(&["touch built.txt", "exit 1"]);
741        let shell = vec!["sh".to_owned(), "-c".to_owned()];
742        let env = Env {
743            repo: &f.repo,
744            home: &f.home,
745            key: "o/r#1",
746            remote: "origin",
747            shell: &shell,
748            release: &rel,
749        };
750        let mut seen: Vec<Job> = Vec::new();
751        run_job(&env, &mut job, &mut |j: &Job| {
752            seen.push(j.clone());
753            true
754        })
755        .await
756        .unwrap_err();
757        // Every saved state either is mid-step or carries the failure.
758        assert!(
759            seen.iter()
760                .all(|j| j.running.is_some() || j.failed.is_some() || j.done > 0 || j.tag_done)
761        );
762        assert!(seen.last().unwrap().failed.is_some());
763        // The first command's output is still there for the resumed one.
764        let wt = job_dir(&f.home, "o/r#1").join("wt");
765        assert!(wt.join("built.txt").exists());
766        job.resume();
767        let rel = release(&["touch built.txt", "test -f built.txt"]);
768        go(&f, &rel, &mut job).await.unwrap();
769        assert!(job.finished);
770        assert!(!wt.exists(), "a finished release removes its checkout");
771    }
772
773    #[tokio::test]
774    async fn an_existing_tag_elsewhere_is_never_pushed_over_and_runs_nothing() {
775        let f = fixture();
776        // A foreign v1.0.0 on the remote, at a different commit.
777        std::fs::write(f.repo.join("x"), "x").unwrap();
778        g(&f.repo, &["add", "."]);
779        g(&f.repo, &["commit", "-q", "-m", "other"]);
780        let other = g(&f.repo, &["rev-parse", "HEAD"]);
781        g(&f.repo, &["tag", "v1.0.0", &other]);
782        g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
783
784        let mut job = Job::new("1.0.0", "u", &f.commit);
785        let rel = release(&["echo ran > ran.txt"]);
786        let err = go(&f, &rel, &mut job).await.unwrap_err().to_string();
787        assert!(err.contains("already exists"), "{err}");
788        assert!(!job.tag_done && job.done == 0);
789        assert_eq!(
790            g(&f.remote, &["rev-parse", "refs/tags/v1.0.0^{commit}"]),
791            other
792        );
793    }
794
795    #[tokio::test]
796    async fn a_remote_tag_at_the_merge_commit_is_not_pushed_again() {
797        let f = fixture();
798        g(&f.repo, &["tag", "v1.0.0", &f.commit]);
799        g(&f.repo, &["push", "-q", "origin", "refs/tags/v1.0.0"]);
800        let mut job = Job::new("1.0.0", "u", &f.commit);
801        go(&f, &release(&["true"]), &mut job).await.unwrap();
802        assert!(job.finished);
803        assert!(job.log[0].tail.contains("already on the remote"));
804    }
805
806    #[tokio::test]
807    async fn a_version_that_disagrees_with_the_merge_commit_is_not_tagged() {
808        let f = fixture();
809        let mut job = Job::new("2.0.0", "u", &f.commit);
810        let err = go(&f, &release(&[]), &mut job)
811            .await
812            .unwrap_err()
813            .to_string();
814        assert!(err.contains("not tagging"), "{err}");
815        assert_eq!(g(&f.remote, &["tag", "-l"]), "");
816    }
817
818    #[test]
819    fn the_title_names_the_escalated_version() {
820        assert_eq!(
821            version_from_title("chore: release v0.2.0 (minor bump)").as_deref(),
822            Some("0.2.0")
823        );
824        assert_eq!(version_from_title("feat: x"), None);
825    }
826
827    #[test]
828    fn only_a_version_the_pull_request_named_replaces_the_job_version() {
829        let mut job = Job::new("1.0.0", "u", "c");
830        job.accepted = vec!["2.0.0".to_owned()];
831        assert!(!reconcile_version("3.0.0", false, &mut job));
832        assert_eq!(job.version, "1.0.0");
833        job.tag_done = true;
834        assert!(!reconcile_version("2.0.0", false, &mut job));
835        job.tag_done = false;
836        assert!(reconcile_version("2.0.0", false, &mut job));
837        assert_eq!(job.version, "2.0.0");
838    }
839
840    #[tokio::test]
841    async fn an_escalated_version_named_by_the_title_is_tagged() {
842        let f = fixture();
843        let mut job = Job::new("0.9.0", "u", &f.commit);
844        job.accepted = vec!["1.0.0".to_owned()];
845        go(&f, &release(&[]), &mut job).await.unwrap();
846        assert_eq!(job.version, "1.0.0");
847        assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.0.0");
848    }
849
850    #[tokio::test]
851    async fn a_manifest_matching_no_candidate_is_not_tagged() {
852        let f = fixture();
853        let mut job = Job::new("0.9.0", "u", &f.commit);
854        job.accepted = vec!["0.8.0".to_owned()];
855        let err = go(&f, &release(&[]), &mut job)
856            .await
857            .unwrap_err()
858            .to_string();
859        assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
860        assert_eq!(g(&f.remote, &["tag", "-l"]), "");
861    }
862
863    #[tokio::test]
864    async fn a_merge_that_bumped_the_manifest_recovers_a_stale_branch_version() {
865        let f = fixture();
866        std::fs::write(
867            f.repo.join("Cargo.toml"),
868            "[package]\nname = \"x\"\nversion = \"1.1.0\"\n",
869        )
870        .unwrap();
871        g(&f.repo, &["commit", "-q", "-am", "bump"]);
872        g(&f.repo, &["push", "-q", "origin", "main"]);
873        let commit = g(&f.repo, &["rev-parse", "HEAD"]);
874        // A record failed on the mismatch, nothing in `accepted`.
875        let mut job = Job::new("1.0.1", "u", &commit);
876        job.failed = Some(format!(
877            "{VERSION_MISMATCH_PREFIX}1.1.0, but the release is 1.0.1; not tagging"
878        ));
879        job.resume();
880        go(&f, &release(&[]), &mut job).await.unwrap();
881        assert_eq!(job.version, "1.1.0");
882        assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.1.0");
883    }
884
885    #[tokio::test]
886    async fn a_commit_that_left_the_version_alone_is_not_tagged_under_a_stale_one() {
887        let f = fixture();
888        std::fs::write(f.repo.join("a.txt"), "x").unwrap();
889        g(&f.repo, &["add", "."]);
890        g(&f.repo, &["commit", "-q", "-m", "other"]);
891        g(&f.repo, &["push", "-q", "origin", "main"]);
892        let commit = g(&f.repo, &["rev-parse", "HEAD"]);
893        let mut job = Job::new("0.9.0", "u", &commit);
894        let err = go(&f, &release(&[]), &mut job)
895            .await
896            .unwrap_err()
897            .to_string();
898        assert!(err.starts_with(VERSION_MISMATCH_PREFIX), "{err}");
899        assert_eq!(g(&f.remote, &["tag", "-l"]), "");
900    }
901
902    #[tokio::test]
903    async fn a_shallow_boundary_merge_commit_still_recovers_a_stale_branch_version() {
904        let mut f = fixture();
905        std::fs::write(
906            f.repo.join("Cargo.toml"),
907            "[package]\nname = \"x\"\nversion = \"1.1.0\"\n",
908        )
909        .unwrap();
910        g(&f.repo, &["commit", "-q", "-am", "bump"]);
911        g(&f.repo, &["push", "-q", "origin", "main"]);
912        let commit = g(&f.repo, &["rev-parse", "HEAD"]);
913        let shallow = f.repo.parent().unwrap().join("shallow-clone");
914        let url = format!("file://{}", f.remote.display());
915        g(
916            &f.repo,
917            &[
918                "clone",
919                "-q",
920                "--branch",
921                "main",
922                "--depth",
923                "1",
924                &url,
925                shallow.to_str().unwrap(),
926            ],
927        );
928        assert!(shallow.join(".git/shallow").exists());
929        let probe = std::process::Command::new("git")
930            .current_dir(&shallow)
931            .quiet()
932            .args(["rev-parse", "--verify", "--quiet", &format!("{commit}^1")])
933            .output()
934            .unwrap();
935        assert!(!probe.status.success(), "the parent must be hidden");
936        f.repo = shallow;
937        let mut job = Job::new("1.0.1", "u", &commit);
938        job.failed = Some(format!(
939            "{VERSION_MISMATCH_PREFIX}1.1.0, but the release is 1.0.1; not tagging"
940        ));
941        job.resume();
942        go(&f, &release(&[]), &mut job).await.unwrap();
943        assert_eq!(job.version, "1.1.0");
944        assert_eq!(g(&f.remote, &["tag", "-l"]), "v1.1.0");
945    }
946}