use std::collections::BTreeMap;
use std::path::PathBuf;
const ASSET_GLOB_KEYS: [&str; 2] = ["path", "artifacts"];
const UPLOAD_ACTIONS: [&str; 2] = ["upload-artifact", "publish-github-release"];
fn workflow_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".github/workflows/publish.yaml")
}
struct GlobUpload {
job: String,
step: String,
key: String,
glob: String,
fails_on_empty: bool,
}
fn collect_glob_uploads(document: &serde_json::Value) -> Vec<GlobUpload> {
let mut found = Vec::new();
let Some(jobs) = document.get("jobs").and_then(serde_json::Value::as_object) else {
panic!("publish.yaml has no `jobs:` mapping");
};
for (job_name, job) in jobs {
let Some(steps) = job.get("steps").and_then(serde_json::Value::as_array) else {
continue;
};
for step in steps {
let uses = step.get("uses").and_then(serde_json::Value::as_str).unwrap_or_default();
if !UPLOAD_ACTIONS.iter().any(|action| uses.contains(action)) {
continue;
}
let Some(with) = step.get("with").and_then(serde_json::Value::as_object) else {
continue;
};
let fails_on_empty = with.get("if-no-files-found").and_then(serde_json::Value::as_str) == Some("error");
let step_name = step
.get("name")
.and_then(serde_json::Value::as_str)
.unwrap_or(uses)
.to_string();
for key in ASSET_GLOB_KEYS {
let Some(value) = with.get(key).and_then(serde_json::Value::as_str) else {
continue;
};
if !value.contains('*') {
continue;
}
found.push(GlobUpload {
job: job_name.clone(),
step: step_name.clone(),
key: key.to_string(),
glob: value.to_string(),
fails_on_empty,
});
}
}
}
found
}
fn run_scripts_by_job(document: &serde_json::Value) -> BTreeMap<String, Vec<String>> {
let mut scripts: BTreeMap<String, Vec<String>> = BTreeMap::new();
let Some(jobs) = document.get("jobs").and_then(serde_json::Value::as_object) else {
return scripts;
};
for (job_name, job) in jobs {
let Some(steps) = job.get("steps").and_then(serde_json::Value::as_array) else {
continue;
};
for step in steps {
if let Some(run) = step.get("run").and_then(serde_json::Value::as_str) {
scripts.entry(job_name.clone()).or_default().push(run.to_string());
}
}
}
scripts
}
fn has_guard_script(scripts: &BTreeMap<String, Vec<String>>, upload: &GlobUpload) -> bool {
scripts.get(&upload.job).is_some_and(|bodies| {
bodies
.iter()
.any(|body| body.contains(&upload.glob) && body.contains("exit 1"))
})
}
#[test]
fn every_release_asset_glob_hard_fails_when_it_matches_nothing() {
let path = workflow_path();
let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let document: serde_json::Value = serde_saphyr::from_str(&raw).expect("parse publish.yaml");
let uploads = collect_glob_uploads(&document);
assert!(
!uploads.is_empty(),
"found no glob-bearing upload steps in {} -- the scan is looking at the wrong keys, \
so this test would pass vacuously",
path.display()
);
let scripts = run_scripts_by_job(&document);
let unguarded: Vec<String> = uploads
.iter()
.filter(|upload| !upload.fails_on_empty && !has_guard_script(&scripts, upload))
.map(|upload| {
format!(
"job `{}` step `{}` ({}: {})",
upload.job, upload.step, upload.key, upload.glob
)
})
.collect();
assert!(
unguarded.is_empty(),
"release-asset glob(s) that silently succeed when they match zero files:\n {}\n\
Set `if-no-files-found: error` on the step, or add a `run:` step in the same job that \
names the glob and `exit 1`s when it matches nothing.",
unguarded.join("\n ")
);
}