release-tool 0.3.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use anyhow::Result;
use chrono::Local;
use release_tool::command::{CommandRequest, CommandResult, CommandRunner, SystemCommandRunner};
use release_tool::domain::{ReleaseCandidate, ReleaseIntent};
use release_tool::git::GitRepository;
use release_tool::tag::CalendarTag;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use tempfile::TempDir;

#[test]
fn seal_tags_the_existing_commit_without_creating_a_release_commit() {
    let fixture = GitFixture::new();
    let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
    let snapshot = repository.snapshot("main").unwrap();
    let tag = snapshot.tags.next(Local::now().date_naive()).unwrap();
    let candidate = ReleaseCandidate {
        repository: "computed-parameter/example".to_owned(),
        commit: snapshot.head.clone(),
        tag,
        intent: ReleaseIntent::New,
        tag_already_sealed: false,
    };
    let branch_before = fixture.remote_ref("refs/heads/main");

    let sealed = repository.seal(&candidate, "main").unwrap();

    assert_eq!(sealed.release.commit, snapshot.head);
    assert!(sealed.release.tag_already_sealed);
    assert_eq!(fixture.remote_ref("refs/heads/main"), branch_before);
    assert_eq!(
        fixture.remote_ref(&format!("refs/tags/{tag}^{{}}")),
        snapshot.head
    );
    assert_eq!(fixture.git(&["rev-parse", "HEAD"]), snapshot.head);
}

#[test]
fn local_only_annotated_tag_is_reused_after_a_rejected_push() {
    let fixture = GitFixture::new();
    let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
    let snapshot = repository.snapshot("main").unwrap();
    let tag = snapshot.tags.next(Local::now().date_naive()).unwrap();
    let candidate = ReleaseCandidate {
        repository: "computed-parameter/example".to_owned(),
        commit: snapshot.head,
        tag,
        intent: ReleaseIntent::New,
        tag_already_sealed: false,
    };
    fixture.reject_tag_pushes();

    let error = repository.seal(&candidate, "main").unwrap_err();
    assert!(
        error
            .to_string()
            .contains("local annotated tag retained for retry")
    );
    assert_eq!(
        fixture.git(&["cat-file", "-t", &format!("refs/tags/{tag}")]),
        "tag"
    );
    assert!(
        fixture
            .remote_ref_optional(&format!("refs/tags/{tag}"))
            .is_none()
    );

    fixture.allow_tag_pushes();
    let sealed = repository.seal(&candidate, "main").unwrap();
    assert!(sealed.release.tag_already_sealed);
    assert_eq!(
        fixture.remote_ref(&format!("refs/tags/{tag}^{{}}")),
        candidate.commit
    );
}

#[test]
fn failed_push_response_is_treated_as_success_when_remote_tag_is_complete() {
    let fixture = GitFixture::new();
    let repository = GitRepository::new(
        &fixture.work,
        Arc::new(LostPushResponseRunner(SystemCommandRunner)),
    );
    let snapshot = repository.snapshot("main").unwrap();
    let candidate = ReleaseCandidate {
        repository: "computed-parameter/example".to_owned(),
        commit: snapshot.head,
        tag: snapshot.tags.next(Local::now().date_naive()).unwrap(),
        intent: ReleaseIntent::New,
        tag_already_sealed: false,
    };

    let sealed = repository.seal(&candidate, "main").unwrap();
    assert!(sealed.release.tag_already_sealed);
    assert_eq!(
        fixture.remote_ref(&format!("refs/tags/{}^{{}}", candidate.tag)),
        candidate.commit
    );
}

#[test]
fn seal_stops_when_the_candidate_tag_is_lost_to_another_commit() {
    let fixture = GitFixture::new();
    let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
    let snapshot = repository.snapshot("main").unwrap();
    let candidate = ReleaseCandidate {
        repository: "computed-parameter/example".to_owned(),
        commit: snapshot.head,
        tag: snapshot.tags.next(Local::now().date_naive()).unwrap(),
        intent: ReleaseIntent::New,
        tag_already_sealed: false,
    };
    fixture.publish_tag_on_other_commit(&candidate.tag.to_string());

    let error = repository.seal(&candidate, "main").unwrap_err();

    assert!(error.to_string().contains("points to"));
    assert!(error.to_string().contains("expected"));
}

#[test]
fn seal_stops_when_a_newer_tag_wins_the_candidate_race() {
    let fixture = GitFixture::new();
    let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
    let snapshot = repository.snapshot("main").unwrap();
    let candidate = ReleaseCandidate {
        repository: "computed-parameter/example".to_owned(),
        commit: snapshot.head,
        tag: snapshot.tags.next(Local::now().date_naive()).unwrap(),
        intent: ReleaseIntent::New,
        tag_already_sealed: false,
    };
    let racing_tag = format!("{}.2", candidate.tag.date().format("%Y.%m.%d"));
    fixture.publish_tag_on_other_commit(&racing_tag);

    let error = repository.seal(&candidate, "main").unwrap_err();

    assert!(error.to_string().contains("release candidate is stale"));
}

