use std::path::PathBuf;
use std::process::Command;
const CHANGELOG: &str = include_str!("../CHANGELOG.md");
const CARGO_TOML: &str = include_str!("../Cargo.toml");
const RELEASE_YML: &str = include_str!("../.github/workflows/release.yml");
const RELEASE_DEPLOY_YML: &str = include_str!("../.github/workflows/release-deploy.yml");
const SCRIPT: &str = ".github/scripts/verify-release-version.sh";
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn top_versioned_heading() -> String {
CHANGELOG
.lines()
.find_map(|line| {
let rest = line.strip_prefix("## [")?;
let version = rest.split(']').next()?;
version
.starts_with(|c: char| c.is_ascii_digit())
.then(|| version.to_string())
})
.expect("CHANGELOG.md has no `## [x.y.z]` heading at all")
}
fn cargo_toml_version() -> String {
CARGO_TOML
.lines()
.find_map(|line| {
let rest = line.trim().strip_prefix("version")?.trim_start();
let rest = rest.strip_prefix('=')?.trim();
rest.strip_prefix('"')?
.split('"')
.next()
.map(str::to_string)
})
.expect("Cargo.toml declares no version")
}
#[test]
fn the_changelog_parses_at_all() {
let top = top_versioned_heading();
assert!(
CHANGELOG.contains("## [Unreleased]"),
"the Unreleased heading is gone, so the digit-after-bracket rule this all \
depends on is no longer being exercised"
);
assert_ne!(top, "Unreleased", "`## [Unreleased]` was read as a version");
assert!(
top.starts_with(|c: char| c.is_ascii_digit()),
"the topmost versioned heading parsed as `{top}`"
);
}
#[test]
fn changelog_names_the_version_cargo_toml_is_prepared_to_release() {
assert_eq!(
cargo_toml_version(),
top_versioned_heading(),
"Cargo.toml and CHANGELOG.md's topmost heading disagree. The release guard \
compares the computed version against that heading, so a release dispatched \
from this tree would abort. Move both together"
);
}
#[test]
fn the_workflow_still_calls_the_guard() {
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 checks the version being \
published against CHANGELOG.md"
);
}
#[test]
fn the_guard_runs_before_anything_is_written() {
let guard = RELEASE_YML
.find(SCRIPT)
.expect("release.yml no longer calls the guard");
for writer in [
"cargo set-version",
"git add Cargo.toml",
"git tag -a",
"git push origin",
] {
let at = RELEASE_YML.find(writer).unwrap_or_else(|| {
panic!(
"release.yml no longer contains `{writer}`. If the step was renamed, \
update this list — otherwise this test passes by finding nothing"
)
});
assert!(
guard < at,
"`{writer}` runs before the version guard, so the guard fires after the \
damage is done"
);
}
}
#[test]
fn the_guard_step_is_not_skipped_on_a_dry_run() {
let start = RELEASE_YML
.find(" - name: Verify the version is the one being released")
.expect("the guard step was renamed; update this test with it");
let rest = &RELEASE_YML[start + 1..];
let end = rest
.find("\n - name: ")
.expect("the guard step is never closed by another step");
let step = &rest[..end];
assert!(
!step.contains("if:"),
"the guard step grew an `if:`, so some dispatch no longer runs it:\n{step}"
);
let sibling = RELEASE_YML
.find(" - name: Update Cargo.toml version")
.expect("the Cargo.toml step was renamed");
let sibling = &RELEASE_YML[sibling..];
let sibling_end = sibling[1..]
.find("\n - name: ")
.expect("the Cargo.toml step is never closed");
assert!(
sibling[..sibling_end].contains("if:"),
"the neighbouring step no longer carries an `if:`, so the check above is \
not looking for anything"
);
}
#[test]
fn the_script_agrees_with_this_file_about_the_top_heading() {
let script = repo_root().join(SCRIPT);
let changelog = repo_root().join("CHANGELOG.md");
let top = top_versioned_heading();
let run = |version: &str| {
Command::new("bash")
.arg(&script)
.arg(version)
.arg(&changelog)
.output()
.expect("bash is available to run the guard")
};
assert!(
run(&top).status.success(),
"the script rejects `{top}`, which this file reads as the topmost heading"
);
let wrong = format!("{top}-not-a-real-version");
let rejected = run(&wrong);
assert!(
!rejected.status.success(),
"the script accepted `{wrong}`, so it is not comparing anything"
);
let message = String::from_utf8_lossy(&rejected.stderr);
assert!(
message.contains(&top) && message.contains(&wrong),
"the mismatch message names neither value, which is the whole point of \
printing one:\n{message}"
);
}
const PUBLISH_GUARD_STEP: &str = "Verify CHANGELOG.md names the version being published";
const TAGS_THAT_PREDATE_THE_GUARD: [&str; 5] = ["0.3.0", "0.4.0", "0.5.0", "0.5.1", "0.7.0"];
fn step<'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 rest = &yaml[at..];
let end = rest[1..]
.find("\n - name: ")
.map_or(rest.len(), |i| i + 1);
&rest[..end]
}
fn run_body(step: &str) -> String {
const KEY: &str = " run: |\n";
const INDENT: &str = " ";
let at = step
.find(KEY)
.unwrap_or_else(|| panic!("the step has no `run: |` block:\n{step}"));
let mut body = String::new();
for line in step[at + KEY.len()..].lines() {
if line.trim().is_empty() {
body.push('\n');
continue;
}
let dedented = line.strip_prefix(INDENT).unwrap_or_else(|| {
panic!(
"a line of the `run:` body is indented less than the block's ten \
spaces, so the body is being read as ending before it: {line:?}"
)
});
body.push_str(dedented);
body.push('\n');
}
assert!(
body.lines().filter(|l| !l.trim().is_empty()).count() >= 20,
"the guard step's body came out as {} non-blank lines, which is shorter than \
the step has ever been. A truncated body passes every behavioural test below \
by doing nothing:\n{body}",
body.lines().filter(|l| !l.trim().is_empty()).count()
);
body
}
fn version_key(version: &str) -> Vec<u64> {
let base = version.split('-').next().unwrap_or(version);
let parts: Vec<u64> = base
.split('.')
.map(|part| {
part.parse().unwrap_or_else(|_| {
panic!("`{version}` is not a numeric x.y.z version, so it cannot be ordered")
})
})
.collect();
assert_eq!(
parts.len(),
3,
"`{version}` does not have three numeric components"
);
parts
}
fn publish_guard_floor() -> String {
run_body(step(RELEASE_DEPLOY_YML, PUBLISH_GUARD_STEP))
.lines()
.find_map(|line| line.trim().strip_prefix("FLOOR=").map(str::to_string))
.expect("the guard step no longer sets `FLOOR=`, which is what decides what an absent script means")
}
fn published_tree(top_heading: &str, carries_the_guard: bool) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("a temporary directory");
std::fs::write(
dir.path().join("CHANGELOG.md"),
format!("# Changelog\n\n## [Unreleased]\n\n## [{top_heading}] - 2026-01-01\n\n- a note\n"),
)
.expect("the fixture changelog is writable");
if carries_the_guard {
std::fs::create_dir_all(dir.path().join(".github/scripts"))
.expect("the fixture script directory is creatable");
std::fs::copy(repo_root().join(SCRIPT), dir.path().join(SCRIPT))
.expect("the real guard script is readable");
}
dir
}
fn run_publish_guard(tree: &std::path::Path, version: &str) -> std::process::Output {
Command::new("bash")
.arg("-e")
.arg("-c")
.arg(run_body(step(RELEASE_DEPLOY_YML, PUBLISH_GUARD_STEP)))
.current_dir(tree)
.env("VERSION", version)
.output()
.expect("bash is available to run the guard step")
}
#[test]
fn the_publish_job_runs_the_guard_before_it_installs_or_publishes_anything() {
let guard_step = step(RELEASE_DEPLOY_YML, PUBLISH_GUARD_STEP);
assert!(
guard_step.contains(SCRIPT),
"the publish guard step no longer names {SCRIPT}, so nothing checks the \
version being published against the tag's CHANGELOG.md:\n{guard_step}"
);
assert!(
repo_root().join(SCRIPT).exists(),
"{SCRIPT} is gone, and release-deploy.yml calls it"
);
let header = |name: &str| {
let needle = format!(" - name: {name}\n");
RELEASE_DEPLOY_YML.find(&needle).unwrap_or_else(|| {
panic!(
"release-deploy.yml has no step named `{name}`. If it was renamed, \
rename it here too — otherwise this test passes by finding nothing"
)
})
};
let guard = header(PUBLISH_GUARD_STEP);
for later in [
"Install system dependencies",
"Install Rust toolchain",
"Run cargo check",
"Dry run publish",
"Publish to crates.io",
] {
assert!(
guard < header(later),
"`{later}` runs before the changelog guard, so the guard fires after the \
time or the damage it exists to save"
);
}
assert!(
step(RELEASE_DEPLOY_YML, "Publish to crates.io").contains("cargo publish"),
"the step this guard is ordered against no longer runs `cargo publish`, so \
the ordering above is against the wrong anchor"
);
}
#[test]
fn the_publish_guard_step_takes_the_version_as_data() {
let guard_step = step(RELEASE_DEPLOY_YML, PUBLISH_GUARD_STEP);
assert!(
guard_step.contains("VERSION: ${{ needs.prepare.outputs.version }}"),
"the guard step no longer binds the prepared version under `env:`, so it is \
judging something else:\n{guard_step}"
);
let body = run_body(guard_step);
assert!(
!body.contains("${{"),
"the guard step interpolates a workflow expression into its `run:` body. Bind \
it under `env:` and read it as \"$NAME\" — see the rule at the top of the \
file:\n{body}"
);
assert!(
body.contains("\"$VERSION\""),
"the guard step's body no longer reads the bound version, so it is checking \
something other than the version being published:\n{body}"
);
assert!(
!guard_step.contains("if:"),
"the guard step grew an `if:`, so some route to `cargo publish` no longer runs \
it:\n{guard_step}"
);
assert!(
step(RELEASE_YML, "Update Cargo.toml version").contains("if:"),
"the step reader cannot see an `if:` it is looking straight at, so the \
assertion above proves nothing"
);
}
#[test]
fn the_floor_sits_above_every_existing_tag_and_at_or_below_the_prepared_version() {
let floor = publish_guard_floor();
let key = version_key(&floor);
for tag in TAGS_THAT_PREDATE_THE_GUARD {
assert!(
version_key(tag) < key,
"the guard floor {floor} is at or below {tag}, a tag whose tree does not \
carry {SCRIPT}. Publishing that tag would be refused rather than warned \
about, which breaks the recovery path the workflow_dispatch trigger \
exists for"
);
}
let prepared = cargo_toml_version();
assert!(
key <= version_key(&prepared),
"the guard floor {floor} is above {prepared}, the version Cargo.toml is \
preparing, so the next release publishes with nothing checking it. The floor \
is a boundary between two eras, not a number to keep current"
);
}
#[test]
fn the_guard_runs_the_scripts_the_tag_carries() {
let tree = published_tree("1.4.0", true);
let out = run_publish_guard(tree.path(), "1.4.0");
assert!(
out.status.success(),
"the guard rejected 1.4.0 against a changelog whose topmost heading is \
1.4.0:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!String::from_utf8_lossy(&out.stdout).contains("::warning::"),
"the guard warned about a tree that carries the script and agrees with it"
);
}
#[test]
fn the_guard_refuses_a_version_the_tags_changelog_does_not_name() {
let tree = published_tree("1.4.0", true);
let out = run_publish_guard(tree.path(), "1.5.0");
assert_eq!(
out.status.code(),
Some(1),
"the guard answered {:?} for a version the changelog does not name; 1 is \
`mismatch`, and must stay distinct from 2, `wired up wrong`",
out.status.code()
);
let message = String::from_utf8_lossy(&out.stderr);
assert!(
message.contains("1.5.0") && message.contains("1.4.0"),
"the refusal names neither the version being published nor the one the \
changelog carries, which is the whole point of printing one:\n{message}"
);
}
#[test]
fn every_tag_that_predates_the_guard_publishes_with_a_warning() {
let floor = publish_guard_floor();
for tag in TAGS_THAT_PREDATE_THE_GUARD {
let tree = published_tree("0.1.0", false);
let out = run_publish_guard(tree.path(), tag);
assert!(
out.status.success(),
"publishing {tag} — a tag cut before {SCRIPT} existed — was refused. That \
closes the recovery path:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let log = String::from_utf8_lossy(&out.stdout);
assert!(
log.contains("::warning::"),
"publishing {tag} without the guard produced no `::warning::`, so the run \
is green and silent about having checked nothing:\n{log}"
);
assert!(
log.contains(tag) && log.contains(&floor),
"the warning for {tag} names neither the version nor the floor {floor}, so \
a reader cannot tell what was skipped or why:\n{log}"
);
assert!(
log.contains("Not checked:"),
"the warning for {tag} does not say in plain text that nothing was \
checked. `::warning::` is an annotation; the log line is what somebody \
reading the job output sees:\n{log}"
);
}
}
#[test]
fn the_floor_and_everything_above_it_is_refused_when_the_script_is_absent() {
let floor = publish_guard_floor();
let [major, minor, _] = version_key(&floor)[..] else {
unreachable!("version_key returns three components")
};
let at_or_above = [
floor.clone(),
format!("{floor}-beta.1"),
format!("{major}.{}.0", minor + 1),
format!("{major}.{}.0", minor + 10),
format!("{}.0.0", major + 1),
];
for version in at_or_above {
let tree = published_tree(&version, false);
let out = run_publish_guard(tree.path(), &version);
assert_eq!(
out.status.code(),
Some(1),
"publishing {version} with {SCRIPT} missing answered {:?}. It is at or \
above the floor {floor}, so the script's absence means it was removed \
from a tree that should carry it:\nstdout: {}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let message = String::from_utf8_lossy(&out.stderr);
assert!(
message.contains(&version) && message.contains(&floor),
"the refusal for {version} names neither it nor the floor {floor}:\n{message}"
);
}
}
#[test]
fn the_guard_passes_against_this_repository_as_it_stands() {
let out = run_publish_guard(&repo_root(), &cargo_toml_version());
assert!(
out.status.success(),
"the publish guard, run against this repository with the version Cargo.toml \
is preparing, refuses it. A release cut from this tree would abort:\nstdout: \
{}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!String::from_utf8_lossy(&out.stdout).contains("::warning::"),
"the guard warned rather than checked against this repository, which does \
carry {SCRIPT} — so the presence test is not seeing the file that is there"
);
}