release-tool 0.4.0

Configuration-driven release lifecycle for computed-parameter repositories
Documentation
use crate::command::{CommandRequest, CommandRunner};
use crate::config::Config;
use crate::config::TargetConfig;
use crate::git::GitRepository;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::path::Path;
use std::sync::Arc;

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CapabilityStatus {
    Verified,
    Unverifiable,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Capability {
    pub status: CapabilityStatus,
    pub description: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DoctorReport {
    pub capabilities: Vec<Capability>,
    pub actor: String,
    pub token: Secret,
}

#[derive(Clone, Eq, PartialEq)]
pub struct Secret(String);

impl Secret {
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    pub fn expose(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Debug for Secret {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter.write_str("Secret([REDACTED])")
    }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RepositoryView {
    name_with_owner: String,
    viewer_permission: String,
}

pub fn run_doctor(
    config: &Config,
    root: &Path,
    runner: Arc<dyn CommandRunner>,
) -> Result<DoctorReport> {
    check_project_requirements(config, root, Arc::clone(&runner))?;
    let staging = tempfile::tempdir().context("tool-owned staging directory is not writable")?;
    std::fs::write(staging.path().join("probe"), b"release-tool doctor\n")
        .context("tool-owned staging directory is not writable")?;
    command(runner.as_ref(), root, "git", &["--version"])?;
    command(runner.as_ref(), root, "gh", &["--version"])?;

    let repository = GitRepository::new(root, Arc::clone(&runner));
    repository.snapshot(&config.repository.branch)?;
    let origin = repository.origin_url()?;
    let identity = github_identity(&origin)
        .with_context(|| format!("origin is not a GitHub repository URL: {origin}"))?;
    if identity != config.repository.github {
        bail!(
            "configured repository `{}` does not match origin `{identity}`",
            config.repository.github
        );
    }

    command(
        runner.as_ref(),
        root,
        "gh",
        &["auth", "status", "--hostname", "github.com"],
    )?;
    let token = command(
        runner.as_ref(),
        root,
        "gh",
        &["auth", "token", "--hostname", "github.com"],
    )?;
    let token = token.trim().to_owned();
    if token.is_empty() {
        bail!("gh returned an empty GitHub token");
    }
    let actor = command(
        runner.as_ref(),
        root,
        "gh",
        &["api", "--hostname", "github.com", "user", "--jq", ".login"],
    )?;
    let actor = actor.trim().to_owned();
    if actor.is_empty() {
        bail!("gh returned an empty GitHub actor");
    }
    let view = command(
        runner.as_ref(),
        root,
        "gh",
        &[
            "repo",
            "view",
            &config.repository.github,
            "--json",
            "nameWithOwner,viewerPermission",
        ],
    )?;
    let view: RepositoryView =
        serde_json::from_str(&view).context("invalid gh repo view output")?;
    if view.name_with_owner != config.repository.github {
        bail!(
            "gh resolved `{}` instead of configured repository `{}`",
            view.name_with_owner,
            config.repository.github
        );
    }
    if !matches!(
        view.viewer_permission.as_str(),
        "WRITE" | "MAINTAIN" | "ADMIN"
    ) {
        bail!(
            "GitHub account only has {} permission for {}",
            view.viewer_permission,
            config.repository.github
        );
    }

    let mut capabilities = vec![
        Capability {
            status: CapabilityStatus::Verified,
            description: "Git repository".to_owned(),
        },
        Capability {
            status: CapabilityStatus::Verified,
            description: format!("GitHub account: {actor}"),
        },
        Capability {
            status: CapabilityStatus::Verified,
            description: format!("repository permission: {}", view.viewer_permission),
        },
        Capability {
            status: CapabilityStatus::Verified,
            description: "tool-owned staging directory".to_owned(),
        },
        Capability {
            status: CapabilityStatus::Unverifiable,
            description: "destination write permission".to_owned(),
        },
    ];
    if config
        .targets
        .iter()
        .any(|target| matches!(target, TargetConfig::OciImage { .. }))
    {
        capabilities.push(Capability {
            status: CapabilityStatus::Verified,
            description: "Docker buildx and OCI target commands".to_owned(),
        });
        capabilities.push(Capability {
            status: CapabilityStatus::Unverifiable,
            description: "OCI registry single-writer or immutable-tag enforcement".to_owned(),
        });
    }
    Ok(DoctorReport {
        capabilities,
        actor,
        token: Secret(token),
    })
}

fn check_project_requirements(
    config: &Config,
    root: &Path,
    runner: Arc<dyn CommandRunner>,
) -> Result<()> {
    if let Some(program) = config.hooks.preflight.first() {
        require_command(root, program)?;
    }
    let mut needs_oci = false;
    for target in &config.targets {
        match target {
            TargetConfig::DockerArchive {
                build, local_check, ..
            } => {
                require_command(root, &build[0])?;
                require_command(root, &local_check[0])?;
                require_command(root, "docker")?;
                require_command(root, "xz")?;
            }
            TargetConfig::MavenReactor {
                wrapper,
                pom,
                projects,
                remote_check,
                ..
            } => {
                require_command(root, &wrapper.display().to_string())?;
                let root_pom = root.join(pom);
                if !root_pom.is_file() {
                    bail!("Maven root POM does not exist: {}", root_pom.display());
                }
                for project in projects {
                    let project_pom = root.join(project).join("pom.xml");
                    if !project_pom.is_file() {
                        bail!(
                            "Maven project POM does not exist: {}",
                            project_pom.display()
                        );
                    }
                }
                if let Some(program) = remote_check.first() {
                    require_command(root, program)?;
                }
                let publisher = config
                    .publishers
                    .get(target.publisher())
                    .context("Maven target publisher disappeared during doctor")?;
                if let crate::config::PublisherConfig::GithubMaven { settings, .. } = publisher {
                    let settings = root.join(settings);
                    if !settings.is_file() {
                        bail!("Maven settings file does not exist: {}", settings.display());
                    }
                }
            }
            TargetConfig::OciImage {
                reuse_check, build, ..
            } => {
                require_command(root, &reuse_check[0])?;
                require_command(root, &build[0])?;
                needs_oci = true;
            }
        }
    }
    if needs_oci {
        require_command(root, "docker")?;
        command(runner.as_ref(), root, "docker", &["buildx", "version"])?;
        let inspect_help = command(
            runner.as_ref(),
            root,
            "docker",
            &["buildx", "imagetools", "inspect", "--help"],
        )?;
        if !inspect_help.contains("--format") || !inspect_help.contains("--raw") {
            bail!("docker buildx imagetools inspect does not support --format and --raw");
        }
        let create_help = command(
            runner.as_ref(),
            root,
            "docker",
            &["buildx", "imagetools", "create", "--help"],
        )?;
        if !create_help.contains("--prefer-index") {
            bail!("docker buildx imagetools create does not support --prefer-index");
        }
    }
    Ok(())
}

fn require_command(root: &Path, program: &str) -> Result<()> {
    let program_path = Path::new(program);
    if program_path.components().count() > 1 || program_path.is_absolute() {
        let path = if program_path.is_absolute() {
            program_path.to_path_buf()
        } else {
            root.join(program_path)
        };
        if path.is_file() {
            return Ok(());
        }
    } else if std::env::var_os("PATH")
        .map(|path| {
            std::env::split_paths(&path)
                .map(|directory| directory.join(program))
                .any(|candidate| candidate.is_file())
        })
        .unwrap_or(false)
    {
        return Ok(());
    }
    bail!("required command `{program}` was not found")
}

fn command(
    runner: &dyn CommandRunner,
    root: &Path,
    program: &str,
    arguments: &[&str],
) -> Result<String> {
    let request = CommandRequest::new(program, arguments.iter().copied(), root);
    Ok(runner
        .execute(&request)?
        .require_success(&format!("{} {}", program, arguments.join(" ")))?
        .stdout)
}

fn github_identity(url: &str) -> Option<String> {
    let url = url.trim().trim_end_matches(".git");
    if let Some(identity) = url.strip_prefix("https://github.com/") {
        return Some(identity.to_owned());
    }
    if let Some(identity) = url.strip_prefix("git@github.com:") {
        return Some(identity.to_owned());
    }
    if let Some(identity) = url.strip_prefix("ssh://git@github.com/") {
        return Some(identity.to_owned());
    }
    None
}