release-tool 0.3.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use release_tool::command::SystemCommandRunner;
use release_tool::config::{Config, TargetConfig};
use release_tool::domain::{ReleaseCandidate, ReleaseIntent};
use release_tool::tag::CalendarTag;
use release_tool::target::{DockerArchiveTarget, TargetAdapter};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;

#[test]
fn docker_target_prepares_a_verified_archive_manifest_inside_staging() {
    let root = tempfile::tempdir().unwrap();
    let staging = root.path().join("staging");
    let bin = root.path().join("bin");
    fs::create_dir(&bin).unwrap();
    let log = root.path().join("commands.log");
    executable(
        &bin.join("build-image"),
        "#!/usr/bin/env bash\nset -eu\nprintf 'build %s\\n' \"$*\" >> \"$COMMAND_LOG\"\n",
    );
    executable(
        &bin.join("check-image"),
        "#!/usr/bin/env bash\nset -eu\nprintf 'check %s\\n' \"$*\" >> \"$COMMAND_LOG\"\n",
    );
    executable(
        &bin.join("docker"),
        r#"#!/usr/bin/env bash
set -eu
case "$1 $2" in
  "image inspect") printf 'linux/amd64\n' ;;
  "save -o") printf 'deterministic docker archive\n' > "$3" ;;
  *) printf 'unexpected docker command: %s\n' "$*" >&2; exit 2 ;;
esac
"#,
    );
    executable(
        &bin.join("xz"),
        r#"#!/usr/bin/env bash
set -eu
input="$2"
cp "$input" "$input.xz"
rm "$input"
"#,
    );
    let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap());
    unsafe {
        std::env::set_var("PATH", path);
        std::env::set_var("COMMAND_LOG", &log);
    }

    let config = Config::parse(
        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 = ["build-image", "{image}"]
local_check = ["check-image", "{image}"]
"#,
    )
    .unwrap();
    let target = match &config.targets[0] {
        value @ TargetConfig::DockerArchive { .. } => value,
        _ => unreachable!(),
    };
    let release = ReleaseCandidate {
        repository: "computed-parameter/example".to_owned(),
        commit: "commit01".to_owned(),
        tag: CalendarTag::parse("2026.08.23.1").unwrap(),
        intent: ReleaseIntent::New,
        tag_already_sealed: false,
    };
    let adapter =
        DockerArchiveTarget::new(root.path(), target, Arc::new(SystemCommandRunner)).unwrap();
    let plan = adapter.resolve(&release).unwrap();
    let manifest = adapter
        .prepare(&plan, &Default::default(), &staging)
        .unwrap();

    assert_eq!(manifest.target, "app");
    assert_eq!(manifest.artifacts.len(), 2);
    assert_eq!(
        manifest.artifacts[0].path.file_name().unwrap(),
        "example-2026.08.23.1-linux-amd64.tar.xz"
    );
    assert!(manifest.artifacts[0].path.starts_with(&staging));
    assert_eq!(manifest.artifacts[0].sha256.len(), 64);
    assert_eq!(
        fs::read_to_string(&log).unwrap(),
        "build example:2026.08.23.1\ncheck example:2026.08.23.1\n"
    );
    let checksum = fs::read_to_string(&manifest.artifacts[1].path).unwrap();
    assert_eq!(
        checksum,
        format!(
            "{}  example-2026.08.23.1-linux-amd64.tar.xz\n",
            manifest.artifacts[0].sha256
        )
    );
}

fn executable(path: &std::path::Path, source: &str) {
    fs::write(path, source).unwrap();
    let mut permissions = fs::metadata(path).unwrap().permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).unwrap();
}