use camino::Utf8Path;
use serde::Serialize;
use crate::embedded;
use crate::profile::RegistryAnswer;
#[derive(Debug, Clone, Serialize)]
pub struct InvariantFailure {
pub code: &'static str,
pub destination: String,
pub reason: String,
pub remediation: &'static str,
}
impl InvariantFailure {
fn new(
code: &'static str,
destination: &str,
reason: impl Into<String>,
remediation: &'static str,
) -> Self {
Self {
code,
destination: destination.to_owned(),
reason: reason.into(),
remediation,
}
}
}
#[must_use]
pub fn failures(
tech: &str,
forge: &str,
destination: &str,
bytes: &[u8],
registry: Option<RegistryAnswer>,
) -> Vec<InvariantFailure> {
match (tech, forge, destination) {
("rust", "github", "dist-workspace.toml") => dist_workspace(destination, bytes),
("rust", _, "release-plz.toml") => release_plz(destination, bytes, registry),
_ => Vec::new(),
}
}
fn parsed(destination: &str, bytes: &[u8]) -> Result<toml::Table, InvariantFailure> {
let text = std::str::from_utf8(bytes).map_err(|_| {
InvariantFailure::new(
"unparsable-configuration",
destination,
"the file is not UTF-8, so its configuration cannot be judged",
"repair the file so it parses as TOML",
)
})?;
text.parse().map_err(|error| {
InvariantFailure::new(
"unparsable-configuration",
destination,
format!("the file does not parse as TOML: {error}"),
"repair the file so it parses as TOML",
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Flag {
Absent,
Set(bool),
Malformed,
}
impl Flag {
const fn over(self, workspace: Self) -> Self {
match self {
Self::Absent => workspace,
set => set,
}
}
}
fn release_plz_scopes(
destination: &str,
table: &toml::Table,
failures: &mut Vec<InvariantFailure>,
) -> Vec<(String, Flag, Flag)> {
let mut flag = |scope_name: &str, scope: Option<&toml::Table>, key: &str| -> Flag {
match scope.and_then(|scope| scope.get(key)) {
None => Flag::Absent,
Some(toml::Value::Boolean(value)) => Flag::Set(*value),
Some(other) => {
failures.push(InvariantFailure::new(
"release-plz-flag-not-boolean",
destination,
format!(
"{scope_name} sets {key} = {other}, which is no boolean, so release-plz refuses the file"
),
"write publish and git_only as true or false, unquoted",
));
Flag::Malformed
}
}
};
let workspace = table.get("workspace").and_then(toml::Value::as_table);
let workspace_flags = (
flag("[workspace]", workspace, "publish"),
flag("[workspace]", workspace, "git_only"),
);
let mut scopes = vec![(
"[workspace]".to_owned(),
workspace_flags.0,
workspace_flags.1,
)];
for package in table
.get("package")
.and_then(toml::Value::as_array)
.into_iter()
.flatten()
.filter_map(toml::Value::as_table)
{
let name = format!(
"the [[package]] {}",
package
.get("name")
.and_then(toml::Value::as_str)
.unwrap_or("an unnamed package")
);
let (publish, git_only) = (
flag(&name, Some(package), "publish"),
flag(&name, Some(package), "git_only"),
);
if publish == Flag::Absent && git_only == Flag::Absent {
continue;
}
scopes.push((
name,
publish.over(workspace_flags.0),
git_only.over(workspace_flags.1),
));
}
scopes
}
fn release_plz(
destination: &str,
bytes: &[u8],
registry: Option<RegistryAnswer>,
) -> Vec<InvariantFailure> {
let table = match parsed(destination, bytes) {
Ok(table) => table,
Err(failure) => return vec![failure],
};
let mut failures = Vec::new();
let scopes = release_plz_scopes(destination, &table, &mut failures);
let answer = registry.unwrap_or(RegistryAnswer::CratesIo);
for (scope, publish, git_only) in scopes {
if publish == Flag::Malformed || git_only == Flag::Malformed {
continue;
}
let git_only = git_only == Flag::Set(true);
if publish == Flag::Set(false) && !git_only {
failures.push(InvariantFailure::new(
"publish-off-without-git-only",
destination,
format!(
"{scope} sets publish = false without git_only = true, so release-plz compares with the registry and never proposes a version past the last published one"
),
"set git_only = true beside publish = false, and set profile.release.registry = \"none\" in .release-kit/config.toml",
));
}
if publish == Flag::Set(true) && git_only {
failures.push(InvariantFailure::new(
"git-only-with-publish",
destination,
format!(
"{scope} sets git_only = true beside publish = true, a pair release-plz refuses"
),
"remove publish = true, or set publish = false",
));
}
match (answer, git_only) {
(RegistryAnswer::None, false) => failures.push(InvariantFailure::new(
"registry-answer-disagrees",
destination,
format!(
"profile.release.registry is none, and {scope} does not set git_only = true, so release-plz reads the registry for a crate that publishes to none"
),
"set publish = false and git_only = true in [workspace] of release-plz.toml, or set profile.release.registry = \"crates-io\" in .release-kit/config.toml",
)),
(RegistryAnswer::CratesIo, true) => failures.push(InvariantFailure::new(
"registry-answer-disagrees",
destination,
format!(
"profile.release.registry is crates-io or unanswered, and {scope} sets git_only = true, so release-plz never publishes the crate"
),
"set profile.release.registry = \"none\" in .release-kit/config.toml, or remove git_only from release-plz.toml and set publish = true",
)),
_ => {}
}
}
failures
}
fn dist_workspace(destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
let Ok(text) = std::str::from_utf8(bytes) else {
return vec![InvariantFailure::new(
"unparsable-configuration",
destination,
"the file is not UTF-8, so its configuration cannot be judged",
"repair the file so it parses as TOML",
)];
};
let table: toml::Table = match text.parse() {
Ok(table) => table,
Err(error) => {
return vec![InvariantFailure::new(
"unparsable-configuration",
destination,
format!("the file does not parse as TOML: {error}"),
"repair the file so it parses as TOML",
)];
}
};
let dist = table.get("dist").and_then(toml::Value::as_table);
let mut failures = Vec::new();
let value = |key: &str| dist.and_then(|dist| dist.get(key));
if value("github-attestations").and_then(toml::Value::as_bool) != Some(true) {
failures.push(InvariantFailure::new(
"attestations-disabled",
destination,
"github-attestations is not effectively true, so no release artifact is attested",
"set github-attestations = true in [dist]",
));
}
let phase = value("github-attestations-phase").and_then(toml::Value::as_str);
if phase != Some("host") {
failures.push(InvariantFailure::new(
"attestation-phase-not-host",
destination,
phase.map_or_else(
|| "github-attestations-phase is unset, so the default phase attests only the per-platform archives and the curled installers ship unattested".to_owned(),
|other| format!(
"github-attestations-phase is \"{other}\"; only the host phase attests every asset before the release page exists"
),
),
"set github-attestations-phase = \"host\" in [dist]",
));
}
if value("github-release").and_then(toml::Value::as_str) != Some("host") {
failures.push(InvariantFailure::new(
"release-phase-unpaired",
destination,
"github-release is not \"host\", leaving the release creation unpaired with the attest phase",
"set github-release = \"host\" in [dist], pairing the release creation with the phase that attests",
));
}
if value("github-attestations-filters").is_some() {
failures.push(InvariantFailure::new(
"attestation-filters-narrowed",
destination,
"github-attestations-filters narrows what is attested below the whole set of release artifacts",
"remove github-attestations-filters from [dist]; the default [\"*\"] attests every hosted file",
));
}
let mode = value("pr-run-mode").and_then(toml::Value::as_str);
if mode != Some("skip") {
failures.push(InvariantFailure::new(
"pr-run-mode-not-skip",
destination,
mode.map_or_else(
|| "pr-run-mode is unset, so it defaults to plan and the generated workflow reports a job on every pull request that no gate can need".to_owned(),
|other| format!(
"pr-run-mode is \"{other}\", so the generated workflow reports a job on every pull request that no gate can need"
),
),
"set pr-run-mode = \"skip\" in [dist] and regenerate with dist generate, then run dist plan and the dist generate proof as a job of the workflow the required check gates",
));
}
failures.extend(action_commit_failures(
destination,
value("github-action-commits").and_then(toml::Value::as_table),
));
failures
}
fn action_commit_failures(destination: &str, found: Option<&toml::Table>) -> Vec<InvariantFailure> {
let remediation = "bring the [dist.github-action-commits] table to the embedded seed's (rk snippet rust/github/dist-workspace.toml) and regenerate with dist generate --mode ci";
let mut failures = Vec::new();
for (action, commit) in &seed_action_commits() {
match found.and_then(|table| table.get(action)) {
Some(value) => match value.as_str() {
Some(pinned) if pinned == commit.as_str() => {}
Some(pinned) => failures.push(InvariantFailure::new(
"action-commit-stale",
destination,
format!(
"[dist.github-action-commits] pins {action} at {pinned}, where the seed pins {commit}"
),
remediation,
)),
None => failures.push(InvariantFailure::new(
"action-commit-invalid",
destination,
format!(
"[dist.github-action-commits] pins {action} with a non-string value; a pin is a full commit SHA string"
),
remediation,
)),
},
None => failures.push(InvariantFailure::new(
"action-commit-missing",
destination,
format!(
"[dist.github-action-commits] does not pin {action}, so the workflow runs whatever the movable tag names"
),
remediation,
)),
}
}
failures
}
fn seed_action_commits() -> Vec<(String, String)> {
let Some(text) = embedded::SNIPPETS
.get_file("rust/github/dist-workspace.toml")
.and_then(|file| file.contents_utf8())
else {
return Vec::new();
};
let Ok(table) = text.parse::<toml::Table>() else {
return Vec::new();
};
table
.get("dist")
.and_then(toml::Value::as_table)
.and_then(|dist| dist.get("github-action-commits"))
.and_then(toml::Value::as_table)
.map(|commits| {
commits
.iter()
.filter_map(|(action, commit)| {
commit
.as_str()
.map(|commit| (action.clone(), commit.to_owned()))
})
.collect()
})
.unwrap_or_default()
}
const GENERATED_WORKFLOW: &str = ".github/workflows/release.yml";
#[must_use]
pub fn target_failures(tech: &str, forge: &str, target: &Utf8Path) -> Vec<InvariantFailure> {
match (tech, forge) {
("rust", "github") => {
let mut failures = generated_release_workflow(target);
failures.extend(dist_profile(target));
failures
}
("bash" | "python", "github") => predecessor_workflows(target),
_ => Vec::new(),
}
}
pub const PREDECESSOR_RELEASE_WORKFLOW: &str = "predecessor-release-workflow";
const ENGINE_WORKFLOW: &str = ".github/workflows/release.yml";
fn predecessor_workflows(target: &Utf8Path) -> Vec<InvariantFailure> {
let regular = |path: &str| {
let full = target.join(path);
full.is_file() && !full.is_symlink()
};
if !regular(ENGINE_WORKFLOW) {
return Vec::new();
}
crate::migrate::inventory::PREDECESSORS
.iter()
.filter(|(path, _, kind)| {
*kind == crate::migrate::inventory::Kind::Workflow
&& *path != ENGINE_WORKFLOW
&& regular(path)
})
.map(|(path, tool, _)| {
InvariantFailure::new(
PREDECESSOR_RELEASE_WORKFLOW,
path,
format!(
"a {tool} release workflow stands beside the engine workflow {ENGINE_WORKFLOW}, and each runs its own release"
),
"close any release request the workflow opened and delete its branch, then delete the workflow, as the release-kit guidance for the release that moved this target onto the engine says",
)
})
.collect()
}
pub const GATE_UNNAMED: &str = "gate-unnamed";
pub const DIST_BUILD_CONDITIONAL: &str = "dist-build-conditional";
const WORKFLOW_DIR: &str = ".github/workflows";
#[derive(Debug, Default)]
pub struct DistGate {
pub failures: Vec<InvariantFailure>,
pub limitations: Vec<(&'static str, String)>,
}
struct GatedWorkflow {
path: String,
on_requests: bool,
filtered: bool,
env: std::collections::BTreeMap<String, String>,
jobs: Vec<(String, yaml_serde::Value)>,
}
enum PlanCredit {
Every,
Named(std::collections::BTreeSet<String>),
}
enum Credit {
Targets(std::collections::BTreeSet<String>),
Conditional,
Filtered,
Unreadable(String),
}
#[must_use]
pub fn dist_targets_built(target: &Utf8Path, required_check: &str, dev_run: &str) -> DistGate {
let recorded: Vec<&str> = dev_run.split_whitespace().collect();
let mut gate = DistGate::default();
let Some(targets) = dist_targets(target) else {
return gate;
};
let workflows = gated_workflows(target);
if workflows.is_empty() {
return gate;
}
let Some(judged) = judged_jobs(&workflows, required_check, &mut gate) else {
return gate;
};
let mut built = std::collections::BTreeSet::new();
for (index, id) in &judged {
let workflow = &workflows[*index];
let Some(job) = job_of(workflow, id) else {
continue;
};
let peers: Vec<&str> = judged
.iter()
.filter(|(other, _)| other == index)
.map(|(_, peer)| peer.as_str())
.collect();
for step in steps_of(job) {
let Some(run) = step.get("run").and_then(yaml_serde::Value::as_str) else {
continue;
};
if !run.contains("dist build") {
continue;
}
match credit(workflow, job, step, run, &peers, &targets, &recorded) {
Credit::Targets(triples) => built.extend(triples),
Credit::Conditional => gate.limitations.push((
DIST_BUILD_CONDITIONAL,
format!(
"{} job {id} runs dist build under a condition, so it proves no merge and counts no target",
workflow.path
),
)),
Credit::Filtered => gate.limitations.push((
DIST_BUILD_CONDITIONAL,
format!(
"{} job {id} runs dist build in a workflow whose request trigger carries a paths filter, so it proves no merge and counts no target",
workflow.path
),
)),
Credit::Unreadable(what) => gate.failures.push(InvariantFailure::new(
"dist-build-unreadable",
&workflow.path,
format!("job {id} runs dist build with {what}, which the judgment cannot resolve"),
"write the dist build step in the shape rk binding rust gives, or name its targets literally",
)),
}
}
}
for triple in targets.iter().filter(|triple| !built.contains(*triple)) {
gate.failures.push(InvariantFailure::new(
"dist-target-unbuilt",
"dist-workspace.toml",
format!(
"no job the gate needs builds {triple} with dist build before a merge, so a target the code cannot build fails only after the tag exists"
),
"add the dist-plan and dist-build jobs of rk binding rust to the gate's needs, or drop the target from dist-workspace.toml",
));
}
gate
}
fn dist_targets(target: &Utf8Path) -> Option<Vec<String>> {
let text = std::fs::read_to_string(target.join("dist-workspace.toml")).ok()?;
let table: toml::Table = text.parse().ok()?;
let targets = table
.get("dist")
.and_then(toml::Value::as_table)
.and_then(|dist| dist.get("targets"))
.and_then(toml::Value::as_array)?;
let targets: Vec<String> = targets
.iter()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect();
(!targets.is_empty()).then_some(targets)
}
fn gated_workflows(target: &Utf8Path) -> Vec<GatedWorkflow> {
let Ok(entries) = std::fs::read_dir(target.join(WORKFLOW_DIR)) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.filter(|name| {
std::path::Path::new(name)
.extension()
.is_some_and(|extension| extension == "yml" || extension == "yaml")
&& format!("{WORKFLOW_DIR}/{name}") != GENERATED_WORKFLOW
})
.collect();
names.sort();
names
.into_iter()
.filter_map(|name| {
let path = format!("{WORKFLOW_DIR}/{name}");
let text = std::fs::read_to_string(target.join(&path)).ok()?;
let value: yaml_serde::Value = yaml_serde::from_str(&text).ok()?;
let jobs = value
.get("jobs")
.and_then(yaml_serde::Value::as_mapping)
.map(|jobs| {
jobs.iter()
.filter_map(|(id, job)| Some((id.as_str()?.to_owned(), job.clone())))
.collect()
})
.unwrap_or_default();
let (on_requests, filtered) = request_trigger(&value);
Some(GatedWorkflow {
path,
on_requests,
filtered,
env: env_of(value.get("env")),
jobs,
})
})
.collect()
}
fn request_trigger(workflow: &yaml_serde::Value) -> (bool, bool) {
let trigger = workflow
.get("on")
.or_else(|| workflow.get(yaml_serde::Value::Bool(true)));
let request = |name: &str| matches!(name, "pull_request" | "merge_group");
match trigger {
Some(yaml_serde::Value::String(name)) => (request(name), false),
Some(yaml_serde::Value::Sequence(names)) => (
names
.iter()
.filter_map(yaml_serde::Value::as_str)
.any(request),
false,
),
Some(yaml_serde::Value::Mapping(events)) => {
let requests: Vec<&yaml_serde::Value> = events
.iter()
.filter(|(name, _)| name.as_str().is_some_and(request))
.map(|(_, config)| config)
.collect();
let filtered = requests.iter().any(|config| {
config.get("paths").is_some() || config.get("paths-ignore").is_some()
});
(!requests.is_empty(), filtered)
}
_ => (false, false),
}
}
fn judged_jobs(
workflows: &[GatedWorkflow],
required_check: &str,
gate: &mut DistGate,
) -> Option<Vec<(usize, String)>> {
if required_check.is_empty() {
gate.limitations.push((
GATE_UNNAMED,
"setup.required_check names no gate, so every job of every pull-request workflow is judged".to_owned(),
));
return Some(
workflows
.iter()
.enumerate()
.filter(|(_, workflow)| workflow.on_requests)
.flat_map(|(index, workflow)| {
workflow.jobs.iter().map(move |(id, _)| (index, id.clone()))
})
.collect(),
);
}
let remediation =
"name the gate's job in setup.required_check, and run its workflow on pull_request";
let matches: Vec<(usize, &str)> = workflows
.iter()
.enumerate()
.flat_map(|(index, workflow)| {
workflow.jobs.iter().filter_map(move |(id, job)| {
let context = job
.get("name")
.and_then(yaml_serde::Value::as_str)
.unwrap_or(id);
(context == required_check).then_some((index, id.as_str()))
})
})
.collect();
let [(index, gate_id)] = matches.as_slice() else {
gate.failures.push(InvariantFailure::new(
"gate-unresolved",
"setup.required_check",
format!(
"setup.required_check is \"{required_check}\", and {} jobs of the workflows carry that check context, where exactly one must",
matches.len()
),
remediation,
));
return None;
};
let workflow = &workflows[*index];
if !workflow.on_requests {
gate.failures.push(InvariantFailure::new(
"gate-not-on-requests",
&workflow.path,
format!(
"the gate job {gate_id} runs in a workflow that runs on neither pull_request nor merge_group, so its jobs prove nothing before a merge"
),
remediation,
));
return None;
}
let mut judged = vec![(*index, (*gate_id).to_owned())];
let mut next = 0;
while next < judged.len() {
let (_, id) = judged[next].clone();
next += 1;
for need in job_of(workflow, &id).map(needs_of).unwrap_or_default() {
if !judged.iter().any(|(_, seen)| *seen == need) {
judged.push((*index, need));
}
}
}
Some(judged)
}
fn job_of<'w>(workflow: &'w GatedWorkflow, id: &str) -> Option<&'w yaml_serde::Value> {
workflow
.jobs
.iter()
.find(|(job_id, _)| job_id == id)
.map(|(_, job)| job)
}
fn needs_of(job: &yaml_serde::Value) -> Vec<String> {
match job.get("needs") {
Some(yaml_serde::Value::String(one)) => vec![one.clone()],
Some(yaml_serde::Value::Sequence(many)) => many
.iter()
.filter_map(yaml_serde::Value::as_str)
.map(str::to_owned)
.collect(),
_ => Vec::new(),
}
}
fn steps_of(job: &yaml_serde::Value) -> &[yaml_serde::Value] {
job.get("steps")
.and_then(yaml_serde::Value::as_sequence)
.map_or(&[], Vec::as_slice)
}
fn env_of(env: Option<&yaml_serde::Value>) -> std::collections::BTreeMap<String, String> {
env.and_then(yaml_serde::Value::as_mapping)
.map(|env| {
env.iter()
.filter_map(|(name, value)| {
let value = match value {
yaml_serde::Value::String(text) => text.clone(),
yaml_serde::Value::Bool(flag) => flag.to_string(),
yaml_serde::Value::Number(number) => number.to_string(),
_ => return None,
};
Some((name.as_str()?.to_owned(), value))
})
.collect()
})
.unwrap_or_default()
}
fn squeeze(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("${{") {
out.push_str(&rest[..start]);
let Some(end) = rest[start..].find("}}") else {
out.push_str(&rest[start..]);
return out;
};
out.extend(
rest[start..start + end + 2]
.chars()
.filter(|c| !c.is_whitespace()),
);
rest = &rest[start + end + 2..];
}
out.push_str(rest);
out
}
fn expand(text: &str, env: &std::collections::BTreeMap<String, String>) -> String {
let mut out = text.to_owned();
for (name, value) in env {
out = out.replace(&format!("${{{name}}}"), value);
let marker = format!("${name}");
let mut expanded = String::with_capacity(out.len());
let mut rest = out.as_str();
while let Some(at) = rest.find(&marker) {
let after = &rest[at + marker.len()..];
expanded.push_str(&rest[..at]);
if after
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
{
expanded.push_str(&marker);
} else {
expanded.push_str(value);
}
rest = after;
}
expanded.push_str(rest);
out = expanded;
}
out
}
struct Invocation {
arguments: Vec<String>,
stdout: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct Quoting {
double: bool,
single: bool,
}
impl Quoting {
const fn any(self) -> bool {
self.double || self.single
}
}
fn segments_of(line: &str) -> Vec<Vec<(String, Quoting)>> {
let mut segments = vec![Vec::new()];
let mut word = String::new();
let mut quoting = Quoting::default();
let mut in_word = false;
let mut chars = line.chars().peekable();
let mut quote: Option<char> = None;
let finish = |segments: &mut Vec<Vec<(String, Quoting)>>,
word: &mut String,
quoting: &mut Quoting,
in_word: &mut bool| {
if *in_word && let Some(segment) = segments.last_mut() {
segment.push((std::mem::take(word), *quoting));
}
*quoting = Quoting::default();
*in_word = false;
};
while let Some(c) = chars.next() {
if let Some(open) = quote {
if c == open {
quote = None;
} else {
word.push(c);
}
continue;
}
match c {
'\u{22}' => {
quote = Some(c);
quoting.double = true;
in_word = true;
}
'\u{27}' => {
quote = Some(c);
quoting.single = true;
in_word = true;
}
'#' if !in_word => break,
';' | '|' | '&' => {
finish(&mut segments, &mut word, &mut quoting, &mut in_word);
while chars.peek().is_some_and(|next| matches!(next, '|' | '&')) {
chars.next();
}
segments.push(Vec::new());
}
c if c.is_whitespace() => {
finish(&mut segments, &mut word, &mut quoting, &mut in_word);
}
c => {
word.push(c);
in_word = true;
}
}
}
finish(&mut segments, &mut word, &mut quoting, &mut in_word);
segments
}
fn wrapper_supported(prefix: &[(String, Quoting)], recorded: &[&str]) -> bool {
if prefix.iter().any(|(_, quoting)| quoting.any()) {
return false;
}
let words: Vec<&str> = prefix.iter().map(|(word, _)| word.as_str()).collect();
if !recorded.is_empty() && words == recorded {
return true;
}
matches!(
words.as_slice(),
[] | ["mise", "exec", "--"] | ["devbox", "run", "--"] | ["nix", "develop", .., "--command"]
)
}
fn invocations(run: &str, command: &str, recorded: &[&str]) -> Result<Vec<Invocation>, String> {
let mut found = Vec::new();
for line in run.lines() {
for words in segments_of(&squeeze(line)) {
let Some(at) = words.windows(2).position(|pair| {
!pair[0].1.any() && !pair[1].1.any() && pair[0].0 == "dist" && pair[1].0 == command
}) else {
continue;
};
if !wrapper_supported(&words[..at], recorded) {
let prefix: Vec<&str> = words[..at].iter().map(|(word, _)| word.as_str()).collect();
return Err(format!(
"dist {command} after {}, which is no wrapper the binding names",
prefix.join(" ")
));
}
let mut arguments = Vec::new();
let mut stdout = None;
let mut rest = words[at + 2..].iter();
while let Some((word, _)) = rest.next() {
if word == ">" {
stdout = rest.next().map(|(file, _)| file.clone());
} else if let Some(file) = word.strip_prefix('>') {
stdout = Some(file.to_owned());
} else if word.starts_with('<') || word.starts_with("2>") {
rest.next();
} else {
arguments.push(word.clone());
}
}
found.push(Invocation { arguments, stdout });
}
}
Ok(found)
}
fn exports_plan(run: &str, name: &str, file: &str) -> bool {
let wanted = format!("{name}=$(jq -c . {file})");
run.lines().any(|line| {
segments_of(line)
.iter()
.any(|words| match words.as_slice() {
[
(echo, echo_quoting),
(export, export_quoting),
(append, append_quoting),
(sink, sink_quoting),
] => {
echo == "echo"
&& !echo_quoting.any()
&& export_quoting.double
&& !export_quoting.single
&& export.replace("'.'", ".") == wanted
&& append == ">>"
&& !append_quoting.any()
&& !sink_quoting.single
&& (sink == "$GITHUB_OUTPUT" || sink == "${GITHUB_OUTPUT}")
}
_ => false,
})
})
}
fn target_arguments(arguments: &[String]) -> Vec<String> {
flag_values(arguments, "--target", None)
}
fn flag_values(arguments: &[String], long: &str, short: Option<&str>) -> Vec<String> {
let mut found = Vec::new();
let mut iter = arguments.iter();
while let Some(argument) = iter.next() {
if let Some(value) = argument.strip_prefix(&format!("{long}=")) {
found.push(value.to_owned());
} else if argument == long || short.is_some_and(|short| argument == short) {
if let Some(value) = iter.next() {
found.push(value.clone());
}
} else if let Some(value) = short.and_then(|short| argument.strip_prefix(short)) {
found.push(value.to_owned());
}
}
found
}
fn triples_of(
value: &str,
job: &yaml_serde::Value,
) -> Result<std::collections::BTreeSet<String>, String> {
if let Some(key) = value
.strip_prefix("${{matrix.")
.and_then(|rest| rest.strip_suffix("}}"))
{
let matrix = job
.get("strategy")
.and_then(|strategy| strategy.get("matrix"));
if matrix.is_some_and(|matrix| {
matrix.get("include").is_some() || matrix.get("exclude").is_some()
}) {
return Err(format!(
"--target from matrix.{key} in a matrix that include or exclude reshapes"
));
}
let list = matrix
.and_then(|matrix| matrix.get(key))
.and_then(yaml_serde::Value::as_sequence)
.ok_or_else(|| format!("--target from matrix.{key}, which is no literal list"))?;
return list
.iter()
.map(|item| {
item.as_str()
.map(str::to_owned)
.ok_or_else(|| format!("--target from matrix.{key}, which holds a non-string"))
})
.collect();
}
if value.contains('$') {
return Err(format!("--target {value}"));
}
Ok(std::iter::once(value.to_owned()).collect())
}
fn builds_local_artifacts(arguments: &[String]) -> Result<bool, String> {
let modes = flag_values(arguments, "--artifacts", Some("-a"));
match modes.as_slice() {
[] => Err("no --artifacts, so dist builds in its host mode".to_owned()),
[mode] if mode == "local" || mode == "all" => Ok(true),
[mode] if mode == "global" || mode == "lies" => Ok(false),
_ => Err(format!("--artifacts {}", modes.join(" "))),
}
}
fn credit(
workflow: &GatedWorkflow,
job: &yaml_serde::Value,
step: &yaml_serde::Value,
run: &str,
peers: &[&str],
targets: &[String],
recorded: &[&str],
) -> Credit {
if job.get("if").is_some() || step.get("if").is_some() {
return Credit::Conditional;
}
if workflow.filtered {
return Credit::Filtered;
}
let mut env = workflow.env.clone();
env.extend(env_of(job.get("env")));
env.extend(env_of(step.get("env")));
let builds = match invocations(&expand(run, &env), "build", recorded) {
Ok(builds) => builds,
Err(what) => return Credit::Unreadable(what),
};
let mut triples = std::collections::BTreeSet::new();
for build in builds {
let arguments = &build.arguments;
let named = target_arguments(arguments);
if !named.is_empty() {
match builds_local_artifacts(arguments) {
Ok(true) => {}
Ok(false) => continue,
Err(what) => return Credit::Unreadable(what),
}
for value in named {
match triples_of(&value, job) {
Ok(found) => triples.extend(found),
Err(what) => return Credit::Unreadable(what),
}
}
continue;
}
if arguments
.iter()
.any(|argument| argument == "${{matrix.dist_args}}")
{
if !flag_values(arguments, "--artifacts", Some("-a")).is_empty() {
return Credit::Unreadable(
"--artifacts beside matrix.dist_args, which already carries it".to_owned(),
);
}
match planned_triples(workflow, job, peers, recorded) {
Ok(PlanCredit::Every) => triples.extend(targets.iter().cloned()),
Ok(PlanCredit::Named(found)) => triples.extend(found),
Err(what) => return Credit::Unreadable(what),
}
}
}
Credit::Targets(triples)
}
fn planned_triples(
workflow: &GatedWorkflow,
job: &yaml_serde::Value,
peers: &[&str],
recorded: &[&str],
) -> Result<PlanCredit, String> {
let matrix = job
.get("strategy")
.and_then(|strategy| strategy.get("matrix"))
.and_then(yaml_serde::Value::as_str)
.map(squeeze)
.ok_or_else(|| "matrix.dist_args from a matrix that is no expression".to_owned())?;
let inner = matrix
.strip_prefix("${{fromJson(needs.")
.or_else(|| matrix.strip_prefix("${{fromJSON(needs."))
.and_then(|rest| rest.strip_suffix(").ci.github.artifacts_matrix}}"))
.ok_or_else(|| format!("matrix.dist_args from the matrix {matrix}"))?;
let (planner, output) = inner
.split_once(".outputs.")
.ok_or_else(|| format!("matrix.dist_args from the matrix {matrix}"))?;
if !peers.contains(&planner) {
return Err(format!(
"matrix.dist_args from job {planner}, which the gate does not need"
));
}
let planner_job =
job_of(workflow, planner).ok_or_else(|| format!("matrix.dist_args from job {planner}"))?;
let source = planner_job
.get("outputs")
.and_then(|outputs| outputs.get(output))
.and_then(yaml_serde::Value::as_str)
.map(squeeze)
.ok_or_else(|| format!("matrix.dist_args from {planner}.outputs.{output}"))?;
let (step_id, written) = source
.strip_prefix("${{steps.")
.and_then(|rest| rest.strip_suffix("}}"))
.and_then(|rest| rest.split_once(".outputs."))
.ok_or_else(|| format!("matrix.dist_args from {planner}.outputs.{output} = {source}"))?;
let plan = steps_of(planner_job)
.iter()
.find(|step| step.get("id").and_then(yaml_serde::Value::as_str) == Some(step_id))
.and_then(|step| step.get("run"))
.and_then(yaml_serde::Value::as_str)
.ok_or_else(|| format!("matrix.dist_args from step {step_id} of {planner}"))?;
let plans = invocations(plan, "plan", recorded)?;
let [invocation] = plans.as_slice() else {
return Err(format!(
"matrix.dist_args from step {step_id} of {planner}, which runs dist plan {} times",
plans.len()
));
};
let file = invocation.stdout.as_deref().ok_or_else(|| {
format!("matrix.dist_args from step {step_id} of {planner}, whose dist plan writes no file")
})?;
if !exports_plan(plan, written, file) {
return Err(format!(
"matrix.dist_args from {planner}.outputs.{output}, which carries no jq -c . {file} as {written}"
));
}
let mut json = false;
let mut narrowed = std::collections::BTreeSet::new();
let mut iter = invocation.arguments.iter();
while let Some(argument) = iter.next() {
match argument.as_str() {
"--output-format=json" => json = true,
"--output-format" if iter.next().is_some_and(|value| value == "json") => json = true,
"--target" => match iter.next() {
Some(value) if !value.contains('$') => {
narrowed.insert(value.clone());
}
_ => return Err("dist plan --target with no literal value".to_owned()),
},
other => match other.strip_prefix("--target=") {
Some(value) if !value.contains('$') => {
narrowed.insert(value.to_owned());
}
_ => return Err(format!("dist plan {other}")),
},
}
}
if !json {
return Err("dist plan with no --output-format=json".to_owned());
}
Ok(if narrowed.is_empty() {
PlanCredit::Every
} else {
PlanCredit::Named(narrowed)
})
}
fn dist_profile(target: &Utf8Path) -> Vec<InvariantFailure> {
let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
return Vec::new();
};
let Ok(config) = config.parse::<toml::Table>() else {
return Vec::new();
};
let Some(ci) = config
.get("dist")
.and_then(toml::Value::as_table)
.and_then(|dist| dist.get("ci"))
else {
return Vec::new();
};
let github = ci.as_str() == Some("github")
|| ci
.as_array()
.is_some_and(|values| values.iter().any(|value| value.as_str() == Some("github")));
if !github {
return Vec::new();
}
let Ok(manifest) = std::fs::read_to_string(target.join("Cargo.toml")) else {
return Vec::new();
};
let Ok(manifest) = manifest.parse::<toml::Table>() else {
return Vec::new();
};
if manifest
.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profile| profile.get("dist"))
.is_some_and(toml::Value::is_table)
{
return Vec::new();
}
vec![InvariantFailure::new(
"dist-profile-missing",
"Cargo.toml",
"dist-workspace.toml enables GitHub CI, whose generated workflow builds with `--profile dist`, but the root Cargo.toml defines no [profile.dist] table",
concat!(
"add this exact block to Cargo.toml:\n",
"\n",
"[profile.dist]\n",
"inherits = \"release\""
),
)]
}
fn generated_release_workflow(target: &Utf8Path) -> Vec<InvariantFailure> {
let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
return Vec::new();
};
let workflow = match std::fs::read_to_string(target.join(GENERATED_WORKFLOW)) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(error) => {
return vec![InvariantFailure::new(
"workflow-file-unreadable",
GENERATED_WORKFLOW,
format!("the workflow is present and cannot be read as text: {error}"),
"repair the file so it reads as UTF-8 text, or regenerate it with dist generate --mode ci",
)];
}
};
workflow_matches_configuration(&config, &workflow)
}
fn workflow_matches_configuration(config: &str, workflow: &str) -> Vec<InvariantFailure> {
let Ok(table) = config.parse::<toml::Table>() else {
return Vec::new();
};
let dist = table.get("dist").and_then(toml::Value::as_table);
let pinned = dist
.and_then(|dist| dist.get("github-action-commits"))
.and_then(toml::Value::as_table);
let attested = dist
.and_then(|dist| dist.get("github-attestations"))
.and_then(toml::Value::as_bool)
== Some(true);
let mut failures = Vec::new();
let steps = workflow_uses(workflow);
for step in &steps {
let (action, reference) = match step {
Step::Opaque(value) => {
failures.push(InvariantFailure::new(
"workflow-step-unreadable",
GENERATED_WORKFLOW,
format!(
"the workflow runs `uses: {value}`, which this check cannot resolve into an action and an immutable reference"
),
"write the step as <action>@<full commit SHA>, resolving any alias, so what the workflow runs can be read; regenerating with dist generate --mode ci writes that form",
));
continue;
}
Step::Action(action, reference) => (action, reference),
};
let pin = pinned
.and_then(|table| table.get(action.as_str()))
.and_then(toml::Value::as_str);
if let Some(commit) = pin
&& commit != reference
{
failures.push(InvariantFailure::new(
"workflow-action-stale",
GENERATED_WORKFLOW,
format!(
"the workflow runs {action}@{reference}, where dist-workspace.toml pins {commit}"
),
"regenerate the workflow from the configuration with dist generate --mode ci and commit it; a hand edit is reverted at the next generate",
));
continue;
}
if !is_immutable(reference) {
failures.push(InvariantFailure::new(
"workflow-action-unpinned",
GENERATED_WORKFLOW,
format!(
"the workflow runs {action}@{reference}, which is no immutable reference, so the step runs whatever that name points at today"
),
"pin the action at a full commit SHA in [dist.github-action-commits] in dist-workspace.toml, then regenerate with dist generate --mode ci",
));
}
}
if attested
&& !steps.iter().any(|step| match step {
Step::Action(action, _) => {
action == "actions/attest" || action.starts_with("actions/attest-")
}
Step::Opaque(_) => false,
})
{
failures.push(InvariantFailure::new(
"workflow-attestation-missing",
GENERATED_WORKFLOW,
"dist-workspace.toml sets github-attestations = true, and the workflow carries no attest step, so what this workflow builds ships unattested",
"regenerate the workflow with dist generate --mode ci and commit it, so the configured attest step is what runs",
));
}
if crate::setup::workflow_jobs::request_trigger(workflow, crate::config::TRUNK_DEFAULT)
.is_some()
{
failures.push(InvariantFailure::new(
"workflow-runs-on-a-request",
GENERATED_WORKFLOW,
"the workflow triggers on a pull request, and no gate in another file can need a job declared here, so the one required check does not hold what this workflow reports",
"set pr-run-mode = \"skip\" in [dist] in dist-workspace.toml and regenerate with dist generate, so the artifact workflow is tag-only; the dist plan and dist generate proofs belong to the workflow the required check gates",
));
}
failures
}
enum Step {
Action(String, String),
Opaque(String),
}
fn workflow_uses(workflow: &str) -> Vec<Step> {
let mut seen: Vec<String> = Vec::new();
let mut steps = Vec::new();
for fragment in workflow.lines().flat_map(line_fragments) {
let fragment = fragment.trim_start();
let fragment = fragment
.strip_prefix("- ")
.map_or(fragment, str::trim_start);
let Some(rest) = uses_value(fragment) else {
continue;
};
let rest = before_comment(rest).trim();
let rest = rest
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.or_else(|| {
rest.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
})
.unwrap_or(rest);
if rest.starts_with("./") || rest.starts_with("$/") {
continue;
}
if seen.iter().any(|value| value == rest) {
continue;
}
seen.push(rest.to_owned());
steps.push(match rest.split_once('@') {
Some((action, reference)) => Step::Action(action.to_owned(), reference.to_owned()),
None if rest.is_empty() => Step::Opaque("a value carried on another line".to_owned()),
None => Step::Opaque(rest.to_owned()),
});
}
steps
}
fn line_fragments(line: &str) -> Vec<&str> {
let item = line.trim_start();
let item = item.strip_prefix("- ").map_or(item, str::trim_start);
let flow = item.starts_with('{')
|| item.starts_with('[')
|| ((line.contains('{') || line.contains('[')) && line.contains("uses"));
if !flow {
return vec![line];
}
if line.contains(QUOTES) {
return vec![UNSPLITTABLE_FLOW_LINE];
}
line.split(['{', '}', '[', ']', ',']).collect()
}
pub(crate) fn before_comment(value: &str) -> &str {
let mut previous = ' ';
for (index, character) in value.char_indices() {
if character == '#' && (previous == ' ' || previous == '\t') {
return &value[..index];
}
previous = character;
}
value
}
const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
const UNSPLITTABLE_FLOW_LINE: &str = "uses: a flow-style step carrying a quoted value";
fn uses_value(line: &str) -> Option<&str> {
let rest = line
.strip_prefix("\"uses\"")
.or_else(|| line.strip_prefix("'uses'"))
.or_else(|| line.strip_prefix("uses"))?;
rest.trim_start().strip_prefix(':')
}
fn is_immutable(reference: &str) -> bool {
let digest = reference
.strip_prefix("sha256:")
.filter(|digest| digest.len() == 64);
let commit = Some(reference).filter(|reference| reference.len() == 40);
digest
.or(commit)
.is_some_and(|value| value.chars().all(|char| char.is_ascii_hexdigit()))
}
#[cfg(test)]
mod tests {
use camino::Utf8Path;
use super::{failures, target_failures, workflow_matches_configuration};
const CLEAN: &str = r#"
[dist]
pr-run-mode = "skip"
github-attestations = true
github-attestations-phase = "host"
github-release = "host"
[dist.github-action-commits]
"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
"#;
#[test]
fn the_seeded_configuration_is_judged_effectively() {
assert!(
failures(
"rust",
"github",
"dist-workspace.toml",
CLEAN.as_bytes(),
None
)
.is_empty()
);
let seed = crate::embedded::SNIPPETS
.get_file("rust/github/dist-workspace.toml")
.and_then(|file| file.contents_utf8())
.expect("the seed is embedded");
assert!(
failures(
"rust",
"github",
"dist-workspace.toml",
seed.as_bytes(),
None
)
.is_empty(),
"the embedded seed satisfies the invariants it seeds"
);
}
#[test]
fn a_missing_or_stale_action_commit_table_fails() {
let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
let found = failures(
"rust",
"github",
"dist-workspace.toml",
missing.as_bytes(),
None,
);
assert!(
found
.iter()
.any(|failure| failure.code == "action-commit-missing"),
"a missing entry falls back to the movable tag: {found:?}"
);
let stale = CLEAN.replace(
"d23441a48e516b6c34aea4fa41551a30e30af803",
"0000000000000000000000000000000000000000",
);
let found = failures(
"rust",
"github",
"dist-workspace.toml",
stale.as_bytes(),
None,
);
assert!(
found
.iter()
.any(|failure| failure.code == "action-commit-stale"
&& failure.reason.contains("actions/checkout")
&& failure
.reason
.contains("0000000000000000000000000000000000000000")),
"a mismatch names the found and expected commits: {found:?}"
);
let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
let found_invalid = failures(
"rust",
"github",
"dist-workspace.toml",
invalid.as_bytes(),
None,
);
assert!(
found_invalid
.iter()
.any(|failure| failure.code == "action-commit-invalid"
&& failure.reason.contains("actions/checkout")),
"a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
);
assert!(
!found
.iter()
.any(|failure| failure.reason.contains("actions/attest")),
"only the stale action is named: {found:?}"
);
}
#[test]
fn each_degraded_form_fails_with_its_code() {
let cases: &[(&str, &str)] = &[
(
"[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
"attestations-disabled",
),
(
"[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
"attestations-disabled",
),
(
"[dist]\ngithub-attestations = true\ngithub-release='host'\n",
"attestation-phase-not-host",
),
(
"[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
"attestation-phase-not-host",
),
(
"[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
"release-phase-unpaired",
),
(
"[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
"attestation-filters-narrowed",
),
("not toml at [all", "unparsable-configuration"),
];
for (text, code) in cases {
let found = failures(
"rust",
"github",
"dist-workspace.toml",
text.as_bytes(),
None,
);
assert!(
found.iter().any(|failure| failure.code == *code),
"{text:?} must fail with {code}, got {found:?}"
);
}
}
#[test]
fn a_configuration_that_reports_on_a_request_fails() {
let absent = CLEAN.replace("pr-run-mode = \"skip\"\n", "");
let found = failures(
"rust",
"github",
"dist-workspace.toml",
absent.as_bytes(),
None,
);
assert!(
found
.iter()
.any(|failure| failure.code == "pr-run-mode-not-skip"
&& failure.reason.contains("unset")
&& failure.reason.contains("plan")),
"an unset key defaults to plan and says so: {found:?}"
);
for other in ["plan", "upload"] {
let text = CLEAN.replace("\"skip\"", &format!("\"{other}\""));
let found = failures(
"rust",
"github",
"dist-workspace.toml",
text.as_bytes(),
None,
);
assert!(
found
.iter()
.any(|failure| failure.code == "pr-run-mode-not-skip"
&& failure.reason.contains(other)),
"{other} fails and is named: {found:?}"
);
}
let non_string = CLEAN.replace("\"skip\"", "3");
assert!(
failures(
"rust",
"github",
"dist-workspace.toml",
non_string.as_bytes(),
None,
)
.iter()
.any(|failure| failure.code == "pr-run-mode-not-skip"),
"a non-string run mode is not skip"
);
assert!(
!failures(
"rust",
"github",
"dist-workspace.toml",
CLEAN.as_bytes(),
None
)
.iter()
.any(|failure| failure.code == "pr-run-mode-not-skip"),
"skip fails nothing"
);
}
#[test]
fn a_generated_workflow_that_triggers_on_a_request_fails() {
let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
for trigger in [
"on:\n pull_request:\n",
"on: [push, pull_request]\n",
"on: pull_request_target\n",
] {
let workflow = format!("{trigger}jobs:\n plan:\n steps:\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &workflow)
.iter()
.any(|failure| failure.code == "workflow-runs-on-a-request"
&& failure.destination == super::GENERATED_WORKFLOW),
"{trigger:?} reports a check no gate can need"
);
}
let tag_only =
format!("on:\n push:\n tags:\n - '**'\njobs:\n plan:\n steps:\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &tag_only).is_empty(),
"a tag-only workflow reports nothing on a request"
);
}
#[test]
fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
let workflow = "\
jobs:
plan:
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
# - uses: actions/checkout@v4
- name: Upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
- name: Attest
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
";
let found = workflow_matches_configuration(CLEAN, workflow);
assert!(
found.is_empty(),
"the generated workflow is clean: {found:?}"
);
}
#[test]
fn a_workflow_left_at_a_movable_tag_fails() {
for stale in ["v4", "0000000000000000000000000000000000000000"] {
let workflow = format!(
"steps:\n - uses: actions/checkout@{stale}\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
);
let found = workflow_matches_configuration(CLEAN, &workflow);
assert!(
found
.iter()
.any(|failure| failure.code == "workflow-action-stale"
&& failure.destination == ".github/workflows/release.yml"
&& failure.reason.contains("actions/checkout")
&& failure.reason.contains(stale)
&& failure
.reason
.contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
"{stale} names both sides of the disagreement: {found:?}"
);
}
let twice = "steps:\n - uses: actions/checkout@v4\n - uses: actions/checkout@v4\n - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
assert_eq!(
workflow_matches_configuration(CLEAN, twice).len(),
1,
"one reference is one failure, however many jobs run it"
);
}
#[test]
fn a_configured_attestation_with_no_attest_step_fails() {
let bare = "steps:\n - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
let found = workflow_matches_configuration(CLEAN, bare);
assert!(
found
.iter()
.any(|failure| failure.code == "workflow-attestation-missing"),
"an unattested workflow fails: {found:?}"
);
assert!(
!found
.iter()
.any(|failure| failure.code.starts_with("workflow-action-")),
"the pinned step itself is clean: {found:?}"
);
let variant = format!(
"{bare} - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
);
assert!(
workflow_matches_configuration(CLEAN, &variant).is_empty(),
"the build-provenance variant is an attest step"
);
}
#[test]
fn a_movable_reference_fails_whatever_the_configuration_says() {
let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
let movable = format!("steps:\n - uses: third/party@v1\n{attest}");
let found = workflow_matches_configuration(CLEAN, &movable);
assert!(
found
.iter()
.any(|failure| failure.code == "workflow-action-unpinned"
&& failure.reason.contains("third/party")),
"an action the configuration never names fails: {found:?}"
);
let agreed = CLEAN.replace(
"[dist.github-action-commits]",
"[dist.github-action-commits]\n\"third/party\" = \"v1\"",
);
let found = workflow_matches_configuration(&agreed, &movable);
assert!(
found
.iter()
.any(|failure| failure.code == "workflow-action-unpinned"
&& failure.reason.contains("third/party")),
"a table entry naming the same movable tag pins nothing: {found:?}"
);
let non_string = CLEAN.replace(
"[dist.github-action-commits]",
"[dist.github-action-commits]\n\"third/party\" = 1",
);
assert!(
workflow_matches_configuration(&non_string, &movable)
.iter()
.any(|failure| failure.code == "workflow-action-unpinned"),
"a non-string entry pins nothing either"
);
let pinned = format!(
"steps:\n - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
);
assert!(
workflow_matches_configuration(CLEAN, &pinned).is_empty(),
"a commit-pinned action the configuration does not name is the target's own"
);
assert!(
workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
"a pin no step runs is the target's tuning, not drift"
);
}
#[test]
fn every_real_step_shape_reaches_the_judgment() {
let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
let padded = format!("steps:\n - uses : actions/checkout@v4\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &padded)
.iter()
.any(|failure| failure.code == "workflow-action-stale"),
"a padded key is the same mapping"
);
let quoted = format!("steps:\n - \"uses\": actions/checkout@v4\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, "ed)
.iter()
.any(|failure| failure.code == "workflow-action-stale"),
"a quoted key is the same mapping"
);
let flow =
format!("steps:\n - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &flow)
.iter()
.any(|failure| failure.code == "workflow-action-stale"),
"a flow-style step is the same mapping"
);
let comma = format!(
"steps:\n - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
);
assert!(
workflow_matches_configuration(CLEAN, &comma)
.iter()
.any(|failure| failure.code == "workflow-action-unpinned"
&& failure.reason.contains(",dev")),
"the whole reference is judged, never its prefix"
);
let hashed = format!(
"steps:\n - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
);
assert!(
workflow_matches_configuration(CLEAN, &hashed)
.iter()
.any(|failure| failure.code == "workflow-action-unpinned"
&& failure.reason.contains("#dev")),
"an adjacent hash is scalar content, not a comment"
);
let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &compact)
.iter()
.any(|failure| failure.code == "workflow-action-unpinned"
&& failure.reason.contains("third/party")),
"a compact flow sequence carries its uses key"
);
assert!(
workflow_matches_configuration(CLEAN, &format!("steps:\n - usesful: no\n{attest}"))
.is_empty(),
"a key that merely starts with uses is another key"
);
for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
let local = format!("steps:\n - uses: {same_repository}\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &local).is_empty(),
"{same_repository} is the repository's own file at the running commit"
);
}
let tagged = format!("steps:\n - uses: docker://alpine:3.8\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &tagged)
.iter()
.any(|failure| failure.code == "workflow-step-unreadable"),
"a docker image with no digest is not immutable"
);
let digested = format!(
"steps:\n - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
);
assert!(
workflow_matches_configuration(CLEAN, &digested).is_empty(),
"a docker image pinned by digest is immutable"
);
}
#[test]
fn a_step_the_reader_cannot_resolve_is_reported() {
let attest = " - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
let aliased = format!("steps:\n - uses: *checkout\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &aliased)
.iter()
.any(|failure| failure.code == "workflow-step-unreadable"
&& failure.reason.contains("*checkout")),
"an alias is unreadable, never clean"
);
let continued = format!("steps:\n - uses:\n actions/checkout@v4\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, &continued)
.iter()
.any(|failure| failure.code == "workflow-step-unreadable"),
"a value on another line is unreadable, never clean"
);
let quoted_flow = format!("steps:\n - {{ uses: \"third/party@1,dev\" }}\n{attest}");
assert!(
workflow_matches_configuration(CLEAN, "ed_flow)
.iter()
.any(|failure| failure.code == "workflow-step-unreadable"),
"a quoted flow line is not split on a guess"
);
let expression = format!(
"jobs:\n host:\n if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n steps:\n{attest}"
);
assert!(
workflow_matches_configuration(CLEAN, &expression).is_empty(),
"an expression is not a step this reader cannot resolve"
);
}
#[test]
fn the_cross_file_judgment_needs_both_files() {
let dir = tempfile::tempdir().expect("a scratch directory");
let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
let broken = "steps:\n - uses: actions/checkout@v4\n";
assert!(
target_failures("rust", "github", target).is_empty(),
"an empty target"
);
std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
assert!(
target_failures("rust", "github", target).is_empty(),
"a configuration with no generated workflow"
);
std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
assert!(
!target_failures("rust", "github", target).is_empty(),
"both files present, and they disagree"
);
for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
assert!(
target_failures(tech, forge, target).is_empty(),
"{tech}/{forge} generates no artifact workflow"
);
}
std::fs::write(
target.join(".github/workflows/release.yml"),
[0x66, 0xff, 0xfe],
)
.expect("the workflow");
assert!(
target_failures("rust", "github", target)
.iter()
.any(|failure| failure.code == "workflow-file-unreadable"),
"a present workflow that does not read as text is reported"
);
std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
assert!(
target_failures("rust", "github", target).is_empty(),
"a workflow with no configuration to judge it against"
);
}
#[test]
fn the_rule_is_keyed_by_pair_and_destination() {
let broken = b"[dist]\ngithub-attestations = false\n";
assert!(failures("rust", "gitlab", "dist-workspace.toml", broken, None).is_empty());
assert!(failures("bash", "github", "dist-workspace.toml", broken, None).is_empty());
assert!(failures("rust", "github", "release-plz.toml", broken, None).is_empty());
}
}