#[test]
fn existing_intent_never_recreates_a_disappeared_remote_tag() {
    let fixture = GitFixture::new();
    let tag = format!("{}.1", Local::now().date_naive().format("%Y.%m.%d"));
    run(&fixture.work, &["git", "tag", "-a", &tag, "-m", &tag]);
    run(&fixture.work, &["git", "push", "origin", &tag]);
    let repository = GitRepository::new(&fixture.work, Arc::new(SystemCommandRunner));
    let snapshot = repository.snapshot("main").unwrap();
    let candidate = ReleaseCandidate {
        repository: "computed-parameter/example".to_owned(),
        commit: snapshot.head,
        tag: CalendarTag::parse(&tag).unwrap(),
        intent: ReleaseIntent::Existing,
        tag_already_sealed: true,
    };
    run(
        &fixture.work,
        &["git", "push", "origin", &format!(":refs/tags/{tag}")],
    );

    let error = repository.seal(&candidate, "main").unwrap_err();

    assert!(error.to_string().contains("disappeared"));
    assert!(
        fixture
            .remote_ref_optional(&format!("refs/tags/{tag}"))
            .is_none()
    );
}

struct LostPushResponseRunner(SystemCommandRunner);

impl CommandRunner for LostPushResponseRunner {
    fn execute(&self, request: &CommandRequest) -> Result<CommandResult> {
        let mut result = self.0.execute(request)?;
        if request.program == "git"
            && request.arguments.first().map(String::as_str) == Some("push")
            && result.status == 0
        {
            result.status = 1;
            result.stderr = "simulated lost push response".to_owned();
        }
        Ok(result)
    }
}

struct GitFixture {
    _root: TempDir,
    work: std::path::PathBuf,
    remote: std::path::PathBuf,
}

impl GitFixture {
    fn new() -> Self {
        let root = tempfile::tempdir().unwrap();
        let work = root.path().join("work");
        let remote = root.path().join("remote.git");
        run(
            root.path(),
            &["git", "init", "--bare", remote.to_str().unwrap()],
        );
        run(
            root.path(),
            &[
                "git",
                "init",
                "--initial-branch=main",
                work.to_str().unwrap(),
            ],
        );
        run(&work, &["git", "config", "user.name", "Release Test"]);
        run(
            &work,
            &["git", "config", "user.email", "release@example.com"],
        );
        fs::write(work.join("seed"), "seed\n").unwrap();
        run(&work, &["git", "add", "--", "seed"]);
        run(&work, &["git", "commit", "-m", "Seed"]);
        run(
            &work,
            &["git", "remote", "add", "origin", remote.to_str().unwrap()],
        );
        run(&work, &["git", "push", "-u", "origin", "main"]);
        Self {
            _root: root,
            work,
            remote,
        }
    }

    fn reject_tag_pushes(&self) {
        let hook = self.remote.join("hooks/pre-receive");
        fs::write(
            &hook,
            "#!/usr/bin/env bash\nset -eu\nwhile read -r old new ref; do\n  case \"$ref\" in refs/tags/*) exit 1 ;; esac\ndone\n",
        )
        .unwrap();
        let mut permissions = fs::metadata(&hook).unwrap().permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(hook, permissions).unwrap();
    }

    fn allow_tag_pushes(&self) {
        fs::remove_file(self.remote.join("hooks/pre-receive")).unwrap();
    }

    fn publish_tag_on_other_commit(&self, tag: &str) {
        let other = self.git(&["commit-tree", "HEAD^{tree}", "-m", "Concurrent release"]);
        run(&self.work, &["git", "tag", "-a", tag, "-m", tag, &other]);
        run(&self.work, &["git", "push", "origin", tag]);
    }

    fn git(&self, args: &[&str]) -> String {
        output(&self.work, "git", args)
    }

    fn remote_ref(&self, reference: &str) -> String {
        output(
            self._root.path(),
            "git",
            &[
                "--git-dir",
                self.remote.to_str().unwrap(),
                "rev-parse",
                reference,
            ],
        )
    }

    fn remote_ref_optional(&self, reference: &str) -> Option<String> {
        let output = Command::new("git")
            .args([
                "--git-dir",
                self.remote.to_str().unwrap(),
                "rev-parse",
                reference,
            ])
            .output()
            .unwrap();
        output
            .status
            .success()
            .then(|| String::from_utf8(output.stdout).unwrap().trim().to_owned())
    }
}

fn run(directory: &Path, command: &[&str]) {
    let result = Command::new(command[0])
        .args(&command[1..])
        .current_dir(directory)
        .output()
        .unwrap();
    assert!(
        result.status.success(),
        "command {:?} failed: {}",
        command,
        String::from_utf8_lossy(&result.stderr)
    );
}

fn output(directory: &Path, program: &str, args: &[&str]) -> String {
    let result = Command::new(program)
        .args(args)
        .current_dir(directory)
        .output()
        .unwrap();
    assert!(
        result.status.success(),
        "{program} {args:?} failed: {}",
        String::from_utf8_lossy(&result.stderr)
    );
    String::from_utf8(result.stdout).unwrap().trim().to_owned()
}