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,
}
}