release-tool 0.4.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
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,
    }
}