use std::collections::BTreeSet;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use yaml_rust2::Yaml;
const REQUIRED_CONTEXTS: &[&str] = &["checks", "default-features", "msrv", "no-default-features"];
const NOT_REQUIRED_JOBS: &[(&str, &str)] = &[(
"coverage",
"#319: measured, not gated — `continue-on-error: true` and no agreed \
threshold, so nothing depends on its verdict",
)];
const ALWAYS_RUN_ACTIONS: &[&str] = &["actions/checkout@", "dtolnay/rust-toolchain@"];
const EXPENSIVE_ARM: &str = "env.RELEASE_PR != 'true'";
const CHEAP_ARM: &str = "env.RELEASE_PR == 'true'";
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)
.expect("repo root is two levels above crates/roteiro")
.to_path_buf()
}
fn is_repository_checkout() -> bool {
let manifest = repo_root().join("Cargo.toml");
match std::fs::read_to_string(&manifest) {
Ok(text) => text.lines().any(|line| line.trim() == "[workspace]"),
Err(e) if e.kind() == ErrorKind::NotFound => false,
Err(e) => panic!(
"cannot read {} ({:?}: {e}). Without it this test cannot tell a \
packaged crate from a repository checkout, and guessing would make \
the guard skip in silence — which is the failure this whole file \
exists to rule out.",
manifest.display(),
e.kind(),
),
}
}
fn read_repo_file(rel: &str) -> Option<String> {
let path = repo_root().join(rel);
match std::fs::read_to_string(&path) {
Ok(text) => Some(text),
Err(e) if e.kind() == ErrorKind::NotFound && !is_repository_checkout() => None,
Err(e) => panic!(
"cannot read {} ({:?}: {e}). This guard asserts a property of that \
file, so skipping here would be a green that means \"could not \
look\". If the file moved or was deliberately deleted, this test \
moves or goes with it.",
path.display(),
e.kind(),
),
}
}
fn workflow_jobs(rel: &str) -> Option<Vec<(String, Yaml)>> {
let text = read_repo_file(rel)?;
let docs = yaml_rust2::YamlLoader::load_from_str(&text)
.unwrap_or_else(|e| panic!("{rel} is not parseable YAML: {e}"));
let doc = docs
.first()
.unwrap_or_else(|| panic!("{rel} is an empty YAML document"));
let jobs = field(doc, "jobs")
.and_then(Yaml::as_hash)
.unwrap_or_else(|| panic!("{rel} has no `jobs:` mapping"));
Some(
jobs.iter()
.filter_map(|(name, job)| Some((name.as_str()?.to_owned(), job.clone())))
.collect(),
)
}
fn ci_jobs() -> Option<Vec<(String, Yaml)>> {
workflow_jobs(".github/workflows/ci.yml")
}
fn field<'a>(node: &'a Yaml, key: &str) -> Option<&'a Yaml> {
node.as_hash()?.get(&Yaml::String(key.to_owned()))
}
fn field_str<'a>(node: &'a Yaml, key: &str) -> Option<&'a str> {
field(node, key)?.as_str()
}
fn steps(job: &Yaml) -> &[Yaml] {
field(job, "steps")
.and_then(Yaml::as_vec)
.map_or(&[], Vec::as_slice)
}
fn condition(node: &Yaml) -> Option<String> {
let raw = field_str(node, "if")?.trim();
let inner = raw
.strip_prefix("${{")
.and_then(|s| s.strip_suffix("}}"))
.unwrap_or(raw);
Some(inner.split_whitespace().collect::<Vec<_>>().join(" "))
}
fn step_label(step: &Yaml) -> String {
field_str(step, "name")
.or_else(|| field_str(step, "uses"))
.unwrap_or("<unnamed step>")
.to_owned()
}
fn is_always_run_setup(step: &Yaml) -> bool {
field_str(step, "uses").is_some_and(|uses| {
ALWAYS_RUN_ACTIONS
.iter()
.any(|allowed| uses.trim().starts_with(allowed))
})
}
#[test]
fn every_ci_job_is_classified_as_required_or_not() {
let Some(jobs) = ci_jobs() else {
return; };
let in_file: BTreeSet<&str> = jobs.iter().map(|(name, _)| name.as_str()).collect();
let classified: BTreeSet<&str> = REQUIRED_CONTEXTS
.iter()
.copied()
.chain(NOT_REQUIRED_JOBS.iter().map(|(name, _)| *name))
.collect();
let unclassified: Vec<&str> = in_file.difference(&classified).copied().collect();
assert!(
unclassified.is_empty(),
"ci.yml defines job(s) {unclassified:?} that this test does not classify. \
Add each to REQUIRED_CONTEXTS if it is a required status check on `main` \
(check with `gh api repos/:owner/:repo/branches/main/protection --jq \
'.required_status_checks.contexts'`), or to NOT_REQUIRED_JOBS with the \
reason it is not. A job nobody classified is how #482 shipped a job that \
cost 595s on every release PR."
);
let missing: Vec<&str> = classified.difference(&in_file).copied().collect();
assert!(
missing.is_empty(),
"this test names job(s) {missing:?} that ci.yml no longer defines. If a \
job was renamed, rename it here too AND in branch protection — a \
required context that names no job leaves a PR pending forever with \
nothing showing red to explain why."
);
}
#[test]
fn every_required_job_verifies_the_manifests_on_a_release_pr() {
let Some(jobs) = ci_jobs() else {
return; };
for context in REQUIRED_CONTEXTS {
let Some((_, job)) = jobs.iter().find(|(name, _)| name == context) else {
continue; };
let verified = steps(job).iter().any(|step| {
let guarded = condition(step).is_some_and(|c| c.contains(CHEAP_ARM));
let run = field_str(step, "run").unwrap_or_default();
guarded && run.contains("cargo metadata") && run.contains("--locked")
});
assert!(
verified,
"the `{context}` job is a required status check but has no step \
guarded by `if: {CHEAP_ARM}` that runs `cargo metadata --locked`. On \
a release-plz PR it would report `success` having verified nothing. \
Copy the `Manifests and lockfile agree (release PR)` step from the \
`checks` job."
);
}
}
#[test]
fn a_required_check_job_is_never_skipped_at_the_job_level() {
let Some(jobs) = ci_jobs() else {
return; };
for context in REQUIRED_CONTEXTS {
let Some((_, job)) = jobs.iter().find(|(name, _)| name == context) else {
continue; };
let condition = condition(job);
assert!(
condition.is_none(),
"the required job `{context}` carries a job-level `if:` ({:?}). A \
skipped job reports `skipped`, which branch protection accepts as \
success — so the context would go green having verified nothing, \
and every step-level assertion in this file would pass vacuously \
because none of the steps ran. Move the condition onto the steps: \
`if: {EXPENSIVE_ARM}` on the expensive ones, `if: {CHEAP_ARM}` on \
the manifest check. If the job genuinely should not report at all, \
it is not a required check — take it out of branch protection and \
move it to NOT_REQUIRED_JOBS in the same change.",
condition.as_deref().unwrap_or("absent"),
);
}
}
#[test]
fn no_required_job_pays_for_expensive_work_on_a_release_pr() {
let Some(jobs) = ci_jobs() else {
return; };
for context in REQUIRED_CONTEXTS {
let Some((_, job)) = jobs.iter().find(|(name, _)| name == context) else {
continue; };
for step in steps(job) {
if is_always_run_setup(step) {
continue;
}
let condition = condition(step);
let armed = condition
.as_deref()
.is_some_and(|c| c.contains(EXPENSIVE_ARM) || c.contains(CHEAP_ARM));
assert!(
armed,
"step `{}` of the required job `{context}` carries no release-PR \
arm (`if:` is {:?}), so it runs in full on every release-plz \
version bump. Guard it with `if: {EXPENSIVE_ARM}`, or — if it is \
genuinely setup that both arms need — add its action to \
ALWAYS_RUN_ACTIONS with the reason.",
step_label(step),
condition.as_deref().unwrap_or("absent"),
);
}
}
}
#[test]
fn a_job_that_is_not_a_required_check_is_skipped_outright_on_a_release_pr() {
let Some(jobs) = ci_jobs() else {
return; };
for (name, reason) in NOT_REQUIRED_JOBS {
let Some((_, job)) = jobs.iter().find(|(job_name, _)| job_name == name) else {
continue; };
let condition = condition(job);
assert!(
condition
.as_deref()
.is_some_and(|c| c.contains("release-plz-")),
"the `{name}` job is classified not-required ({reason}) but its \
job-level `if:` is {:?}, which does not exclude a release-plz \
branch. Nothing depends on it reporting, so it should be skipped \
outright rather than run and pay for itself. If it has become a \
required check, move it to REQUIRED_CONTEXTS instead — a required \
job must RUN, and skipping it would be a different argument.",
condition.as_deref().unwrap_or("absent"),
);
}
}