use std::fs;
use std::process::ExitCode;
use anyhow::{Context, Result, bail, ensure};
use camino::Utf8PathBuf;
use clap::{Args, Parser, Subcommand};
use gha_container_proof::{
ActionPlanInput, CheckWorkflowOptions, JobPlanInput, OutputFormat, ProbeInput, RunnerOs,
apply_strict, render_receipt, run_check_workflow, run_plan_action, run_plan_job, run_probe,
};
#[derive(Debug, Parser)]
#[command(
version,
about = "Classify GitHub Actions job containers and Docker actions with Docker CLI probe \
receipts — offline by default."
)]
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 {
CheckWorkflow(CheckWorkflowArgs),
PlanJob(PlanJobArgs),
PlanAction(PlanActionArgs),
Probe(ProbeArgs),
}
#[derive(Debug, Args)]
struct CheckWorkflowArgs {
#[arg(long, value_name = "DIR", default_value = ".")]
repo: Utf8PathBuf,
#[arg(long, value_name = "DIR")]
workspace: Option<Utf8PathBuf>,
#[arg(long, value_name = "PATH")]
workflow: Vec<Utf8PathBuf>,
#[arg(long, value_enum, default_value = "linux")]
runner_os: RunnerOs,
}
#[derive(Debug, Args)]
struct PlanJobArgs {
#[arg(long, value_name = "JOB", default_value = "job-container")]
job_id: String,
#[arg(long, value_enum, default_value = "linux")]
runner_os: RunnerOs,
#[arg(long = "runs-on", value_name = "LABEL")]
runs_on: Vec<String>,
#[arg(long, value_name = "IMAGE")]
container: Option<String>,
#[arg(long = "env", value_name = "KEY=VALUE")]
env: Vec<String>,
#[arg(long = "port", value_name = "PORT")]
ports: Vec<String>,
#[arg(long = "volume", value_name = "VOLUME")]
volumes: Vec<String>,
#[arg(
long,
value_name = "OPTIONS",
default_value = "",
allow_hyphen_values = true
)]
options: String,
#[arg(long = "credentials-username", default_value_t = false)]
credentials_username: bool,
#[arg(long = "credentials-password", default_value_t = false)]
credentials_password: bool,
}
#[derive(Debug, Args)]
struct PlanActionArgs {
#[arg(long, value_name = "REF")]
action_ref: String,
#[arg(long, value_name = "ID")]
step_id: Option<String>,
#[arg(long, value_name = "DIR")]
action_path: Option<Utf8PathBuf>,
#[arg(long, value_name = "USING")]
using: Option<String>,
#[arg(long, value_name = "IMAGE")]
image: Option<String>,
#[arg(long, value_name = "PATH")]
entrypoint: Option<String>,
#[arg(long = "pre-entrypoint", value_name = "PATH")]
pre_entrypoint: Option<String>,
#[arg(long = "post-entrypoint", value_name = "PATH")]
post_entrypoint: Option<String>,
#[arg(long, value_name = "ARGS", allow_hyphen_values = true)]
args: Vec<String>,
#[arg(long = "env", value_name = "KEY=VALUE")]
env: Vec<String>,
}
#[derive(Debug, Args)]
struct ProbeArgs {
#[arg(long, value_name = "IMAGE")]
image: String,
#[arg(long, value_enum, default_value = "linux")]
runner_os: RunnerOs,
#[arg(long = "tool", value_name = "TOOL")]
tools: Vec<String>,
#[arg(long = "command", value_name = "COMMAND", allow_hyphen_values = true)]
commands: Vec<String>,
#[arg(long = "allow-pull")]
allow_pull: bool,
#[arg(long = "docker-bin", value_name = "PATH")]
docker_bin: Option<Utf8PathBuf>,
}
fn main() -> ExitCode {
match run() {
Ok(success) => {
if success {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
Err(error) => {
eprintln!("error: {error:#}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<bool> {
let cli = Cli::parse();
let mut receipt = match &cli.command {
Command::CheckWorkflow(args) => {
ensure!(
args.repo.exists(),
"--repo must point at an existing directory"
);
let workspace = args.workspace.clone().unwrap_or_else(|| args.repo.clone());
let options = CheckWorkflowOptions {
repo_root: args.repo.clone(),
workspace,
workflows: args.workflow.clone(),
runner_os: args.runner_os,
};
run_check_workflow(&options)?
}
Command::PlanJob(args) => run_plan_job(&JobPlanInput {
job_id: args.job_id.clone(),
runner_os: args.runner_os,
runs_on: expand_lines(&args.runs_on),
container_image: args.container.clone(),
env: parse_env_pairs(&args.env)?,
ports: expand_lines(&args.ports),
volumes: expand_lines(&args.volumes),
options: args.options.clone(),
credentials_username_present: args.credentials_username,
credentials_password_present: args.credentials_password,
location: None,
})?,
Command::PlanAction(args) => run_plan_action(&ActionPlanInput {
action_ref: args.action_ref.clone(),
step_id: args.step_id.clone(),
action_path: args.action_path.clone(),
using: args.using.clone(),
image: args.image.clone(),
entrypoint: args.entrypoint.clone(),
pre_entrypoint: args.pre_entrypoint.clone(),
post_entrypoint: args.post_entrypoint.clone(),
args: expand_lines(&args.args),
env: parse_env_pairs(&args.env)?,
location: None,
})?,
Command::Probe(args) => run_probe(&ProbeInput {
image: args.image.clone(),
runner_os: args.runner_os,
tools: expand_lines(&args.tools),
commands: args.commands.clone(),
allow_pull: args.allow_pull,
docker_bin: args.docker_bin.clone(),
})?,
};
if cli.strict {
apply_strict(&mut receipt);
}
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}");
}
let ok = receipt.is_success(cli.strict);
if !ok {
eprintln!(
"gha-container-proof: {} failed, {} warned (strict={})",
receipt.summary.failed, receipt.summary.warnings, cli.strict
);
}
Ok(ok)
}
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()
}
fn parse_env_pairs(pairs: &[String]) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
for raw in expand_lines(pairs) {
let Some((key, value)) = raw.split_once('=') else {
bail!("expected KEY=VALUE, got `{raw}`");
};
out.push((key.trim().to_owned(), value.to_owned()));
}
Ok(out)
}