use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
const REQUIRED_CHECK: &str = "Wire compat";
const AGGREGATED_HALVES: [&str; 2] = ["wire-compat-rust", "wire-compat-js"];
const WIRE_FILTER: [&str; 7] = [
"pg-core/**",
"pg-wasm/**",
"pg-compat/**",
"pg-compat-js/**",
"Cargo.lock",
"Cargo.toml",
".github/workflows/build.yml",
];
const SEMVER_FILTER: [&str; 7] = [
"pg-core/**",
"pg-wasm/**",
"Cargo.toml",
"Cargo.lock",
"scripts/semver-checks.sh",
"scripts/semver-checks-test.sh",
".github/workflows/build.yml",
];
const SEAL_COMMAND: &str = "cargo run --locked -p pg-core --features stream --example seal-samples";
const OPEN_COMMAND: &str = "cargo test --manifest-path pg-compat/Cargo.toml --locked";
const GATE_CONDITION: &str = "steps.gate.outputs.run == 'true'";
const PUSH_OVERRIDE: &str = r#""$GITHUB_EVENT_NAME" == "push""#;
const SEALED_OUTPUT: &str = "needs.wire-compat-rust.outputs.sealed == 'success'";
const PULL_REQUEST_TYPES: [&str; 4] = ["opened", "synchronize", "reopened", "edited"];
fn workflow_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join(".github/workflows/build.yml")
}
fn workflow() -> String {
let path = workflow_path();
fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
.replace("\r\n", "\n")
}
fn job(workflow: &str, id: &str) -> String {
let body = workflow
.split_once("\njobs:\n")
.unwrap_or_else(|| panic!("no top-level `jobs:` in {}", workflow_path().display()))
.1;
let needle = format!(" {id}:");
let mut lines = body.lines().skip_while(|line| line.trim_end() != needle);
assert!(
lines.next().is_some(),
"{} has no job `{id}:`, so whatever this test asserts about it is vacuous",
workflow_path().display(),
);
lines
.take_while(|line| line.trim().is_empty() || line.starts_with(" "))
.collect::<Vec<_>>()
.join("\n")
}
fn job_ids(workflow: &str) -> Vec<String> {
let body = workflow
.split_once("\njobs:\n")
.expect("no top-level `jobs:`")
.1;
body.lines()
.filter_map(|line| {
let rest = line.strip_prefix(" ")?;
if rest.starts_with(char::is_whitespace) || rest.starts_with('#') {
return None;
}
rest.strip_suffix(':').map(str::to_owned)
})
.collect()
}
fn field(job: &str, key: &str) -> Option<String> {
let needle = format!("{key}:");
job.lines()
.filter_map(|line| line.strip_prefix(" "))
.filter(|rest| !rest.starts_with(char::is_whitespace) && !rest.starts_with('-'))
.find_map(|rest| rest.strip_prefix(&needle))
.map(|value| value.trim().to_owned())
}
fn steps(job: &str) -> Vec<String> {
let mut steps: Vec<String> = Vec::new();
for line in job.lines().skip_while(|line| line.trim() != "steps:") {
if line.starts_with(" - ") {
steps.push(String::new());
}
if let Some(step) = steps.last_mut() {
step.push_str(line);
step.push('\n');
}
}
steps
}
fn step_with<'a>(steps: &'a [String], needle: &str) -> &'a str {
let found: Vec<&String> = steps.iter().filter(|step| step.contains(needle)).collect();
assert_eq!(
found.len(),
1,
"expected exactly one step containing {needle:?} in {}, found {}",
workflow_path().display(),
found.len(),
);
found[0]
}
fn filter_paths(job: &str) -> BTreeSet<String> {
let mut lines = job.lines().skip_while(|line| line.trim() != "filters: |");
let header = lines.next().expect("no `filters: |` block in the job");
let indent = header.len() - header.trim_start().len();
lines
.take_while(|line| line.trim().is_empty() || line.len() - line.trim_start().len() > indent)
.map(str::trim)
.filter_map(|line| line.strip_prefix("- "))
.map(|path| path.trim_matches('\'').to_owned())
.collect()
}
fn expected(paths: &[&str]) -> BTreeSet<String> {
paths.iter().map(|p| (*p).to_owned()).collect()
}
#[test]
fn the_required_check_name_still_names_the_aggregator() {
let workflow = workflow();
let named: Vec<String> = job_ids(&workflow)
.into_iter()
.filter(|id| field(&job(&workflow, id), "name").as_deref() == Some(REQUIRED_CHECK))
.collect();
assert_eq!(
named.len(),
1,
"the `main: required checks` ruleset (bypass_actors: []) requires the context \
{REQUIRED_CHECK:?}, and {} jobs in {} carry that name: {named:?}. A ruleset \
requiring a context no job produces blocks nothing, so renaming this job disarms \
the gate instead of breaking it -- rename the ruleset's context in the same change \
(`gh api repos/encryption4all/postguard/rules/branches/main`).",
named.len(),
workflow_path().display(),
);
let aggregator = job(&workflow, &named[0]);
let needs: BTreeSet<String> = field(&aggregator, "needs")
.expect("the aggregator has no `needs:`")
.trim_matches(['[', ']'].as_slice())
.split(',')
.map(|need| need.trim().to_owned())
.collect();
assert_eq!(
needs,
expected(&AGGREGATED_HALVES),
"the job named {REQUIRED_CHECK:?} does not aggregate both halves of the wire gate, \
so the repo's sole required check would go green on a run where one half never \
passed",
);
assert_eq!(
field(&aggregator, "if").as_deref(),
Some("${{ !cancelled() }}"),
"the job named {REQUIRED_CHECK:?} must run even when an upstream half failed, or a \
red half leaves the required check pending instead of failing it",
);
for half in AGGREGATED_HALVES {
assert!(
aggregator.contains(&format!(r#""${{{{ needs.{half}.result }}}}" != "success""#)),
"the job named {REQUIRED_CHECK:?} never reads `{half}`'s result, so it reports \
success however that half ended",
);
}
assert!(
aggregator.contains("exit 1"),
"the job named {REQUIRED_CHECK:?} reads both halves' results and then exits zero \
regardless, so the repo's sole required check can never go red",
);
}
#[test]
fn a_retitled_or_retargeted_pr_still_re_runs_the_gates() {
let workflow = workflow();
let triggers = workflow
.split_once("\njobs:\n")
.expect("no top-level `jobs:`")
.0;
let types: BTreeSet<String> = triggers
.lines()
.map(str::trim)
.find_map(|line| line.strip_prefix("types:"))
.expect("the `pull_request` trigger declares no `types:`")
.trim()
.trim_matches(['[', ']'].as_slice())
.split(',')
.map(|kind| kind.trim().to_owned())
.collect();
assert_eq!(
types,
expected(&PULL_REQUEST_TYPES),
"the `pull_request` trigger no longer re-runs on the events these gates' verdicts \
depend on. `edited` is the one that is not a default: without it a retitle leaves \
the semver gate's verdict attached to the title it was computed from, and a base \
retarget leaves paths-filter's attached to the old base",
);
}
#[test]
fn the_rust_wire_gate_still_seals_and_opens_what_it_claims_to() {
let job = job(&workflow(), "wire-compat-rust");
let steps = steps(&job);
assert_eq!(
filter_paths(&job),
expected(&WIRE_FILTER),
"wire-compat-rust's path filter and this test disagree about what can change the \
sealed bytes. A path dropped here does not fail the gate, it silently stops it \
firing; if the filter was widened on purpose, widen WIRE_FILTER with it",
);
let gate = step_with(&steps, "id: gate");
assert!(
gate.contains(PUSH_OVERRIDE),
"wire-compat-rust's gate step no longer bypasses the path filter on `push`. On a \
push the filter diffs only the push that triggered it, so a commit that reached \
`main` without this gate running is never re-checked -- and the job still reports \
green, because \"filter said no\" and \"gate passed\" are the same success (#299)",
);
let seal = step_with(&steps, "id: seal");
assert!(
seal.contains(SEAL_COMMAND),
"wire-compat-rust does not seal with `{SEAL_COMMAND}`, so the bytes the published \
readers open are not the ones this tree produces",
);
assert!(
seal.contains(GATE_CONDITION),
"wire-compat-rust's seal step is not behind `{GATE_CONDITION}`, so it no longer \
shares one decision with the open step and the `push` override can be applied to \
one and forgotten on the other",
);
let open = step_with(&steps, OPEN_COMMAND);
assert!(
open.contains(GATE_CONDITION),
"wire-compat-rust's open step is not behind `{GATE_CONDITION}`, so it no longer \
shares one decision with the seal step",
);
assert!(
job.contains("sealed: ${{ steps.seal.outcome }}"),
"wire-compat-rust no longer publishes `sealed` from the seal step's outcome, which is \
the only thing wire-compat-js gates on -- without it the Node half runs on nothing, \
or not at all",
);
}
#[test]
fn the_js_wire_gate_still_opens_the_bytes_the_rust_half_sealed() {
let job = job(&workflow(), "wire-compat-js");
let steps = steps(&job);
assert_eq!(
field(&job, "needs").as_deref(),
Some("wire-compat-rust"),
"wire-compat-js no longer depends on wire-compat-rust, so it cannot be holding both \
readers to the same bytes",
);
assert_eq!(
field(&job, "if").as_deref(),
Some("${{ !cancelled() }}"),
"wire-compat-js must still run when the Rust half failed: a red seal is exactly when \
it is worth knowing whether the JS readers broke the same way, and the artifact is \
uploaded before that job's read step for this reason",
);
let download = step_with(&steps, "actions/download-artifact");
assert!(
download.contains(SEALED_OUTPUT),
"wire-compat-js's download is not gated on `{SEALED_OUTPUT}`, so it no longer keys \
off the same outcome the seal did and the two halves can drift over whether to run",
);
let install = step_with(&steps, "run: npm ci");
assert!(
install.contains("working-directory: pg-compat-js"),
"wire-compat-js no longer installs the pinned readers with `npm ci` in pg-compat-js, \
so the gate measures whatever npm resolves rather than the declared support window",
);
let open = step_with(&steps, "run: npm test");
assert!(
open.contains(SEALED_OUTPUT) && open.contains("working-directory: pg-compat-js"),
"wire-compat-js does not run `npm test` in pg-compat-js behind `{SEALED_OUTPUT}`, so \
the published npm readers are not being pointed at the sealed set",
);
}
#[test]
fn the_semver_gate_still_calls_the_script_this_repo_pins() {
let job = job(&workflow(), "semver-checks");
let steps = steps(&job);
assert_eq!(
filter_paths(&job),
expected(&SEMVER_FILTER),
"semver-checks's path filter and this test disagree about which changes can break a \
published API. A path dropped here silently stops the gate firing; if the filter \
was widened on purpose, widen SEMVER_FILTER with it",
);
step_with(&steps, "./scripts/semver-checks-test.sh");
let check = step_with(&steps, "./scripts/semver-checks.sh");
assert!(
check.contains("SEMVER_RELEASE_TYPE: ${{ steps.declared.outputs.release_type }}"),
"semver-checks no longer passes SEMVER_RELEASE_TYPE from the declaration step, so \
either every breaking change fails the gate or none of them do",
);
let declared = step_with(&steps, "id: declared");
assert!(
declared.contains("PR_TITLE: ${{ github.event.pull_request.title }}"),
"the breaking-change declaration is no longer read off the PR title. It cannot be \
read from the PR body: squash_merge_commit_message is COMMIT_MESSAGES, so the body \
never reaches the commit release-plz reads",
);
assert!(
declared.contains("release_type=major"),
"the declaration step no longer emits `release_type=major`, so the `!` in a PR title \
grants nothing and a declared break fails the gate anyway",
);
}
#[test]
fn the_registry_gate_still_reads_the_ruleset_back() {
let job = job(&workflow(), "ruleset-drift");
let steps = steps(&job);
step_with(&steps, "scripts/ruleset-drift.sh");
step_with(&steps, "scripts/ruleset-drift-test.sh");
assert!(
job.contains("actions/checkout"),
"the registry gate no longer checks the repo out, so the script it runs is not there",
);
let aggregator = self::job(&workflow(), "wire-compat");
assert!(
!aggregator.contains("ruleset-drift"),
"the registry gate has been wired into the sole required check. It reads a live API, \
so an outage there would block every merge; keep it a separate, non-required context",
);
}
#[test]
fn every_job_this_test_reads_is_still_found() {
let workflow = workflow();
let ids = job_ids(&workflow);
for id in [
"wire-compat-rust",
"wire-compat-js",
"semver-checks",
"wire-compat",
"ruleset-drift",
] {
assert!(
ids.contains(&id.to_owned()),
"{} has no job `{id}`, and this test is what was supposed to notice",
workflow_path().display(),
);
let body = job(&workflow, id);
assert!(
field(&body, "name").is_some() && !steps(&body).is_empty(),
"job `{id}` read back as {} lines with no name or no steps, so the reader in \
this file no longer understands {}",
body.lines().count(),
workflow_path().display(),
);
}
assert_ne!(
job(&workflow, "wire-compat"),
job(&workflow, "wire-compat-rust"),
"the job reader is matching on a prefix, so `wire-compat` and `wire-compat-rust` \
read back as the same block",
);
}