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