release-tool 0.2.1

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use anyhow::Result;
use release_tool::command::{CommandRequest, CommandResult, CommandRunner};
use release_tool::config::Config;
use release_tool::domain::{ReleaseCandidate, ReleaseIntent};
use release_tool::publisher::{GithubReleasePublisher, PublicationState, Publisher};
use release_tool::tag::CalendarTag;
use release_tool::target::{DockerArchiveTarget, TargetAdapter};
use std::collections::BTreeMap;
use std::fs;
use std::sync::{Arc, Mutex};

#[test]
fn github_release_publish_is_verified_and_idempotent() {
    let root = tempfile::tempdir().unwrap();
    let runner = Arc::new(FakeGithub::default());
    let config = config();
    let target_config = &config.targets[0];
    let target = DockerArchiveTarget::new(root.path(), target_config, runner.clone()).unwrap();
    let release = release();
    let plan = target.resolve(&release).unwrap();
    let manifest = prepared_manifest(root.path(), &plan);
    let publisher_config = config.publishers.get("release").unwrap();
    let publisher = GithubReleasePublisher::new(
        root.path(),
        &config.repository.github,
        "release",
        publisher_config,
        runner.clone(),
    )
    .unwrap();

    assert_eq!(publisher.inspect(&plan).unwrap(), PublicationState::Absent);
    let first = publisher.publish(&manifest).unwrap();
    assert!(!first.skipped);
    assert_eq!(first.tool_version, "0.2.1");
    assert_eq!(first.repository, "computed-parameter/example");
    assert_eq!(first.tag, "2026.08.23.1");
    assert_eq!(first.commit, "commit01");
    assert_eq!(first.artifacts.len(), 2);
    assert_eq!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Complete
    );
    assert!(publisher.verify(&manifest).unwrap().verified);
    assert!(publisher.verify_existing(&plan).unwrap().verified);
    let writes_after_first = runner.writes();

    let second = publisher.publish(&manifest).unwrap();
    assert!(second.skipped);
    assert_eq!(runner.writes(), writes_after_first);
}

#[test]
fn publish_refuses_an_artifact_changed_after_prepare() {
    let root = tempfile::tempdir().unwrap();
    let runner = Arc::new(FakeGithub::default());
    let config = config();
    let target = DockerArchiveTarget::new(root.path(), &config.targets[0], runner.clone()).unwrap();
    let plan = target.resolve(&release()).unwrap();
    let manifest = prepared_manifest(root.path(), &plan);
    fs::write(&manifest.artifacts[0].path, b"changed after prepare").unwrap();
    let publisher = GithubReleasePublisher::new(
        root.path(),
        &config.repository.github,
        "release",
        config.publishers.get("release").unwrap(),
        runner.clone(),
    )
    .unwrap();

    let error = publisher.publish(&manifest).unwrap_err();

    assert!(error.to_string().contains("changed after Prepare"));
    assert!(runner.writes().is_empty());
}

#[test]
fn partial_release_is_only_resumed_when_existing_bytes_match() {
    let root = tempfile::tempdir().unwrap();
    let runner = Arc::new(FakeGithub::default());
    let config = config();
    let target = DockerArchiveTarget::new(root.path(), &config.targets[0], runner.clone()).unwrap();
    let plan = target.resolve(&release()).unwrap();
    let manifest = prepared_manifest(root.path(), &plan);
    let first = &manifest.artifacts[0];
    runner.seed(
        first.path.file_name().unwrap().to_str().unwrap(),
        fs::read(&first.path).unwrap(),
    );
    let publisher = GithubReleasePublisher::new(
        root.path(),
        &config.repository.github,
        "release",
        config.publishers.get("release").unwrap(),
        runner.clone(),
    )
    .unwrap();

    assert!(matches!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Partial { .. }
    ));
    publisher.publish(&manifest).unwrap();
    assert_eq!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Complete
    );

    let mismatch_runner = Arc::new(FakeGithub::default());
    mismatch_runner.seed(
        first.path.file_name().unwrap().to_str().unwrap(),
        b"different bytes".to_vec(),
    );
    let mismatch_publisher = GithubReleasePublisher::new(
        root.path(),
        &config.repository.github,
        "release",
        config.publishers.get("release").unwrap(),
        mismatch_runner,
    )
    .unwrap();
    let error = mismatch_publisher.publish(&manifest).unwrap_err();
    assert!(error.to_string().contains("remote asset digest mismatch"));
}

#[test]
fn failed_create_response_is_success_when_remote_release_is_complete() {
    let root = tempfile::tempdir().unwrap();
    let runner = Arc::new(FakeGithub::default());
    runner.fail_next_write_response();
    let config = config();
    let target = DockerArchiveTarget::new(root.path(), &config.targets[0], runner.clone()).unwrap();
    let plan = target.resolve(&release()).unwrap();
    let manifest = prepared_manifest(root.path(), &plan);
    let publisher = GithubReleasePublisher::new(
        root.path(),
        &config.repository.github,
        "release",
        config.publishers.get("release").unwrap(),
        runner,
    )
    .unwrap();

    let receipt = publisher.publish(&manifest).unwrap();
    assert!(!receipt.skipped);
    assert_eq!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Complete
    );
}

