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