gha-cache-proof 1.0.1

GitHub Actions cache compatibility checker and local cache-store receipt tool for offline CI
Documentation
use anyhow::{Context, Result, bail, ensure};
use camino::Utf8PathBuf;
use clap::{Args, Parser, Subcommand};
use gha_cache_proof::{
    CheckWorkflowOptions, CommonOptions, Compression, OutputFormat, RestoreOptions, RunnerOs,
    SaveOptions, check_workflows, render_receipt, restore_cache, save_cache,
};
use serde_json::{Map, Value};
use std::fs;
use std::process::ExitCode;

#[derive(Debug, Parser)]
#[command(
    version,
    about = "Check and emulate GitHub Actions cache behavior with receipts"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,

    #[arg(long, global = true, value_enum, default_value = "text")]
    format: OutputFormat,

    #[arg(long, global = true, value_name = "PATH")]
    output: Option<Utf8PathBuf>,

    #[arg(long, global = true)]
    strict: bool,
}

#[derive(Debug, Subcommand)]
enum Command {
    Restore(RestoreArgs),
    Save(SaveArgs),
    CheckWorkflow(CheckWorkflowArgs),
}

#[derive(Debug, Args, Clone)]
struct CommonArgs {
    #[arg(long, value_name = "DIR", default_value = ".gha-cache-proof")]
    store: Utf8PathBuf,

    #[arg(long, value_name = "DIR", default_value = ".")]
    workspace: Utf8PathBuf,

    #[arg(long, value_name = "REF", default_value = "refs/heads/main")]
    reference: String,

    #[arg(long, value_name = "BRANCH", default_value = "main")]
    default_branch: String,

    #[arg(long, value_name = "REF")]
    base_ref: Option<String>,

    #[arg(long, value_enum, default_value = "linux")]
    runner_os: RunnerOs,

    #[arg(long, value_enum)]
    compression: Option<Compression>,

    #[arg(long)]
    enable_cross_os_archive: bool,
}

#[derive(Debug, Args)]
struct RestoreArgs {
    #[command(flatten)]
    common: CommonArgs,

    #[arg(long)]
    key: String,

    #[arg(long = "restore-key")]
    restore_keys: Vec<String>,

    #[arg(long = "path")]
    paths: Vec<String>,

    #[arg(long)]
    lookup_only: bool,

    #[arg(long)]
    fail_on_cache_miss: bool,
}

#[derive(Debug, Args)]
struct SaveArgs {
    #[command(flatten)]
    common: CommonArgs,

    #[arg(long)]
    key: String,

    #[arg(long = "path")]
    paths: Vec<String>,
}

#[derive(Debug, Args)]
struct CheckWorkflowArgs {
    #[command(flatten)]
    common: CommonArgs,

    #[arg(long, value_name = "DIR", default_value = ".")]
    repo: Utf8PathBuf,

    #[arg(long, value_name = "PATH")]
    workflow: Vec<Utf8PathBuf>,

    #[arg(long, value_name = "PATH")]
    context: Option<Utf8PathBuf>,

    #[arg(long = "context-file", value_name = "NAME=PATH")]
    context_files: Vec<String>,

    #[arg(long = "context-json", value_name = "NAME=JSON")]
    context_json: Vec<String>,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("error: {error:#}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();
    let receipt = match &cli.command {
        Command::Restore(args) => restore_cache(&RestoreOptions {
            common: common_options(&args.common)?,
            key: args.key.clone(),
            restore_keys: expand_lines(&args.restore_keys),
            paths: expand_lines(&args.paths),
            lookup_only: args.lookup_only,
            fail_on_cache_miss: args.fail_on_cache_miss,
        })?,
        Command::Save(args) => save_cache(&SaveOptions {
            common: common_options(&args.common)?,
            key: args.key.clone(),
            paths: expand_lines(&args.paths),
        })?,
        Command::CheckWorkflow(args) => check_workflows(&CheckWorkflowOptions {
            common: common_options(&args.common)?,
            repo_root: args.repo.clone(),
            workflows: args.workflow.clone(),
            context: load_context(args)?,
        })?,
    };

    let rendered = render_receipt(&receipt, cli.format)?;
    if let Some(output) = cli.output {
        if let Some(parent) = output.parent() {
            fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
        }
        fs::write(&output, rendered).with_context(|| format!("writing {output}"))?;
    } else {
        print!("{rendered}");
    }

    if receipt.summary.failed > 0 || (cli.strict && receipt.summary.warnings > 0) {
        bail!("cache proof failed");
    }

    Ok(())
}

fn common_options(args: &CommonArgs) -> Result<CommonOptions> {
    ensure!(
        args.workspace.is_dir(),
        "--workspace must be an existing directory"
    );
    Ok(CommonOptions {
        store: args.store.clone(),
        workspace: args.workspace.clone(),
        ref_name: args.reference.clone(),
        default_branch: args.default_branch.clone(),
        base_ref: args.base_ref.clone(),
        runner_os: args.runner_os,
        compression: args.compression,
        enable_cross_os_archive: args.enable_cross_os_archive,
    })
}

fn load_context(args: &CheckWorkflowArgs) -> Result<Value> {
    let mut root = Map::new();
    if let Some(path) = &args.context {
        let value: Value = serde_json::from_str(
            &fs::read_to_string(path).with_context(|| format!("reading context {path}"))?,
        )
        .with_context(|| format!("parsing context {path}"))?;
        let Value::Object(object) = value else {
            bail!("--context must contain a JSON object");
        };
        root.extend(object);
    }

    for pair in &args.context_files {
        let (name, path) = split_pair(pair)?;
        let value: Value = serde_json::from_str(
            &fs::read_to_string(&path).with_context(|| format!("reading context file {path}"))?,
        )
        .with_context(|| format!("parsing context file {path}"))?;
        root.insert(name, value);
    }
    for pair in &args.context_json {
        let (name, raw) = split_pair(pair)?;
        let value =
            serde_json::from_str(&raw).with_context(|| format!("parsing JSON for {name}"))?;
        root.insert(name, value);
    }

    Ok(Value::Object(root))
}

fn split_pair(raw: &str) -> Result<(String, String)> {
    let Some((key, value)) = raw.split_once('=') else {
        bail!("expected NAME=VALUE, got {raw}");
    };
    ensure!(!key.trim().is_empty(), "context name cannot be empty");
    Ok((key.trim().to_owned(), value.to_owned()))
}

fn expand_lines(values: &[String]) -> Vec<String> {
    values
        .iter()
        .flat_map(|value| value.lines())
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}