gha-cache-proof 1.0.1

GitHub Actions cache compatibility checker and local cache-store receipt tool for offline CI
Documentation
use std::fs;

use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use gha_expression_proof::{EvaluationOptions, JobStatus, evaluate_template};
use serde_yaml::Value as YamlValue;

use crate::engine::{
    CheckWorkflowOptions, context_with_defaults, request_from_parts, restore_operation,
    save_operation,
};
use crate::model::{
    CacheOperationKind, Check, ReceiptSummary, WorkflowCacheReport, WorkflowCacheStep,
};
use crate::store::CacheStore;

pub fn check_workflows(
    options: &CheckWorkflowOptions,
    store: &mut CacheStore,
) -> Result<Vec<WorkflowCacheReport>> {
    let workflows = discover_workflows(options)?;
    let mut reports = Vec::new();
    for workflow in workflows {
        reports.push(check_workflow(options, store, workflow)?);
    }
    Ok(reports)
}

fn discover_workflows(options: &CheckWorkflowOptions) -> Result<Vec<Utf8PathBuf>> {
    if !options.workflows.is_empty() {
        return Ok(options.workflows.clone());
    }

    let dir = options.repo_root.join(".github").join("workflows");
    if !dir.exists() {
        return Ok(Vec::new());
    }

    let mut workflows = Vec::new();
    for item in fs::read_dir(&dir).with_context(|| format!("reading workflows dir {dir}"))? {
        let item = item?;
        let path = Utf8PathBuf::from_path_buf(item.path())
            .map_err(|_| anyhow::anyhow!("non-UTF-8 workflow path"))?;
        if matches!(path.extension(), Some("yml" | "yaml")) {
            workflows.push(path);
        }
    }
    workflows.sort();
    Ok(workflows)
}

fn check_workflow(
    options: &CheckWorkflowOptions,
    store: &mut CacheStore,
    workflow: Utf8PathBuf,
) -> Result<WorkflowCacheReport> {
    let raw =
        fs::read_to_string(&workflow).with_context(|| format!("reading workflow {workflow}"))?;
    let yaml: YamlValue =
        serde_yaml::from_str(&raw).with_context(|| format!("parsing workflow {workflow}"))?;

    let mut checks = Vec::new();
    let mut cache_steps = Vec::new();
    let Some(jobs) = yaml.get("jobs").and_then(YamlValue::as_mapping) else {
        checks.push(
            Check::skip("workflow.jobs", "workflow has no jobs mapping").at(workflow.to_string()),
        );
        return Ok(report(workflow, cache_steps, checks));
    };

    for (job_key, job_value) in jobs {
        let job_id = yaml_string(job_key).unwrap_or_else(|| "<unknown>".to_owned());
        let Some(steps) = job_value.get("steps").and_then(YamlValue::as_sequence) else {
            continue;
        };
        for (index, step) in steps.iter().enumerate() {
            let Some(mapping) = step.as_mapping() else {
                continue;
            };
            let Some(uses) = mapping.get("uses").and_then(YamlValue::as_str) else {
                continue;
            };
            let Some(operation) = cache_operation_from_uses(uses) else {
                continue;
            };
            let Some(with) = mapping.get("with").and_then(YamlValue::as_mapping) else {
                checks.push(
                    Check::fail("cache.step.with", "cache step is missing with: inputs")
                        .at(format!("{workflow}:{}", index + 1)),
                );
                continue;
            };

            let name = mapping
                .get("name")
                .and_then(YamlValue::as_str)
                .map(ToOwned::to_owned);
            let key_template = string_input(with, "key").unwrap_or_default();
            let path_templates = multiline_input(with, "path");
            let restore_key_templates = multiline_input(with, "restore-keys");
            let enable_cross_os_archive = bool_input(with, "enableCrossOsArchive");
            let lookup_only = bool_input(with, "lookup-only");
            let fail_on_cache_miss = bool_input(with, "fail-on-cache-miss");

            let rendered = render_cache_inputs(
                options,
                &key_template,
                &restore_key_templates,
                &path_templates,
            );
            let mut step_checks = Vec::new();
            if key_template.is_empty() {
                step_checks.push(Check::fail(
                    "cache.step.key",
                    "cache step is missing required key",
                ));
            } else {
                step_checks.push(Check::pass("cache.step.key", "cache step has a key"));
            }
            if path_templates.is_empty() {
                step_checks.push(Check::fail(
                    "cache.step.path",
                    "cache step is missing required path",
                ));
            } else {
                step_checks.push(Check::pass(
                    "cache.step.path",
                    "cache step has at least one path",
                ));
            }
            if !uses.contains('@') {
                step_checks.push(Check::warn(
                    "cache.step.pin",
                    "cache action reference is not version-pinned",
                ));
            } else {
                step_checks.push(Check::pass(
                    "cache.step.pin",
                    "cache action reference is version-pinned",
                ));
            }

            let mut common = options.common.clone();
            common.enable_cross_os_archive = enable_cross_os_archive;
            let request = request_from_parts(
                &common,
                rendered.key.clone(),
                rendered.restore_keys.clone(),
                rendered.paths.clone(),
            );

            let operation_receipt = match operation {
                CacheOperationKind::Cache | CacheOperationKind::Restore => restore_operation(
                    store,
                    &request,
                    operation,
                    lookup_only || operation == CacheOperationKind::Cache,
                    fail_on_cache_miss,
                    false,
                )?,
                CacheOperationKind::Save => save_operation(store, &request)?,
            };

            cache_steps.push(WorkflowCacheStep {
                job_id: job_id.clone(),
                step_index: index,
                name,
                uses: uses.to_owned(),
                operation,
                key_template,
                key: rendered.key,
                restore_key_templates,
                restore_keys: rendered.restore_keys,
                path_templates,
                paths: rendered.paths,
                expression_receipts: rendered.receipts,
                operation_receipt,
                checks: step_checks,
            });
        }
    }

    if cache_steps.is_empty() {
        checks.push(
            Check::skip(
                "workflow.cache-steps",
                "workflow contains no actions/cache steps",
            )
            .at(workflow.to_string()),
        );
    } else {
        checks.push(
            Check::pass(
                "workflow.cache-steps",
                format!("found {} cache steps", cache_steps.len()),
            )
            .at(workflow.to_string()),
        );
    }

    Ok(report(workflow, cache_steps, checks))
}

