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(),
})
}