use anyhow::{Context, Result, bail};
use release_tool::command::{CommandRequest, CommandResult, CommandRunner};
use release_tool::config::{Config, TargetConfig};
use release_tool::domain::{ArtifactManifest, ReleaseCandidate, ReleaseIntent};
use release_tool::publisher::{OciRegistryPublisher, PublicationState, Publisher};
use release_tool::tag::CalendarTag;
use release_tool::target::{OciImageTarget, PreparedDependencies, TargetAdapter};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::sync::{Arc, Mutex};
const COMMIT: &str = "1111111111111111111111111111111111111111";
#[test]
fn oci_targets_build_then_reuse_and_invalidate_downstream() {
let root = tempfile::tempdir().unwrap();
let runner = Arc::new(FakeOci::default());
let config = config();
let first = release("2026.08.25.1", true);
let (base_plan, base_manifest) = prepare(
root.path(),
&config.targets[0],
&first,
&PreparedDependencies::new(),
runner.clone(),
);
let dependencies = BTreeMap::from([("base".to_owned(), base_manifest.clone())]);
let (app_plan, app_manifest) = prepare(
root.path(),
&config.targets[1],
&first,
&dependencies,
runner.clone(),
);
assert_eq!(prepared_action(&base_manifest), "build");
assert_eq!(prepared_action(&app_manifest), "build");
assert_eq!(runner.builds(), ["base", "app"]);
let first_builds = runner.build_calls();
assert_eq!(first_builds[0].commit, COMMIT);
assert_eq!(
first_builds[0].image,
"ghcr.io/computed-parameter/base:2026.08.25.1"
);
assert_eq!(first_builds[0].candidate, None);
let first_app_dependency = &runner.build_calls()[1].dependencies["base"];
assert_eq!(
first_app_dependency.reference,
"ghcr.io/computed-parameter/base:2026.08.25.1"
);
assert!(first_app_dependency.config_digest.starts_with("sha256:"));
let base_publisher = publisher(root.path(), &config.targets[0], runner.clone());
base_publisher.publish(&base_manifest).unwrap();
let base_verification = base_publisher.verify(&base_manifest).unwrap();
assert_eq!(
base_verification.artifacts,
[format!(
"ghcr.io/computed-parameter/base@{}",
manifest_digest(&digest("base:v1:"))
)]
);
publisher(root.path(), &config.targets[1], runner.clone())
.publish(&app_manifest)
.unwrap();
assert_eq!(runner.pushes(), 2);
runner.add_sealed_release("2026.08.25.1");
let second = release("2026.08.25.2", true);
let (second_base_plan, second_base_manifest) = prepare(
root.path(),
&config.targets[0],
&second,
&PreparedDependencies::new(),
runner.clone(),
);
let dependencies = BTreeMap::from([("base".to_owned(), second_base_manifest.clone())]);
let (second_app_plan, second_app_manifest) = prepare(
root.path(),
&config.targets[1],
&second,
&dependencies,
runner.clone(),
);
assert_eq!(prepared_action(&second_base_manifest), "reuse");
assert_eq!(prepared_action(&second_app_manifest), "reuse");
assert_eq!(runner.builds(), ["base", "app"]);
let app_check = runner
.check_calls()
.into_iter()
.find(|call| call.target == "app" && call.version == "2026.08.25.2")
.unwrap();
assert!(
app_check.dependencies["base"]
.reference
.contains("@sha256:")
);
assert!(
app_check
.candidate
.as_deref()
.is_some_and(|candidate| candidate.contains("@sha256:"))
);
runner.fail_next_registry_write_response();
publisher(root.path(), &config.targets[0], runner.clone())
.publish(&second_base_manifest)
.unwrap();
publisher(root.path(), &config.targets[1], runner.clone())
.publish(&second_app_manifest)
.unwrap();
assert_eq!(runner.retags(), 2);
runner.add_sealed_release("2026.08.25.2");
let (_, existing_base) = prepare(
root.path(),
&config.targets[0],
&second,
&PreparedDependencies::new(),
runner.clone(),
);
assert_eq!(prepared_action(&existing_base), "existing");
let existing_dependencies = BTreeMap::from([("base".to_owned(), existing_base)]);
let (_, existing_app) = prepare(
root.path(),
&config.targets[1],
&second,
&existing_dependencies,
runner.clone(),
);
assert_eq!(prepared_action(&existing_app), "existing");
let existing_receipt = publisher(root.path(), &config.targets[1], runner.clone())
.publish(&existing_app)
.unwrap();
assert!(existing_receipt.skipped);
runner.set_input("base", "v2");
let third = release("2026.08.25.3", true);
let (_, third_base) = prepare(
root.path(),
&config.targets[0],
&third,
&PreparedDependencies::new(),
runner.clone(),
);
let dependencies = BTreeMap::from([("base".to_owned(), third_base.clone())]);
let (_, third_app) = prepare(
root.path(),
&config.targets[1],
&third,
&dependencies,
runner.clone(),
);
assert_eq!(prepared_action(&third_base), "build");
assert_eq!(prepared_action(&third_app), "build");
assert_eq!(runner.builds(), ["base", "app", "base", "app"]);
assert_eq!(base_plan.name, "base");
assert_eq!(app_plan.name, "app");
assert_eq!(second_base_plan.name, "base");
assert_eq!(second_app_plan.name, "app");
}
#[test]
fn oci_publisher_refuses_conflicts_and_unsealed_orphans() {
let root = tempfile::tempdir().unwrap();
let runner = Arc::new(FakeOci::default());
let config = config();
let sealed = release("2026.08.25.1", true);
let (plan, manifest) = prepare(
root.path(),
&config.targets[0],
&sealed,
&PreparedDependencies::new(),
runner.clone(),
);
runner.seed_conflicting_current("ghcr.io/computed-parameter/base:2026.08.25.1");
let registry = publisher(root.path(), &config.targets[0], runner.clone());
let error = registry.publish(&manifest).unwrap_err();
assert!(format!("{error:#}").contains("differs from Prepare"));
assert_eq!(runner.pushes(), 0);
let unsealed = release("2026.08.25.2", false);
let target = OciImageTarget::new(root.path(), &config.targets[0], runner.clone()).unwrap();
let unsealed_plan = target.resolve(&unsealed).unwrap();
runner.seed_remote("ghcr.io/computed-parameter/base:2026.08.25.2", "orphan");
let state = publisher(root.path(), &config.targets[0], runner.clone())
.inspect(&unsealed_plan)
.unwrap();
assert!(matches!(
state,
PublicationState::Invalid { reason } if reason.contains("orphan publication")
));
assert_eq!(plan.name, "base");
}
#[test]
fn reuse_check_errors_and_stale_current_tags_fail_closed() {
let root = tempfile::tempdir().unwrap();
let runner = Arc::new(FakeOci::default());
let config = config();
let first = release("2026.08.25.1", true);
let (_, first_manifest) = prepare(
root.path(),
&config.targets[0],
&first,
&PreparedDependencies::new(),
runner.clone(),
);
publisher(root.path(), &config.targets[0], runner.clone())
.publish(&first_manifest)
.unwrap();
runner.add_sealed_release("2026.08.25.1");
runner.fail_reuse_check("base");
let second = release("2026.08.25.2", true);
let target = OciImageTarget::new(root.path(), &config.targets[0], runner.clone()).unwrap();
let plan = target.resolve(&second).unwrap();
let error = target
.prepare(&plan, &PreparedDependencies::new(), root.path())
.unwrap_err();
assert!(format!("{error:#}").contains("exit status 2"));
runner.clear_reuse_check_failure();
runner.set_input("base", "v2");
runner.seed_remote(
"ghcr.io/computed-parameter/base:2026.08.25.2",
"stale-current",
);
let error = target
.prepare(&plan, &PreparedDependencies::new(), root.path())
.unwrap_err();
assert!(format!("{error:#}").contains("refusing to overwrite"));
}
fn config() -> Config {
Config::parse(
r#"required_version = "0.3.0"
[repository]
github = "computed-parameter/example"
branch = "main"
[[targets]]
name = "base"
kind = "oci_image"
image = "ghcr.io/computed-parameter/base:{version}"
platform = "linux/amd64"
reuse_check = ["reuse-check"]
build = ["build-image"]
[[targets]]
name = "app"
kind = "oci_image"
default = true
depends_on = ["base"]
image = "ghcr.io/computed-parameter/app:{version}"
platform = "linux/amd64"
reuse_check = ["reuse-check"]
build = ["build-image"]
"#,
)
.unwrap()
}
fn prepare(
root: &std::path::Path,
config: &TargetConfig,
release: &ReleaseCandidate,
dependencies: &PreparedDependencies,
runner: Arc<FakeOci>,
) -> (release_tool::domain::TargetPlan, ArtifactManifest) {
let target = OciImageTarget::new(root, config, runner).unwrap();
let plan = target.resolve(release).unwrap();
let staging = tempfile::tempdir().unwrap();
let manifest = target.prepare(&plan, dependencies, staging.path()).unwrap();
let retained = root.join(format!("{}-{}.oci.json", plan.name, plan.release.tag));
fs::copy(&manifest.artifacts[0].path, &retained).unwrap();
let mut manifest = manifest;
manifest.artifacts[0].path = retained;
(plan, manifest)
}
fn publisher(
root: &std::path::Path,
config: &TargetConfig,
runner: Arc<FakeOci>,
) -> OciRegistryPublisher {
OciRegistryPublisher::new(root, config, runner).unwrap()
}
fn release(tag: &str, sealed: bool) -> ReleaseCandidate {
ReleaseCandidate {
repository: "computed-parameter/example".to_owned(),
commit: COMMIT.to_owned(),
tag: CalendarTag::parse(tag).unwrap(),
intent: ReleaseIntent::New,
tag_already_sealed: sealed,
}
}
fn prepared_action(manifest: &ArtifactManifest) -> String {
let value: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest.artifacts[0].path).unwrap()).unwrap();
value["action"]["kind"].as_str().unwrap().to_owned()
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
struct DependencyValue {
reference: String,
config_digest: String,
}
#[derive(Clone, Debug)]
struct ProjectCall {
target: String,
version: String,
commit: String,
image: String,
candidate: Option<String>,
dependencies: BTreeMap<String, DependencyValue>,
}
#[derive(Clone, Debug)]
struct ImageRecord {
manifest_digest: String,
config_digest: String,
platform: String,
}
#[derive(Default)]
struct FakeOci {
state: Mutex<FakeState>,
}
#[derive(Default)]
struct FakeState {
local: BTreeMap<String, ImageRecord>,
remote: BTreeMap<String, ImageRecord>,
inputs: BTreeMap<String, String>,
sealed_releases: Vec<String>,
builds: Vec<ProjectCall>,
checks: Vec<ProjectCall>,
pushes: usize,
retags: usize,
fail_next_registry_write_response: bool,
failed_check_target: Option<String>,
}
impl FakeOci {
fn builds(&self) -> Vec<&'static str> {
self.state
.lock()
.unwrap()
.builds
.iter()
.map(|call| match call.target.as_str() {
"base" => "base",
"app" => "app",
other => panic!("unexpected target {other}"),
})
.collect()
}
fn build_calls(&self) -> Vec<ProjectCall> {
self.state.lock().unwrap().builds.clone()
}
fn check_calls(&self) -> Vec<ProjectCall> {
self.state.lock().unwrap().checks.clone()
}
fn pushes(&self) -> usize {
self.state.lock().unwrap().pushes
}
fn retags(&self) -> usize {
self.state.lock().unwrap().retags
}
fn set_input(&self, target: &str, value: &str) {
self.state
.lock()
.unwrap()
.inputs
.insert(target.to_owned(), value.to_owned());
}
fn add_sealed_release(&self, tag: &str) {
self.state
.lock()
.unwrap()
.sealed_releases
.push(tag.to_owned());
}
fn fail_next_registry_write_response(&self) {
self.state.lock().unwrap().fail_next_registry_write_response = true;
}
fn fail_reuse_check(&self, target: &str) {
self.state.lock().unwrap().failed_check_target = Some(target.to_owned());
}
fn clear_reuse_check_failure(&self) {
self.state.lock().unwrap().failed_check_target = None;
}
fn seed_conflicting_current(&self, reference: &str) {
self.seed_remote(reference, "conflict");
}
fn seed_remote(&self, reference: &str, value: &str) {
let config_digest = digest(value);
let image = ImageRecord {
manifest_digest: manifest_digest(&config_digest),
config_digest,
platform: "linux/amd64".to_owned(),
};
self.state
.lock()
.unwrap()
.remote
.insert(reference.to_owned(), image);
}
}
impl CommandRunner for FakeOci {
fn execute(&self, request: &CommandRequest) -> Result<CommandResult> {
let mut state = self.state.lock().unwrap();
match request.program.as_str() {
"git" => git(&state, &request.arguments),
"reuse-check" => reuse_check(&mut state, request),
"build-image" => build_image(&mut state, request),
"docker" => docker(&mut state, &request.arguments),
program => bail!("unexpected fake command `{program}`"),
}
}
}
fn project_call(request: &CommandRequest) -> Result<ProjectCall> {
Ok(ProjectCall {
target: request.environment["RELEASE_TARGET"].clone(),
version: request.environment["RELEASE_VERSION"].clone(),
commit: request.environment["RELEASE_COMMIT"].clone(),
image: request.environment["RELEASE_IMAGE"].clone(),
candidate: request.environment.get("RELEASE_CANDIDATE_IMAGE").cloned(),
dependencies: serde_json::from_str(&request.environment["RELEASE_DEPENDENCIES_JSON"])
.context("invalid RELEASE_DEPENDENCIES_JSON")?,
})
}
fn desired_config(state: &FakeState, call: &ProjectCall) -> String {
let input = state.inputs.get(&call.target).map_or("v1", String::as_str);
let dependencies = call
.dependencies
.iter()
.map(|(name, dependency)| format!("{name}={}", dependency.config_digest))
.collect::<Vec<_>>()
.join(",");
digest(&format!("{}:{input}:{dependencies}", call.target))
}
fn reuse_check(state: &mut FakeState, request: &CommandRequest) -> Result<CommandResult> {
let call = project_call(request)?;
if state.failed_check_target.as_deref() == Some(&call.target) {
return Ok(CommandResult {
status: 2,
stdout: String::new(),
stderr: "simulated check failure".to_owned(),
});
}
let candidate = &request.environment["RELEASE_CANDIDATE_IMAGE"];
let image = remote_image(state, candidate)
.with_context(|| format!("missing reuse candidate {candidate}"))?;
let status = if image.config_digest == desired_config(state, &call) {
0
} else {
10
};
state.checks.push(call);
Ok(CommandResult {
status,
stdout: String::new(),
stderr: String::new(),
})
}
fn build_image(state: &mut FakeState, request: &CommandRequest) -> Result<CommandResult> {
let call = project_call(request)?;
let reference = request.environment["RELEASE_IMAGE"].clone();
let config_digest = desired_config(state, &call);
state.local.insert(
reference,
ImageRecord {
manifest_digest: manifest_digest(&config_digest),
config_digest,
platform: "linux/amd64".to_owned(),
},
);
state.builds.push(call);
ok("")
}
fn git(state: &FakeState, arguments: &[String]) -> Result<CommandResult> {
if arguments == ["ls-remote", "--tags", "origin"] {
let mut output = String::new();
for (index, tag) in state.sealed_releases.iter().enumerate() {
output.push_str(&format!("tag-object-{index}\trefs/tags/{tag}\n"));
output.push_str(&format!("commit-{index}\trefs/tags/{tag}^{{}}\n"));
}
return ok(&output);
}
bail!("unexpected fake git command: {arguments:?}")
}
fn docker(state: &mut FakeState, arguments: &[String]) -> Result<CommandResult> {
if arguments.starts_with(&[
"buildx".to_owned(),
"imagetools".to_owned(),
"inspect".to_owned(),
]) {
let reference = &arguments[3];
let Some(image) = remote_image(state, reference) else {
return Ok(CommandResult {
status: 1,
stdout: String::new(),
stderr: "manifest unknown: not found".to_owned(),
});
};
if arguments.last().unwrap() == "{{json .Manifest}}" {
return ok(&json!({"digest": image.manifest_digest}).to_string());
}
if arguments.last().unwrap() == "--raw" {
return ok(&raw_manifest_json(&image.config_digest));
}
return ok(&json!({
"os": image.platform.split('/').next().unwrap(),
"architecture": image.platform.split('/').nth(1).unwrap(),
})
.to_string());
}
if arguments.starts_with(&["image".to_owned(), "inspect".to_owned()]) {
let reference = arguments.last().unwrap();
let image = state
.local
.get(reference)
.with_context(|| format!("missing local image {reference}"))?;
return ok(&json!({
"Id": image.config_digest,
"Os": image.platform.split('/').next().unwrap(),
"Architecture": image.platform.split('/').nth(1).unwrap(),
})
.to_string());
}
if arguments.starts_with(&["image".to_owned(), "push".to_owned()]) {
let reference = &arguments[2];
let image = state
.local
.get(reference)
.with_context(|| format!("missing local image {reference}"))?
.clone();
state.remote.insert(reference.clone(), image);
state.pushes += 1;
return maybe_lose_write_response(state);
}
if arguments.starts_with(&[
"buildx".to_owned(),
"imagetools".to_owned(),
"create".to_owned(),
]) {
let tag_index = arguments
.iter()
.position(|argument| argument == "--tag")
.unwrap();
let current = arguments[tag_index + 1].clone();
let source = arguments.last().unwrap();
let image = remote_image(state, source)
.with_context(|| format!("missing reuse source {source}"))?;
state.remote.insert(current, image);
state.retags += 1;
return maybe_lose_write_response(state);
}
bail!("unexpected fake docker command: {arguments:?}")
}
fn maybe_lose_write_response(state: &mut FakeState) -> Result<CommandResult> {
if state.fail_next_registry_write_response {
state.fail_next_registry_write_response = false;
return Ok(CommandResult {
status: 1,
stdout: String::new(),
stderr: "simulated lost write response".to_owned(),
});
}
ok("")
}
fn remote_image(state: &FakeState, reference: &str) -> Option<ImageRecord> {
if let Some(image) = state.remote.get(reference) {
return Some(image.clone());
}
let (_, digest) = reference.rsplit_once('@')?;
state
.remote
.values()
.find(|image| image.manifest_digest == digest)
.cloned()
}
fn raw_manifest_json(config_digest: &str) -> String {
json!({
"schemaVersion": 2,
"config": {"digest": config_digest},
"layers": [],
})
.to_string()
}
fn manifest_digest(config_digest: &str) -> String {
digest(&raw_manifest_json(config_digest))
}
fn digest(value: &str) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(value.as_bytes())))
}
fn ok(stdout: &str) -> Result<CommandResult> {
Ok(CommandResult {
status: 0,
stdout: stdout.to_owned(),
stderr: String::new(),
})
}