release-tool 0.3.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use chrono::NaiveDate;
use release_tool::command::SystemCommandRunner;
use release_tool::config::Config;
use release_tool::domain::ReleaseIntent;
use release_tool::git::RepositorySnapshot;
use release_tool::lifecycle::resolve_plan;
use release_tool::tag::TagCatalog;
use std::sync::Arc;

#[test]
fn selected_targets_cannot_claim_the_same_remote_artifact_identity() {
    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 = "one"
kind = "docker_archive"
publisher = "release"
default = true
platform = "linux/amd64"
image = "one:{version}"
asset = "shared-{version}.tar.xz"
build = ["just", "one"]
local_check = ["just", "check-one"]

[[targets]]
name = "two"
kind = "docker_archive"
publisher = "release"
default = true
platform = "linux/amd64"
image = "two:{version}"
asset = "shared-{version}.tar.xz"
build = ["just", "two"]
local_check = ["just", "check-two"]
"#,
    )
    .unwrap();

    let error = resolve(&config, &[], false).unwrap_err();

    assert!(error.to_string().contains("same artifact identity"));
    assert!(error.to_string().contains("one"));
    assert!(error.to_string().contains("two"));
}

#[test]
fn selected_targets_include_dependencies_in_stable_topological_order() {
    let config = Config::parse(
        r#"required_version = "0.3.0"

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

[[targets]]
name = "app"
kind = "oci_image"
default = true
depends_on = ["base-b", "base-a"]
image = "ghcr.io/computed-parameter/app:{version}"
platform = "linux/amd64"
reuse_check = ["check", "app"]
build = ["build", "app"]

[[targets]]
name = "base-b"
kind = "oci_image"
image = "ghcr.io/computed-parameter/base-b:{version}"
platform = "linux/amd64"
reuse_check = ["check", "base-b"]
build = ["build", "base-b"]

[[targets]]
name = "base-a"
kind = "oci_image"
depends_on = ["foundation"]
image = "ghcr.io/computed-parameter/base-a:{version}"
platform = "linux/amd64"
reuse_check = ["check", "base-a"]
build = ["build", "base-a"]

[[targets]]
name = "foundation"
kind = "oci_image"
image = "ghcr.io/computed-parameter/foundation:{version}"
platform = "linux/amd64"
reuse_check = ["check", "foundation"]
build = ["build", "foundation"]
"#,
    )
    .unwrap();

    let plan = resolve(&config, &[], false).unwrap();
    assert_eq!(
        plan.targets
            .iter()
            .map(|target| target.name.as_str())
            .collect::<Vec<_>>(),
        ["base-b", "foundation", "base-a", "app"]
    );
    assert!(
        plan.targets
            .iter()
            .all(|target| target.publisher == "oci_registry")
    );

    let base_only = resolve(&config, &["base-b".to_owned()], false).unwrap();
    assert_eq!(base_only.targets.len(), 1);
    assert_eq!(base_only.targets[0].name, "base-b");

    let all = resolve_all(&config).unwrap();
    assert_eq!(
        all.targets
            .iter()
            .map(|target| target.name.as_str())
            .collect::<Vec<_>>(),
        ["base-b", "foundation", "base-a", "app"]
    );
}

#[test]
fn oci_platforms_cannot_claim_the_same_repository_tag() {
    let config = Config::parse(
        r#"required_version = "0.3.0"

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

[[targets]]
name = "amd"
kind = "oci_image"
default = true
image = "ghcr.io/computed-parameter/fixture:{version}"
platform = "linux/amd64"
reuse_check = ["check", "amd"]
build = ["build", "amd"]

[[targets]]
name = "arm"
kind = "oci_image"
default = true
image = "ghcr.io/computed-parameter/fixture:{version}"
platform = "linux/arm64"
reuse_check = ["check", "arm"]
build = ["build", "arm"]
"#,
    )
    .unwrap();

    let error = resolve(&config, &[], false).unwrap_err();

    assert!(error.to_string().contains("same OCI tag"));
    assert!(error.to_string().contains("platform"));
    assert!(error.to_string().contains("amd"));
    assert!(error.to_string().contains("arm"));
}

fn resolve(
    config: &Config,
    requested: &[String],
    all: bool,
) -> anyhow::Result<release_tool::domain::ReleasePlan> {
    let snapshot = RepositorySnapshot {
        root: ".".into(),
        branch: "main".to_owned(),
        head: "commit01".to_owned(),
        remote_branch_head: "commit01".to_owned(),
        tags: TagCatalog::default(),
    };
    resolve_plan(
        config,
        &snapshot,
        ReleaseIntent::New,
        NaiveDate::from_ymd_opt(2026, 8, 24).unwrap(),
        requested,
        all,
        Arc::new(SystemCommandRunner),
    )
}

fn resolve_all(config: &Config) -> anyhow::Result<release_tool::domain::ReleasePlan> {
    let snapshot = RepositorySnapshot {
        root: ".".into(),
        branch: "main".to_owned(),
        head: "commit01".to_owned(),
        remote_branch_head: "commit01".to_owned(),
        tags: TagCatalog::default(),
    };
    resolve_plan(
        config,
        &snapshot,
        ReleaseIntent::New,
        NaiveDate::from_ymd_opt(2026, 8, 24).unwrap(),
        &[],
        true,
        Arc::new(SystemCommandRunner),
    )
}