use std::{
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use serde_yaml::{Mapping, Value};
const CANCEL_EXPRESSION: &str = "${{ github.event_name == 'pull_request' }}";
const PULL_REQUEST: &str = "pull_request";
const RUN_UNIQUE_EXPRESSIONS: [&str; 4] = [
"github.run_id",
"github.run_number",
"github.run_attempt",
"github.sha",
];
const PER_PULL_REQUEST_EXPRESSIONS: [&str; 3] = [
"github.event.pull_request.number",
"github.head_ref",
"github.ref",
];
const KNOWN_PULL_REQUEST_WORKFLOWS: [&str; 2] = ["ci.yml", "verus.yml"];
const WORKFLOW_SUFFIXES: [&str; 2] = ["yml", "yaml"];
struct Workflow {
file: String,
triggers: Vec<String>,
concurrency: Option<Mapping>,
}
impl Workflow {
fn setting(&self, key: &str) -> String {
let Some(mapping) = self.concurrency.as_ref() else {
return String::new();
};
match mapping.get(Value::String(key.to_owned())) {
Some(Value::String(text)) => text.clone(),
Some(Value::Bool(flag)) => flag.to_string(),
Some(Value::Number(number)) => number.to_string(),
_ => String::new(),
}
}
}
fn workflow_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(".github")
.join("workflows")
}
fn triggers(document: &Mapping) -> Vec<String> {
let declared = document
.get(Value::String("on".to_owned()))
.or_else(|| document.get(Value::Bool(true)));
match declared {
Some(Value::Mapping(mapping)) => mapping
.keys()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect(),
Some(Value::Sequence(items)) => items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect(),
Some(Value::String(event)) => vec![event.clone()],
_ => Vec::new(),
}
}
fn pull_request_workflows() -> Result<Vec<Workflow>> {
let directory = workflow_dir();
let mut found = Vec::new();
for entry in fs::read_dir(&directory)
.with_context(|| format!("read workflow directory {}", directory.display()))?
{
let path = entry.context("read a workflow directory entry")?.path();
let is_workflow = path
.extension()
.and_then(|suffix| suffix.to_str())
.is_some_and(|suffix| WORKFLOW_SUFFIXES.contains(&suffix));
if !is_workflow {
continue;
}
let text = fs::read_to_string(&path)
.with_context(|| format!("read workflow {}", path.display()))?;
let document: Mapping = serde_yaml::from_str(&text)
.with_context(|| format!("parse workflow {}", path.display()))?;
let declared = triggers(&document);
if !declared.iter().any(|event| event == PULL_REQUEST) {
continue;
}
found.push(Workflow {
file: path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_owned(),
triggers: declared,
concurrency: document
.get(Value::String("concurrency".to_owned()))
.and_then(Value::as_mapping)
.cloned(),
});
}
found.sort_by(|left, right| left.file.cmp(&right.file));
Ok(found)
}
#[test]
fn discovery_still_finds_the_known_pull_request_workflows() -> Result<()> {
let discovered = pull_request_workflows()?;
for expected in KNOWN_PULL_REQUEST_WORKFLOWS {
assert!(
discovered.iter().any(|workflow| workflow.file == expected),
"`{expected}` starts on `{PULL_REQUEST}` but discovery missed it; the contracts below \
would pass without asserting anything about it"
);
}
Ok(())
}
#[test]
fn every_pull_request_workflow_declares_a_concurrency_group() -> Result<()> {
for workflow in pull_request_workflows()? {
let group = workflow.setting("group");
assert!(
!group.trim().is_empty(),
"`{}` declares `{:?}` and must declare `concurrency.group`; without it a superseded \
run holds a runner until it finishes",
workflow.file,
workflow.triggers
);
}
Ok(())
}
#[test]
fn the_concurrency_group_is_not_unique_to_one_run() -> Result<()> {
for workflow in pull_request_workflows()? {
let group = workflow.setting("group");
for expression in RUN_UNIQUE_EXPRESSIONS {
assert!(
!group.contains(expression),
"`{}` builds its concurrency group from `{expression}`, which is unique to one \
run; the group would never match a superseded run and would cancel nothing",
workflow.file
);
}
}
Ok(())
}
#[test]
fn the_concurrency_group_distinguishes_one_pull_request_from_another() -> Result<()> {
for workflow in pull_request_workflows()? {
let group = workflow.setting("group");
assert!(
PER_PULL_REQUEST_EXPRESSIONS
.iter()
.any(|expression| group.contains(expression)),
"`{}` must key its concurrency group on the pull request, by naming one of \
{PER_PULL_REQUEST_EXPRESSIONS:?}; a group shared by every branch would cancel \
unrelated pull requests",
workflow.file
);
}
Ok(())
}
#[test]
fn cancellation_is_conditioned_on_the_event() -> Result<()> {
for workflow in pull_request_workflows()? {
assert_eq!(
workflow.setting("cancel-in-progress"),
CANCEL_EXPRESSION,
"`{}` must set `cancel-in-progress` to `{CANCEL_EXPRESSION}`; a missing value leaves \
superseded runs in flight, and a literal `true` also cancels pushes, schedules, and \
dispatches",
workflow.file
);
}
Ok(())
}