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