use super::{
PublicationReceipt, PublicationState, Publisher, VerificationReport, receipt, validate_manifest,
};
use crate::command::{CommandRequest, CommandRunner};
use crate::config::PublisherConfig;
use crate::doctor::Secret;
use crate::domain::{ArtifactIdentity, ArtifactManifest, PreparedArtifact, TargetPlan};
use anyhow::{Context, Result, bail};
use reqwest::StatusCode;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
pub trait MavenRemote: Send + Sync {
fn get(&self, url: &str, actor: &str, token: &Secret) -> Result<Option<Vec<u8>>>;
}
struct ReqwestMavenRemote {
client: reqwest::blocking::Client,
}
impl ReqwestMavenRemote {
fn new() -> Result<Self> {
Ok(Self {
client: reqwest::blocking::Client::builder()
.user_agent(concat!("release-tool/", env!("CARGO_PKG_VERSION")))
.build()
.context("failed to construct Maven HTTP client")?,
})
}
}
impl MavenRemote for ReqwestMavenRemote {
fn get(&self, url: &str, actor: &str, token: &Secret) -> Result<Option<Vec<u8>>> {
let response = self
.client
.get(url)
.basic_auth(actor, Some(token.expose()))
.send()
.with_context(|| format!("failed to read Maven artifact {url}"))?;
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
let response = response
.error_for_status()
.with_context(|| format!("failed to read Maven artifact {url}"))?;
Ok(Some(
response
.bytes()
.context("failed to read Maven artifact body")?
.to_vec(),
))
}
}
pub struct GithubMavenPublisher {
root: PathBuf,
repository: String,
name: String,
settings: PathBuf,
server_id: String,
wrapper: PathBuf,
actor: String,
token: Secret,
runner: Arc<dyn CommandRunner>,
remote: Arc<dyn MavenRemote>,
}
impl GithubMavenPublisher {
#[allow(clippy::too_many_arguments)]
pub fn new(
root: impl Into<PathBuf>,
repository: &str,
name: &str,
config: &PublisherConfig,
wrapper: impl Into<PathBuf>,
actor: &str,
token: Secret,
runner: Arc<dyn CommandRunner>,
) -> Result<Self> {
Self::new_with_remote(
root,
repository,
name,
config,
wrapper,
actor,
token,
runner,
Arc::new(ReqwestMavenRemote::new()?),
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_remote(
root: impl Into<PathBuf>,
repository: &str,
name: &str,
config: &PublisherConfig,
wrapper: impl Into<PathBuf>,
actor: &str,
token: Secret,
runner: Arc<dyn CommandRunner>,
remote: Arc<dyn MavenRemote>,
) -> Result<Self> {
let PublisherConfig::GithubMaven {
settings,
server_id,
} = config
else {
bail!("publisher `{name}` is not a github_maven publisher");
};
Ok(Self {
root: root.into(),
repository: repository.to_owned(),
name: name.to_owned(),
settings: settings.clone(),
server_id: server_id.clone(),
wrapper: wrapper.into(),
actor: actor.to_owned(),
token,
runner,
remote,
})
}
fn base_url(&self) -> String {
format!("https://maven.pkg.github.com/{}", self.repository)
}
fn url(&self, identity: &ArtifactIdentity) -> Result<String> {
let ArtifactIdentity::MavenPackage {
group_id,
artifact_id,
version,
extension,
} = identity
else {
bail!("GitHub Maven publisher received a non-Maven artifact identity");
};
Ok(format!(
"{}/{}/{artifact_id}/{version}/{artifact_id}-{version}.{extension}",
self.base_url(),
group_id.replace('.', "/")
))
}
fn state_for(&self, identities: &[ArtifactIdentity]) -> Result<PublicationState> {
let mut present = Vec::new();
let mut missing = Vec::new();
for identity in identities {
let url = self.url(identity)?;
if self.remote.get(&url, &self.actor, &self.token)?.is_some() {
present.push(url);
} else {
missing.push(url);
}
}
if present.is_empty() {
Ok(PublicationState::Absent)
} else if missing.is_empty() {
Ok(PublicationState::Complete)
} else {
Ok(PublicationState::Partial { present, missing })
}
}
fn deploy(&self, manifest: &ArtifactManifest) -> Result<()> {
type Gav = (String, String, String);
let mut packages: BTreeMap<Gav, Vec<&PreparedArtifact>> = BTreeMap::new();
for artifact in &manifest.artifacts {
let ArtifactIdentity::MavenPackage {
group_id,
artifact_id,
version,
..
} = &artifact.identity
else {
bail!("Maven artifact manifest contains a non-Maven identity");
};
packages
.entry((group_id.clone(), artifact_id.clone(), version.clone()))
.or_default()
.push(artifact);
}
let mut deployments = Vec::new();
for ((group_id, artifact_id, version), artifacts) in &packages {
let pom = artifacts
.iter()
.find(|artifact| extension(&artifact.identity) == Some("pom"))
.with_context(|| format!("Maven package {group_id}:{artifact_id} has no POM"))?;
let primary_artifacts = artifacts
.iter()
.filter(|artifact| extension(&artifact.identity) != Some("pom"))
.copied()
.collect::<Vec<_>>();
if primary_artifacts.len() > 1 {
bail!(
"Maven package {group_id}:{artifact_id}:{version} has more than one primary artifact"
);
}
let primary = primary_artifacts.first().copied().unwrap_or(*pom);
deployments.push((
(group_id.clone(), artifact_id.clone(), version.clone()),
*pom,
primary,
));
}
for ((group_id, artifact_id, version), pom, primary) in deployments {
let mut arguments = vec![
"--settings".to_owned(),
self.settings.display().to_string(),
"--batch-mode".to_owned(),
"--no-transfer-progress".to_owned(),
"org.apache.maven.plugins:maven-deploy-plugin:3.1.4:deploy-file".to_owned(),
format!("-Dfile={}", primary.path.display()),
];
if extension(&primary.identity) == Some("pom") {
arguments.extend([
"-Dpackaging=pom".to_owned(),
"-DgeneratePom=false".to_owned(),
]);
} else {
arguments.push(format!("-DpomFile={}", pom.path.display()));
}
arguments.extend([
format!("-DrepositoryId={}", self.server_id),
format!("-Durl={}", self.base_url()),
format!("-DgroupId={group_id}"),
format!("-DartifactId={artifact_id}"),
format!("-Dversion={version}"),
]);
let mut request =
CommandRequest::new(self.wrapper.display().to_string(), arguments, &self.root);
request
.environment
.insert("GITHUB_ACTOR".to_owned(), self.actor.clone());
request
.environment
.insert("GITHUB_TOKEN".to_owned(), self.token.expose().to_owned());
self.runner
.execute(&request)?
.redact([self.token.expose()])
.require_success(&format!(
"deploy Maven package {group_id}:{artifact_id}:{version}"
))?;
}
Ok(())
}
}
impl Publisher for GithubMavenPublisher {
fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState> {
self.state_for(&plan.artifacts)
}
fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt> {
if !manifest.release.tag_already_sealed {
bail!(
"release {} is not sealed on the remote",
manifest.release.tag
);
}
if manifest.publisher != self.name {
bail!(
"artifact manifest belongs to publisher `{}`",
manifest.publisher
);
}
validate_manifest(manifest)?;
let identities = manifest
.artifacts
.iter()
.map(|artifact| artifact.identity.clone())
.collect::<Vec<_>>();
match self.state_for(&identities)? {
PublicationState::Complete => {
self.verify(manifest)?;
return Ok(receipt(manifest, &self.name, true));
}
PublicationState::Partial { .. } => {
bail!("partial Maven publication is not recoverable automatically")
}
PublicationState::Invalid { reason } => bail!("invalid Maven publication: {reason}"),
PublicationState::Absent => {}
}
let deploy_result = self.deploy(manifest);
let state_after_deploy = self.state_for(&identities);
match (deploy_result, state_after_deploy) {
(_, Ok(PublicationState::Complete)) => {}
(Err(deploy_error), Ok(state)) => {
return Err(deploy_error
.context(format!("Maven deploy failed and remote state is {state:?}")));
}
(Err(deploy_error), Err(inspect_error)) => {
return Err(deploy_error.context(format!(
"Maven deploy failed; remote reconciliation also failed: {inspect_error:#}"
)));
}
(Ok(()), Ok(state)) => {
bail!("Maven publication is not complete after deploy: {state:?}");
}
(Ok(()), Err(inspect_error)) => return Err(inspect_error),
}
self.verify(manifest)?;
Ok(receipt(manifest, &self.name, false))
}
fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport> {
let mut verified = Vec::new();
for artifact in &manifest.artifacts {
let url = self.url(&artifact.identity)?;
let bytes = self
.remote
.get(&url, &self.actor, &self.token)?
.with_context(|| format!("published Maven artifact is missing: {url}"))?;
let actual = hex::encode(Sha256::digest(&bytes));
if actual != artifact.sha256 {
bail!(
"published Maven artifact digest mismatch for {url}: expected {}, found {actual}",
artifact.sha256
);
}
verified.push(url);
}
Ok(VerificationReport {
target: manifest.target.clone(),
verified: true,
artifacts: verified,
})
}
fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport> {
match self.inspect(plan)? {
PublicationState::Complete => {}
state => bail!("cannot verify incomplete Maven publication: {state:?}"),
}
let mut verified = Vec::new();
for identity in &plan.artifacts {
let url = self.url(identity)?;
let bytes = self
.remote
.get(&url, &self.actor, &self.token)?
.with_context(|| format!("published Maven artifact is missing: {url}"))?;
if bytes.is_empty() {
bail!("published Maven artifact is empty: {url}");
}
verified.push(url);
}
Ok(VerificationReport {
target: plan.name.clone(),
verified: true,
artifacts: verified,
})
}
}
fn extension(identity: &ArtifactIdentity) -> Option<&str> {
match identity {
ArtifactIdentity::MavenPackage { extension, .. } => Some(extension),
_ => None,
}
}