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()
}