release-tool 0.3.1

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use assert_cmd::Command;
use chrono::Local;
use predicates::prelude::*;
use std::fs;
use std::path::Path;
use std::process::Command as ProcessCommand;
use tempfile::TempDir;

#[test]
fn plan_release_derives_the_next_remote_tag_without_mutating_git() {
    let fixture = GitFixture::new();
    let today = Local::now().date_naive().format("%Y.%m.%d").to_string();
    let previous = format!("{today}.9");
    fixture.tag_previous_commit(&previous);
    let head = fixture.add_release_commit();
    let refs_before = fixture.remote_refs();

    Command::cargo_bin("release-tool")
        .unwrap()
        .current_dir(&fixture.repository)
        .args(["plan", "--release"])
        .assert()
        .success()
        .stdout(predicate::str::contains(format!("tag: {today}.10")))
        .stdout(predicate::str::contains(format!("commit: {head}")))
        .stdout(predicate::str::contains("target: app"));

    assert_eq!(fixture.remote_refs(), refs_before);
    assert_eq!(fixture.git(&["status", "--porcelain"]), "");
    assert_eq!(fixture.git(&["tag", "--points-at", "HEAD"]), "");
}

#[test]
fn plan_without_release_requires_an_existing_annotated_remote_tag() {
    let fixture = GitFixture::new();
    fixture.add_release_commit();

    Command::cargo_bin("release-tool")
        .unwrap()
        .current_dir(&fixture.repository)
        .arg("plan")
        .assert()
        .failure()
        .stderr(predicate::str::contains(
            "current commit has no annotated remote calendar release tag; use `--release`",
        ));
}

#[test]
fn plan_supports_named_targets_and_all_without_running_builds() {
    let fixture = GitFixture::new();
    fixture.add_release_commit();

    Command::cargo_bin("release-tool")
        .unwrap()
        .current_dir(&fixture.repository)
        .args(["plan", "--release", "--target", "rl"])
        .assert()
        .success()
        .stdout(predicate::str::contains("target: rl"))
        .stdout(predicate::str::contains("target: app").not());

    Command::cargo_bin("release-tool")
        .unwrap()
        .current_dir(&fixture.repository)
        .args(["plan", "--release", "--all"])
        .assert()
        .success()
        .stdout(predicate::str::contains("target: app"))
        .stdout(predicate::str::contains("target: rl"));
}

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

impl GitFixture {
    fn new() -> Self {
        let root = tempfile::tempdir().unwrap();
        let repository = 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",
                repository.to_str().unwrap(),
            ],
        );
        run(&repository, &["git", "config", "user.name", "Release Test"]);
        run(
            &repository,
            &["git", "config", "user.email", "release@example.com"],
        );
        fs::write(repository.join("seed"), "seed\n").unwrap();
        fs::write(
            repository.join("release.toml"),
            r#"required_version = "0.1.0"

[repository]
github = "computed-parameter/example"
branch = "main"

[publishers.release]
kind = "github_release"
title = "Example {version}"
prerelease = true

[[targets]]
name = "app"
kind = "docker_archive"
publisher = "release"
default = true
platform = "linux/amd64"
image = "example:{version}"
asset = "example-{version}-linux-amd64.tar.xz"
build = ["just", "_build-release-image", "{image}"]
local_check = ["just", "_check-release-image", "{image}"]

[[targets]]
name = "rl"
kind = "docker_archive"
publisher = "release"
platform = "linux/amd64"
image = "example:{version}-rl"
asset = "example-{version}-rl-linux-amd64.tar.xz"
build = ["just", "_build-release-image", "{image}"]
local_check = ["just", "_check-release-image", "{image}"]
"#,
        )
        .unwrap();
        run(&repository, &["git", "add", "--", "seed", "release.toml"]);
        run(&repository, &["git", "commit", "-m", "Seed"]);
        run(
            &repository,
            &["git", "remote", "add", "origin", remote.to_str().unwrap()],
        );
        run(&repository, &["git", "push", "-u", "origin", "main"]);
        Self {
            _root: root,
            repository,
            remote,
        }
    }

    fn tag_previous_commit(&self, tag: &str) {
        run(&self.repository, &["git", "tag", "-a", tag, "-m", tag]);
        run(&self.repository, &["git", "push", "origin", tag]);
    }

    fn add_release_commit(&self) -> String {
        fs::write(self.repository.join("release-change"), "ready\n").unwrap();
        run(&self.repository, &["git", "add", "--", "release-change"]);
        run(&self.repository, &["git", "commit", "-m", "Release change"]);
        run(&self.repository, &["git", "push", "origin", "main"]);
        self.git(&["rev-parse", "HEAD"])
    }

    fn git(&self, arguments: &[&str]) -> String {
        let output = ProcessCommand::new("git")
            .args(arguments)
            .current_dir(&self.repository)
            .output()
            .unwrap();
        assert!(output.status.success(), "git {:?} failed", arguments);
        String::from_utf8(output.stdout).unwrap().trim().to_owned()
    }

    fn remote_refs(&self) -> String {
        run_output(
            self._root.path(),
            &[
                "git",
                "--git-dir",
                self.remote.to_str().unwrap(),
                "show-ref",
            ],
        )
    }
}

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

fn run_output(directory: &Path, command: &[&str]) -> String {
    let output = ProcessCommand::new(command[0])
        .args(&command[1..])
        .current_dir(directory)
        .output()
        .unwrap();
    assert!(output.status.success(), "command {:?} failed", command);
    String::from_utf8(output.stdout).unwrap()
}