release-tool 0.4.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
mod github_maven;
mod github_release;
mod oci_registry;

pub use github_maven::{GithubMavenPublisher, MavenRemote};
pub use github_release::GithubReleasePublisher;
pub use oci_registry::OciRegistryPublisher;

use crate::domain::{ArtifactIdentity, ArtifactManifest, TargetPlan};
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PublicationState {
    Absent,
    Complete,
    Partial {
        present: Vec<String>,
        missing: Vec<String>,
    },
    Invalid {
        reason: String,
    },
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PublicationReceipt {
    pub tool_version: String,
    pub repository: String,
    pub tag: String,
    pub commit: String,
    pub target: String,
    pub publisher: String,
    pub artifacts: Vec<ArtifactIdentity>,
    pub skipped: bool,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VerificationReport {
    pub target: String,
    pub verified: bool,
    pub artifacts: Vec<String>,
}

pub trait Publisher {
    fn inspect(&self, plan: &TargetPlan) -> Result<PublicationState>;
    fn publish(&self, manifest: &ArtifactManifest) -> Result<PublicationReceipt>;
    fn verify(&self, manifest: &ArtifactManifest) -> Result<VerificationReport>;
    fn verify_existing(&self, plan: &TargetPlan) -> Result<VerificationReport>;
}

pub(crate) fn receipt(
    manifest: &ArtifactManifest,
    publisher: &str,
    skipped: bool,
) -> PublicationReceipt {
    PublicationReceipt {
        tool_version: env!("CARGO_PKG_VERSION").to_owned(),
        repository: manifest.release.repository.clone(),
        tag: manifest.release.tag.to_string(),
        commit: manifest.release.commit.clone(),
        target: manifest.target.clone(),
        publisher: publisher.to_owned(),
        artifacts: manifest
            .artifacts
            .iter()
            .map(|artifact| artifact.identity.clone())
            .collect(),
        skipped,
    }
}

fn validate_manifest(manifest: &ArtifactManifest) -> Result<()> {
    if manifest.artifacts.is_empty() {
        bail!("artifact manifest must not be empty");
    }
    let mut identities = HashSet::new();
    for artifact in &manifest.artifacts {
        if !identities.insert(&artifact.identity) {
            bail!(
                "artifact manifest contains a duplicate identity: {:?}",
                artifact.identity
            );
        }
        let expected_file_name = match &artifact.identity {
            ArtifactIdentity::GithubReleaseAsset { name } => name.clone(),
            ArtifactIdentity::MavenPackage {
                artifact_id,
                version,
                extension,
                ..
            } => format!("{artifact_id}-{version}.{extension}"),
            ArtifactIdentity::OciImage { .. } => {
                bail!("file publisher received an OCI image artifact")
            }
        };
        if artifact.path.file_name().and_then(|name| name.to_str())
            != Some(expected_file_name.as_str())
        {
            bail!(
                "prepared artifact file name does not match its identity: expected `{expected_file_name}`, found `{}`",
                artifact.path.display()
            );
        }
        let bytes = fs::read(&artifact.path).with_context(|| {
            format!(
                "failed to read prepared artifact {}",
                artifact.path.display()
            )
        })?;
        let actual = hex::encode(Sha256::digest(bytes));
        if actual != artifact.sha256 {
            bail!(
                "prepared artifact changed after Prepare: {} (expected {}, found {actual})",
                artifact.path.display(),
                artifact.sha256
            );
        }
    }
    Ok(())
}