release-tool 0.4.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::{ArtifactIdentity, ReleaseCandidate, ReleaseIntent};
use release_tool::tag::CalendarTag;
use release_tool::target::{MavenReactorTarget, TargetAdapter};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::sync::Arc;

#[test]
fn maven_target_derives_coordinates_from_the_reactor_and_prepares_exact_artifacts() {
    let root = tempfile::tempdir().unwrap();
    write_reactor(root.path());
    let config = config();
    let target_config = match &config.targets[0] {
        value @ TargetConfig::MavenReactor { .. } => value,
        _ => unreachable!(),
    };
    let adapter =
        MavenReactorTarget::new(root.path(), target_config, Arc::new(SystemCommandRunner)).unwrap();
    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: true,
    };

    let plan = adapter.resolve(&release).unwrap();
    let identities = plan
        .artifacts
        .iter()
        .map(|identity| match identity {
            ArtifactIdentity::MavenPackage {
                group_id,
                artifact_id,
                version,
                extension,
            } => format!("{group_id}:{artifact_id}:{version}:{extension}"),
            _ => unreachable!(),
        })
        .collect::<Vec<_>>();
    assert_eq!(
        identities,
        [
            "com.example:example-sdk:2026.08.23.1:jar",
            "com.example:example-sdk:2026.08.23.1:pom",
            "com.example:example-model:2026.08.23.1:jar",
            "com.example:example-model:2026.08.23.1:pom",
        ]
    );

    let manifest = adapter
        .prepare(&plan, &Default::default(), &root.path().join("staging"))
        .unwrap();
    assert_eq!(manifest.artifacts.len(), 4);
    assert!(
        manifest
            .artifacts
            .iter()
            .all(|artifact| artifact.path.is_file())
    );
    assert!(
        manifest
            .artifacts
            .iter()
            .all(|artifact| artifact.sha256.len() == 64)
    );
    assert_eq!(
        fs::read_to_string(root.path().join("maven.log")).unwrap(),
        "-f pom.xml -pl model -am -Drevision=2026.08.23.1 --batch-mode --no-transfer-progress verify\n"
    );
}

#[test]
fn maven_target_fails_when_the_exact_artifact_inventory_is_not_supported() {
    let root = tempfile::tempdir().unwrap();
    write_reactor(root.path());
    let model_pom = root.path().join("model/pom.xml");
    let source = fs::read_to_string(&model_pom).unwrap().replace(
        "<version>${revision}</version>\n  <dependencies>",
        "<version>${revision}</version>\n  <packaging>war</packaging>\n  <dependencies>",
    );
    fs::write(model_pom, source).unwrap();
    let config = config();
    let adapter = MavenReactorTarget::new(
        root.path(),
        &config.targets[0],
        Arc::new(SystemCommandRunner),
    )
    .unwrap();
    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: true,
    };

    let error = adapter.resolve(&release).unwrap_err();

    assert!(error.to_string().contains("unsupported packaging `war`"));
}

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"

[[targets]]
name = "maven"
kind = "maven_reactor"
publisher = "packages"
default = true
wrapper = "./mvnw"
pom = "pom.xml"
projects = ["model"]
also_make = true
remote_check = []
"#,
    )
    .unwrap()
}

fn write_reactor(root: &std::path::Path) {
    fs::create_dir(root.join("sdk")).unwrap();
    fs::create_dir(root.join("model")).unwrap();
    fs::write(
        root.join("pom.xml"),
        r#"<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>parent</artifactId>
  <version>${revision}</version>
  <packaging>pom</packaging>
  <modules><module>sdk</module><module>model</module></modules>
</project>
"#,
    )
    .unwrap();
    fs::write(
        root.join("sdk/pom.xml"),
        r#"<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>example-sdk</artifactId>
  <version>${revision}</version>
</project>
"#,
    )
    .unwrap();
    fs::write(
        root.join("model/pom.xml"),
        r#"<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>example-model</artifactId>
  <version>${revision}</version>
  <dependencies><dependency>
    <groupId>com.example</groupId>
    <artifactId>example-sdk</artifactId>
    <version>${revision}</version>
  </dependency></dependencies>
</project>
"#,
    )
    .unwrap();
    fs::create_dir(root.join(".mvn")).unwrap();
    fs::write(root.join(".mvn/settings.xml"), "<settings/>\n").unwrap();
    let wrapper = root.join("mvnw");
    fs::write(
        &wrapper,
        r#"#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> maven.log
version=""
for arg in "$@"; do
  case "$arg" in -Drevision=*) version="${arg#-Drevision=}" ;; esac
done
for project in sdk model; do
  artifact="example-$project"
  mkdir -p "$project/target"
  printf 'jar %s %s\n' "$artifact" "$version" > "$project/target/$artifact-$version.jar"
  cp "$project/pom.xml" "$project/target/flattened-pom.xml"
done
"#,
    )
    .unwrap();
    let mut permissions = fs::metadata(&wrapper).unwrap().permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(wrapper, permissions).unwrap();
}