use camino::Utf8Path;
use serde::Serialize;
use crate::embedded;
#[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]) -> Vec<InvariantFailure> {
match (tech, forge, destination) {
("rust", "github", "dist-workspace.toml") => dist_workspace(destination, bytes),
_ => Vec::new(),
}
}
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 release payload",
"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 payload 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 payload 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") => generated_release_workflow(target),
_ => Vec::new(),
}
}
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).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 {
#![allow(clippy::expect_used)]
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()).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()).is_empty(),
"the payload's own 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());
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());
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());
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());
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());
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());
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()
)
.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())
.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).is_empty());
assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
}
}