struct RenderedInputs {
    key: String,
    restore_keys: Vec<String>,
    paths: Vec<String>,
    receipts: Vec<gha_expression_proof::EvaluationReceipt>,
}

fn render_cache_inputs(
    options: &CheckWorkflowOptions,
    key: &str,
    restore_keys: &[String],
    paths: &[String],
) -> RenderedInputs {
    let mut receipts = Vec::new();
    let key = render_template_value(options, key, &mut receipts);
    let restore_keys = restore_keys
        .iter()
        .map(|value| render_template_value(options, value, &mut receipts))
        .collect();
    let paths = paths
        .iter()
        .map(|value| render_template_value(options, value, &mut receipts))
        .collect();

    RenderedInputs {
        key,
        restore_keys,
        paths,
        receipts,
    }
}

fn render_template_value(
    options: &CheckWorkflowOptions,
    value: &str,
    receipts: &mut Vec<gha_expression_proof::EvaluationReceipt>,
) -> String {
    if !value.contains("${{") {
        return value.to_owned();
    }
    let receipt = evaluate_template(
        value,
        &EvaluationOptions {
            context: context_with_defaults(options),
            workspace: Some(options.common.workspace.clone()),
            if_condition: false,
            job_status: JobStatus::Success,
        },
    );
    let rendered = receipt.rendered.clone().unwrap_or_else(|| value.to_owned());
    receipts.push(receipt);
    rendered
}

fn report(
    workflow: Utf8PathBuf,
    cache_steps: Vec<WorkflowCacheStep>,
    checks: Vec<Check>,
) -> WorkflowCacheReport {
    let mut summary = ReceiptSummary::from_checks(&checks);
    for step in &cache_steps {
        summary.add(&ReceiptSummary::from_checks(&step.checks));
        summary.add(&step.operation_receipt.summary());
        for receipt in &step.expression_receipts {
            summary.add(&ReceiptSummary {
                passed: receipt.summary.passed,
                warnings: receipt.summary.warnings,
                failed: receipt.summary.failed,
                skipped: receipt.summary.skipped,
            });
        }
    }
    WorkflowCacheReport {
        workflow,
        cache_steps,
        summary,
        checks,
    }
}

fn cache_operation_from_uses(uses: &str) -> Option<CacheOperationKind> {
    let action = uses.split('@').next().unwrap_or(uses);
    match action.to_ascii_lowercase().as_str() {
        "actions/cache" => Some(CacheOperationKind::Cache),
        "actions/cache/restore" => Some(CacheOperationKind::Restore),
        "actions/cache/save" => Some(CacheOperationKind::Save),
        _ => None,
    }
}

fn string_input(with: &serde_yaml::Mapping, key: &str) -> Option<String> {
    with.get(YamlValue::String(key.to_owned()))
        .and_then(yaml_string)
}

fn multiline_input(with: &serde_yaml::Mapping, key: &str) -> Vec<String> {
    string_input(with, key)
        .map(|value| {
            value
                .lines()
                .map(str::trim)
                .filter(|line| !line.is_empty())
                .map(ToOwned::to_owned)
                .collect()
        })
        .unwrap_or_default()
}

fn bool_input(with: &serde_yaml::Mapping, key: &str) -> bool {
    match with.get(YamlValue::String(key.to_owned())) {
        Some(YamlValue::Bool(value)) => *value,
        Some(YamlValue::String(value)) => value.eq_ignore_ascii_case("true"),
        _ => false,
    }
}

fn yaml_string(value: &YamlValue) -> Option<String> {
    match value {
        YamlValue::String(value) => Some(value.clone()),
        YamlValue::Number(value) => Some(value.to_string()),
        YamlValue::Bool(value) => Some(value.to_string()),
        _ => None,
    }
}