release-tool 0.3.0

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::doctor::Secret;
use release_tool::domain::{
    ArtifactIdentity, ArtifactManifest, PreparedArtifact, ReleaseCandidate, ReleaseIntent,
    TargetPlan,
};
use release_tool::publisher::{GithubMavenPublisher, MavenRemote, PublicationState, Publisher};
use release_tool::tag::CalendarTag;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::sync::{Arc, Mutex};

#[test]
fn maven_publish_deploys_prepared_files_then_verifies_and_skips() {
    let root = tempfile::tempdir().unwrap();
    let manifest = manifest(root.path());
    let plan = plan(&manifest);
    let store = Arc::new(FakeMavenStore::default());
    let runner = Arc::new(FakeMavenRunner {
        store: store.clone(),
        calls: Mutex::new(Vec::new()),
        fail_response: false,
    });
    let config = config();
    let publisher = GithubMavenPublisher::new_with_remote(
        root.path(),
        "computed-parameter/example",
        "packages",
        config.publishers.get("packages").unwrap(),
        "./mvnw",
        "release-bot",
        Secret::new("super-secret-token"),
        runner.clone(),
        store,
    )
    .unwrap();

    assert_eq!(publisher.inspect(&plan).unwrap(), PublicationState::Absent);
    let first = publisher.publish(&manifest).unwrap();
    assert!(!first.skipped);
    assert_eq!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Complete
    );
    assert!(publisher.verify(&manifest).unwrap().verified);
    let calls = runner.calls.lock().unwrap().clone();
    assert_eq!(calls.len(), 1);
    assert_eq!(
        calls[0].environment.get("GITHUB_ACTOR").map(String::as_str),
        Some("release-bot")
    );
    assert_eq!(
        calls[0].environment.get("GITHUB_TOKEN").map(String::as_str),
        Some("super-secret-token")
    );
    assert!(
        !calls[0]
            .arguments
            .iter()
            .any(|argument| argument.contains("super-secret-token"))
    );

    let second = publisher.publish(&manifest).unwrap();
    assert!(second.skipped);
    assert_eq!(runner.calls.lock().unwrap().len(), 1);
}

#[test]
fn partial_maven_version_is_never_overwritten() {
    let root = tempfile::tempdir().unwrap();
    let manifest = manifest(root.path());
    let plan = plan(&manifest);
    let store = Arc::new(FakeMavenStore::default());
    let first = &manifest.artifacts[0];
    store.put(url_for(&first.identity), fs::read(&first.path).unwrap());
    let runner = Arc::new(FakeMavenRunner {
        store: store.clone(),
        calls: Mutex::new(Vec::new()),
        fail_response: false,
    });
    let config = config();
    let publisher = GithubMavenPublisher::new_with_remote(
        root.path(),
        "computed-parameter/example",
        "packages",
        config.publishers.get("packages").unwrap(),
        "./mvnw",
        "release-bot",
        Secret::new("token"),
        runner.clone(),
        store,
    )
    .unwrap();

    assert!(matches!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Partial { .. }
    ));
    let error = publisher.publish(&manifest).unwrap_err();
    assert!(
        error
            .to_string()
            .contains("partial Maven publication is not recoverable automatically")
    );
    assert!(runner.calls.lock().unwrap().is_empty());
}

#[test]
fn failed_deploy_response_is_success_when_remote_version_is_complete() {
    let root = tempfile::tempdir().unwrap();
    let manifest = manifest(root.path());
    let plan = plan(&manifest);
    let store = Arc::new(FakeMavenStore::default());
    let runner = Arc::new(FakeMavenRunner {
        store: store.clone(),
        calls: Mutex::new(Vec::new()),
        fail_response: true,
    });
    let config = config();
    let publisher = GithubMavenPublisher::new_with_remote(
        root.path(),
        "computed-parameter/example",
        "packages",
        config.publishers.get("packages").unwrap(),
        "./mvnw",
        "release-bot",
        Secret::new("token"),
        runner,
        store,
    )
    .unwrap();

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

#[test]
fn pom_only_project_deploys_one_exact_pom_identity() {
    let root = tempfile::tempdir().unwrap();
    let mut manifest = manifest(root.path());
    manifest.artifacts.remove(0);
    let plan = plan(&manifest);
    let store = Arc::new(FakeMavenStore::default());
    let runner = Arc::new(FakeMavenRunner {
        store: store.clone(),
        calls: Mutex::new(Vec::new()),
        fail_response: false,
    });
    let config = config();
    let publisher = GithubMavenPublisher::new_with_remote(
        root.path(),
        "computed-parameter/example",
        "packages",
        config.publishers.get("packages").unwrap(),
        "./mvnw",
        "release-bot",
        Secret::new("token"),
        runner.clone(),
        store,
    )
    .unwrap();

    publisher.publish(&manifest).unwrap();

    assert_eq!(
        publisher.inspect(&plan).unwrap(),
        PublicationState::Complete
    );
    let calls = runner.calls.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert!(
        calls[0]
            .arguments
            .iter()
            .any(|arg| arg == "-Dpackaging=pom")
    );
    assert!(
        calls[0]
            .arguments
            .iter()
            .any(|arg| arg == "-DgeneratePom=false")
    );
    assert!(
        !calls[0]
            .arguments
            .iter()
            .any(|arg| arg.starts_with("-DpomFile="))
    );
}

fn config() -> Config {
    Config::parse(
        r#"required_version = "0.1.0"

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

[publishers.packages]
kind = "github_maven"
settings = ".mvn/settings.xml"
server_id = "github"
"#,
    )
    .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::Existing,
        tag_already_sealed: true,
    }
}

