use std::path::{Path, PathBuf};
fn workflow_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join(".github")
.join("workflows")
.join(name)
}
fn read_workflow(name: &str) -> String {
let path = workflow_path(name);
std::fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()))
}
const ALLOWED_TRIGGERS: [&str; 4] = ["pull_request", "push", "workflow_call", "workflow_dispatch"];
struct Located {
inline: String,
block: Vec<(usize, String)>,
}
fn significant(raw: &str) -> Option<(usize, &str)> {
let trimmed = raw.trim_end();
let body = trimmed.trim_start();
if body.is_empty() || body.starts_with('#') {
return None;
}
Some((trimmed.len() - body.len(), body))
}
fn unquote(key: &str) -> &str {
key.trim().trim_matches(['"', '\''])
}
fn key_matches(key: &str, want: &str) -> bool {
let key = unquote(key);
if want == "on" {
return key.eq_ignore_ascii_case("on") || key.eq_ignore_ascii_case("true");
}
key == want
}
fn locate(source: &str, path: &[&str]) -> Option<Located> {
assert!(!path.is_empty(), "an empty path matches nothing");
let mut matched = 0usize;
let mut lines = source.lines();
while let Some(raw) = lines.next() {
let Some((indent, body)) = significant(raw) else {
continue;
};
if indent > matched * 2 {
continue;
}
if indent < matched * 2 {
return None;
}
let Some((key, rest)) = body.split_once(':') else {
continue;
};
if !key_matches(key, path[matched]) {
continue;
}
matched += 1;
if matched < path.len() {
continue;
}
let key_indent = indent;
let mut block = Vec::new();
for raw in lines.by_ref() {
let Some((indent, body)) = significant(raw) else {
continue;
};
if indent <= key_indent {
break;
}
block.push((indent, body.to_string()));
}
return Some(Located {
inline: rest.trim().to_string(),
block,
});
}
None
}
fn child_keys(source: &str, path: &[&str]) -> Option<Vec<String>> {
let located = locate(source, path)?;
let child_indent = path.len() * 2;
Some(
located
.block
.iter()
.filter(|(indent, _)| *indent == child_indent)
.filter_map(|(_, text)| text.split_once(':'))
.map(|(key, _)| unquote(key).to_string())
.collect(),
)
}
fn inline_list_at(source: &str, path: &[&str]) -> Option<Vec<String>> {
let located = locate(source, path)?;
let inner = located
.inline
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))?;
Some(
inner
.split(',')
.map(|item| item.trim().trim_matches(['"', '\'']).to_string())
.filter(|item| !item.is_empty())
.collect(),
)
}
fn triggers(source: &str) -> Vec<String> {
child_keys(source, &["on"])
.expect("no top-level `on:` block found; the scanner is looking at the wrong thing")
}
fn permission_entry(text: &str) -> (String, String) {
match text.split_once(':') {
Some((scope, level)) => (
unquote(scope).to_string(),
level.trim().trim_matches(['"', '\'']).to_string(),
),
None => (unquote(text).to_string(), String::new()),
}
}
fn assert_triggers(workflow: &str, source: &str) {
let found = triggers(source);
for required in ["pull_request", "push"] {
assert!(
found.contains(&required.to_string()),
"{workflow} must trigger on `{required}`. Finding it absent does \
NOT mean it was removed on purpose — it is far more likely that \
the `on:` block was rewritten into a flow mapping or a block \
sequence, which this file's scanner does not read, in which case \
every absence-based assertion below is passing vacuously. \
Parsed triggers: {found:?}"
);
}
for trigger in &found {
assert!(
ALLOWED_TRIGGERS.contains(&trigger.as_str()),
"{workflow} triggers on `{trigger}`, which is not in the allow-list \
{ALLOWED_TRIGGERS:?}. `pull_request_target` in particular runs \
against the BASE repository with its secrets exposed to fork pull \
requests — this repository's workflows carry \
RUNNER_MANAGER_E2E_TOKEN and RUNNER_MANAGER_E2E_FIXTURE_TOKEN. \
`release`, `create`, `schedule`, `repository_dispatch` and \
`workflow_run` are refused here too: releases are manual only and \
live in release.yml. Parsed triggers: {found:?}"
);
}
}
#[test]
fn release_workflow_has_exactly_one_trigger() {
let source = read_workflow("release.yml");
assert_eq!(
triggers(&source),
vec!["workflow_dispatch".to_string()],
"release.yml must have exactly one trigger. A `push`, `tag`, `schedule`, \
or `release` trigger would make the only credential able to publish \
run automatically, which D10 forbids."
);
}
#[test]
fn release_workflow_requests_contents_write_and_nothing_else() {
let source = read_workflow("release.yml");
let permissions = locate(&source, &["permissions"])
.expect("release.yml must declare a top-level `permissions:` block");
let entries: Vec<&str> = permissions
.block
.iter()
.map(|(_, text)| text.as_str())
.collect();
assert_eq!(
entries,
vec!["contents: write"],
"release.yml's top-level permissions block must be exactly \
`contents: write` — the minimum needed to publish, and no more"
);
let jobs =
child_keys(&source, &["jobs"]).expect("release.yml must declare a top-level `jobs:` block");
assert!(
!jobs.is_empty(),
"no jobs parsed out of release.yml. As with the trigger scan, an empty \
result here is a scanner failure and not a clean bill of health: every \
job-level assertion below would pass vacuously."
);
for job in &jobs {
let Some(permissions) = locate(&source, &["jobs", job.as_str(), "permissions"]) else {
continue;
};
assert!(
permissions.inline.is_empty(),
"release.yml job `{job}` sets `permissions: {}` as a scalar. A \
blanket grant — `write-all`, `read-all` — is precisely what this \
test refuses; name the scopes.",
permissions.inline
);
assert!(
!permissions.block.is_empty(),
"release.yml job `{job}` declares an empty `permissions:` block; \
either name the scopes or delete the key"
);
const OIDC_JOB: &str = "channels";
for (_, text) in &permissions.block {
let (scope, level) = permission_entry(text);
let allowed = matches!(
(scope.as_str(), level.as_str()),
("contents", "write") | ("contents", "read")
) || (job == OIDC_JOB
&& (scope.as_str(), level.as_str()) == ("id-token", "write"));
assert!(
allowed,
"release.yml job `{job}` requests `{text}`. A release job may \
request `contents: write` (or `contents: read`) and nothing \
else, except `{OIDC_JOB}`, which may also request \
`id-token: write` for registry trusted publishing. `packages: \
write` -- and `id-token: write` in any other job -- would let \
the workflow that publishes mint credentials of its own \
(`07-security.md`)."
);
}
}
}
#[test]
fn ci_workflow_runs_on_the_right_pull_request_events_and_on_push_to_main() {
let source = read_workflow("ci.yml");
assert_eq!(
inline_list_at(&source, &["on", "pull_request", "types"]),
Some(vec![
"opened".to_string(),
"synchronize".to_string(),
"reopened".to_string(),
]),
"ci.yml must run on pull-request opened, synchronize, and reopened"
);
assert_eq!(
inline_list_at(&source, &["on", "push", "branches"]),
Some(vec!["main".to_string()]),
"ci.yml must run on push to `main`, and the branch filter must be on \
`push` — under `pull_request` it would restrict which PRs build \
instead"
);
}
#[test]
fn ci_workflow_has_no_release_trigger() {
let source = read_workflow("ci.yml");
assert_triggers("ci.yml", &source);
assert!(
locate(&source, &["on", "push", "tags"]).is_none(),
"ci.yml must not trigger on pushed tags: that is a release trigger \
under another name"
);
}
#[test]
fn e2e_workflow_has_no_release_trigger_and_runs_when_ci_runs() {
let source = read_workflow("e2e.yml");
assert_triggers("e2e.yml", &source);
assert!(
locate(&source, &["on", "push", "tags"]).is_none(),
"e2e.yml must not trigger on pushed tags"
);
assert_eq!(
inline_list_at(&source, &["on", "pull_request", "types"]),
Some(vec![
"opened".to_string(),
"synchronize".to_string(),
"reopened".to_string(),
]),
"e2e.yml must carry the same pull-request event types as ci.yml, or \
splitting it out silently changed the acceptance coverage"
);
assert_eq!(
inline_list_at(&source, &["on", "push", "branches"]),
Some(vec!["main".to_string()]),
"e2e.yml must carry the same push branch filter as ci.yml"
);
}
#[test]
fn e2e_workflow_serialises_the_shared_fixture_at_workflow_level() {
let source = read_workflow("e2e.yml");
let concurrency = locate(&source, &["concurrency"]).expect(
"e2e.yml must declare a WORKFLOW-level `concurrency:` block; a \
job-level one cancels matrix legs instead of serialising them",
);
let entries: Vec<&str> = concurrency
.block
.iter()
.map(|(_, text)| text.as_str())
.collect();
assert_eq!(
entries,
vec!["group: e2e-fixture", "cancel-in-progress: false"],
"e2e.yml's workflow-level concurrency must key on the shared fixture \
and must never cancel in progress: cancelling mid-scenario strands a \
registered runner in the fixture org, which is the exact state the \
next run's post-conditions report as a failure"
);
let jobs =
child_keys(&source, &["jobs"]).expect("e2e.yml must declare a top-level `jobs:` block");
assert!(!jobs.is_empty(), "no jobs parsed out of e2e.yml");
for job in &jobs {
assert!(
locate(&source, &["jobs", job.as_str(), "concurrency"]).is_none(),
"e2e.yml job `{job}` declares its own `concurrency:` block. That is \
the construct this file was split out to avoid — a group shared by \
the matrix legs cancels them rather than serialising them. Leave \
the serialisation at workflow level."
);
}
assert_eq!(
locate(&source, &["jobs", "e2e", "strategy", "max-parallel"]).map(|found| found.inline),
Some("1".to_string()),
"the e2e matrix must run one leg at a time: the three legs share one \
disposable repo and one disposable org, and h1's post-conditions sweep \
them repo-wide"
);
}
#[test]
fn e2e_workflow_defines_the_fixed_acceptance_command_behind_a_guard() {
let source = read_workflow("e2e.yml");
assert!(
source.contains("cargo test -p runner-manager-e2e -- --ignored"),
"the e2e job's command is fixed here so that h1 fills the acceptance \
suite in without ever editing this workflow"
);
assert!(
source.contains("steps.guard.outputs.enabled == 'true'"),
"the e2e job must be guarded by a step output rather than run \
unconditionally, so that it skips instead of failing when its secret \
is absent"
);
let ci = read_workflow("ci.yml");
assert!(
!ci.contains("cargo test -p runner-manager-e2e"),
"the acceptance suite belongs to e2e.yml alone. ci.yml's own \
`concurrency` block cancels in-progress runs on pull requests, which \
is exactly what must never happen to a fixture scenario."
);
}