#[test]
fn draft_release_is_never_mistaken_for_a_complete_publication() {
    let root = tempfile::tempdir().unwrap();
    let runner = Arc::new(FakeGithub::default());
    runner.mark_draft();
    let config = config();
    let target = DockerArchiveTarget::new(root.path(), &config.targets[0], runner.clone()).unwrap();
    let plan = target.resolve(&release()).unwrap();
    let publisher = GithubReleasePublisher::new(
        root.path(),
        &config.repository.github,
        "release",
        config.publishers.get("release").unwrap(),
        runner,
    )
    .unwrap();

    assert!(matches!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Invalid { reason } if reason.contains("draft")
    ));
}

fn config() -> 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}.tar.xz"
build = ["unused"]
local_check = ["unused"]
"#,
    )
    .unwrap()
}

fn release() -> ReleaseCandidate {
    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: true,
    }
}

fn prepared_manifest(
    root: &std::path::Path,
    plan: &release_tool::domain::TargetPlan,
) -> release_tool::domain::ArtifactManifest {
    use release_tool::domain::{ArtifactManifest, PreparedArtifact};
    use sha2::{Digest, Sha256};

    let staging = root.join("prepared");
    fs::create_dir_all(&staging).unwrap();
    let archive = b"archive bytes".as_slice();
    let archive_name = match &plan.artifacts[0] {
        release_tool::domain::ArtifactIdentity::GithubReleaseAsset { name } => name,
        _ => unreachable!(),
    };
    let checksum = format!("{}  {archive_name}\n", hex::encode(Sha256::digest(archive)));
    let contents = [archive, checksum.as_bytes()];
    let artifacts = plan
        .artifacts
        .iter()
        .zip(contents)
        .map(|(identity, bytes)| {
            let name = match identity {
                release_tool::domain::ArtifactIdentity::GithubReleaseAsset { name } => name,
                _ => unreachable!(),
            };
            let path = staging.join(name);
            fs::write(&path, bytes).unwrap();
            PreparedArtifact {
                identity: identity.clone(),
                path,
                sha256: hex::encode(Sha256::digest(bytes)),
            }
        })
        .collect();
    ArtifactManifest {
        target: plan.name.clone(),
        publisher: plan.publisher.clone(),
        release: plan.release.clone(),
        artifacts,
    }
}

#[derive(Default)]
struct FakeGithub {
    state: Mutex<FakeGithubState>,
}

#[derive(Default)]
struct FakeGithubState {
    release_exists: bool,
    draft: bool,
    assets: BTreeMap<String, Vec<u8>>,
    writes: Vec<Vec<String>>,
    fail_next_write_response: bool,
}

impl FakeGithub {
    fn seed(&self, name: &str, bytes: Vec<u8>) {
        let mut state = self.state.lock().unwrap();
        state.release_exists = true;
        state.assets.insert(name.to_owned(), bytes);
    }

    fn writes(&self) -> Vec<Vec<String>> {
        self.state.lock().unwrap().writes.clone()
    }

    fn fail_next_write_response(&self) {
        self.state.lock().unwrap().fail_next_write_response = true;
    }

    fn mark_draft(&self) {
        let mut state = self.state.lock().unwrap();
        state.release_exists = true;
        state.draft = true;
    }
}

impl CommandRunner for FakeGithub {
    fn execute(&self, request: &CommandRequest) -> Result<CommandResult> {
        assert_eq!(request.program, "gh");
        let mut state = self.state.lock().unwrap();
        let args = &request.arguments;
        if args.first().map(String::as_str) == Some("api") {
            if !state.release_exists {
                return Ok(CommandResult {
                    status: 1,
                    stdout: String::new(),
                    stderr: "HTTP 404: Not Found".to_owned(),
                });
            }
            let assets = state
                .assets
                .keys()
                .map(|name| serde_json::json!({"name": name}))
                .collect::<Vec<_>>();
            return Ok(CommandResult {
                status: 0,
                stdout: serde_json::json!({
                    "tag_name": "2026.08.23.1",
                    "target_commitish": "main",
                    "prerelease": true,
                    "draft": state.draft,
                    "assets": assets,
                })
                .to_string(),
                stderr: String::new(),
            });
        }
        if args.starts_with(&["release".to_owned(), "create".to_owned()])
            || args.starts_with(&["release".to_owned(), "upload".to_owned()])
        {
            state.release_exists = true;
            state.writes.push(args.clone());
            for argument in args {
                let path = std::path::Path::new(argument);
                if path.is_file() {
                    state.assets.insert(
                        path.file_name().unwrap().to_str().unwrap().to_owned(),
                        fs::read(path).unwrap(),
                    );
                }
            }
            if state.fail_next_write_response {
                state.fail_next_write_response = false;
                return Ok(CommandResult {
                    status: 1,
                    stdout: String::new(),
                    stderr: "simulated lost response".to_owned(),
                });
            }
            return ok();
        }
        if args.starts_with(&["release".to_owned(), "download".to_owned()]) {
            let pattern = args[args.iter().position(|arg| arg == "--pattern").unwrap() + 1].clone();
            let directory = args[args.iter().position(|arg| arg == "--dir").unwrap() + 1].clone();
            let bytes = state.assets.get(&pattern).unwrap();
            fs::create_dir_all(&directory).unwrap();
            fs::write(std::path::Path::new(&directory).join(&pattern), bytes).unwrap();
            return ok();
        }
        panic!("unexpected gh request: {args:?}");
    }
}

fn ok() -> Result<CommandResult> {
    Ok(CommandResult {
        status: 0,
        stdout: String::new(),
        stderr: String::new(),
    })
}