fn manifest(root: &std::path::Path) -> ArtifactManifest {
    let directory = root.join("prepared");
    fs::create_dir_all(&directory).unwrap();
    let version = "2026.08.23.1";
    let jar = directory.join(format!("example-model-{version}.jar"));
    let pom = directory.join(format!("example-model-{version}.pom"));
    fs::write(&jar, b"jar bytes").unwrap();
    fs::write(&pom, b"<project/>\n").unwrap();
    let identities = [
        ArtifactIdentity::MavenPackage {
            group_id: "com.example".to_owned(),
            artifact_id: "example-model".to_owned(),
            version: version.to_owned(),
            extension: "jar".to_owned(),
        },
        ArtifactIdentity::MavenPackage {
            group_id: "com.example".to_owned(),
            artifact_id: "example-model".to_owned(),
            version: version.to_owned(),
            extension: "pom".to_owned(),
        },
    ];
    ArtifactManifest {
        target: "maven".to_owned(),
        publisher: "packages".to_owned(),
        release: release(),
        artifacts: vec![
            PreparedArtifact {
                identity: identities[0].clone(),
                sha256: hex::encode(Sha256::digest(b"jar bytes")),
                path: jar,
            },
            PreparedArtifact {
                identity: identities[1].clone(),
                sha256: hex::encode(Sha256::digest(b"<project/>\n")),
                path: pom,
            },
        ],
    }
}

fn plan(manifest: &ArtifactManifest) -> TargetPlan {
    TargetPlan {
        name: manifest.target.clone(),
        publisher: manifest.publisher.clone(),
        release: manifest.release.clone(),
        artifacts: manifest
            .artifacts
            .iter()
            .map(|artifact| artifact.identity.clone())
            .collect(),
    }
}

#[derive(Default)]
struct FakeMavenStore {
    files: Mutex<BTreeMap<String, Vec<u8>>>,
}

impl FakeMavenStore {
    fn put(&self, url: String, bytes: Vec<u8>) {
        self.files.lock().unwrap().insert(url, bytes);
    }
}

impl MavenRemote for FakeMavenStore {
    fn get(&self, url: &str, _actor: &str, _token: &Secret) -> Result<Option<Vec<u8>>> {
        Ok(self.files.lock().unwrap().get(url).cloned())
    }
}

struct FakeMavenRunner {
    store: Arc<FakeMavenStore>,
    calls: Mutex<Vec<CommandRequest>>,
    fail_response: bool,
}

impl CommandRunner for FakeMavenRunner {
    fn execute(&self, request: &CommandRequest) -> Result<CommandResult> {
        self.calls.lock().unwrap().push(request.clone());
        let file = value(request, "-Dfile=").unwrap();
        let pom = value(request, "-DpomFile=").unwrap_or(file);
        let artifact_name = std::path::Path::new(file)
            .file_name()
            .unwrap()
            .to_str()
            .unwrap();
        let artifact = format!(
            "https://maven.pkg.github.com/computed-parameter/example/com/example/example-model/2026.08.23.1/{artifact_name}"
        );
        let pom_url = if artifact.ends_with(".pom") {
            artifact.clone()
        } else {
            artifact.trim_end_matches(".jar").to_owned() + ".pom"
        };
        self.store.put(artifact, fs::read(file).unwrap());
        self.store.put(pom_url, fs::read(pom).unwrap());
        Ok(CommandResult {
            status: i32::from(self.fail_response),
            stdout: String::new(),
            stderr: if self.fail_response {
                "simulated lost response".to_owned()
            } else {
                String::new()
            },
        })
    }
}

fn value<'a>(request: &'a CommandRequest, prefix: &str) -> Option<&'a str> {
    request
        .arguments
        .iter()
        .find_map(|argument| argument.strip_prefix(prefix))
}

fn url_for(identity: &ArtifactIdentity) -> String {
    match identity {
        ArtifactIdentity::MavenPackage {
            group_id,
            artifact_id,
            version,
            extension,
        } => format!(
            "https://maven.pkg.github.com/computed-parameter/example/{}/{artifact_id}/{version}/{artifact_id}-{version}.{extension}",
            group_id.replace('.', "/")
        ),
        _ => unreachable!(),
    }
}