Skip to main content

release_kit/landing/
invariants.rs

1//! The invariants a seeded file still carries.
2//!
3//! A `seeded` file is the target's to tune — nothing here rewrites one —
4//! but the narrow part the invariants own is judged: a target may choose
5//! its platforms, its installers, and its install path; it may not choose
6//! to ship unattested. The judgment reads the effective configuration,
7//! never the text: a commented key, a `false` value, or an unpaired phase
8//! must fail, and whitespace or key order must not matter. The table is
9//! keyed by `(technology, forge, destination)` — the kind table is
10//! destination-keyed, and a second pair sharing a destination would
11//! otherwise silently inherit the wrong rule.
12//!
13//! A second, pair-keyed table judges target-wide relationships that need
14//! files read from the target's disk: generated files the payload ships no
15//! copy of, and target-owned files landed configuration requires. No digest
16//! records a generated file, so nothing else sees it drift away from the
17//! configuration it was generated from. That judgment reads the generated
18//! text, because the generator is not available to re-run and the text is
19//! what the forge executes. It reads the grammar the generator writes and
20//! reports what it cannot resolve; a workflow hand-authored in some further
21//! YAML presentation is beyond a text reader, and the generator's own check
22//! stays the whole-file proof.
23
24use camino::Utf8Path;
25use serde::Serialize;
26
27use crate::embedded;
28
29/// One invariant a landed file's effective configuration violates: a
30/// stable code, the destination, why, and exactly what to write — the
31/// operator is told the remediation, never just what was not found.
32#[derive(Debug, Clone, Serialize)]
33pub struct InvariantFailure {
34    /// The stable machine code of the failed rule.
35    pub code: &'static str,
36    /// The landed destination the failure is about.
37    pub destination: String,
38    /// Why the configuration violates the invariant.
39    pub reason: String,
40    /// Exactly what to write to satisfy the rule.
41    pub remediation: &'static str,
42}
43
44impl InvariantFailure {
45    fn new(
46        code: &'static str,
47        destination: &str,
48        reason: impl Into<String>,
49        remediation: &'static str,
50    ) -> Self {
51        Self {
52            code,
53            destination: destination.to_owned(),
54            reason: reason.into(),
55            remediation,
56        }
57    }
58}
59
60/// Judge one landed file against the rules its `(tech, forge,
61/// destination)` key owns. A destination no rule owns fails nothing.
62#[must_use]
63pub fn failures(tech: &str, forge: &str, destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
64    match (tech, forge, destination) {
65        ("rust", "github", "dist-workspace.toml") => dist_workspace(destination, bytes),
66        _ => Vec::new(),
67    }
68}
69
70/// The rust/github attestation configuration: attestations on, minted in
71/// the `host` phase where every hosted asset is gathered before the
72/// release page exists, the release creation paired with that phase, and
73/// no narrowing filter — the default `["*"]` covers every hosted file,
74/// where an enumerated list goes quiet when an archive format moves.
75fn dist_workspace(destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
76    let Ok(text) = std::str::from_utf8(bytes) else {
77        return vec![InvariantFailure::new(
78            "unparsable-configuration",
79            destination,
80            "the file is not UTF-8, so its configuration cannot be judged",
81            "repair the file so it parses as TOML",
82        )];
83    };
84    let table: toml::Table = match text.parse() {
85        Ok(table) => table,
86        Err(error) => {
87            return vec![InvariantFailure::new(
88                "unparsable-configuration",
89                destination,
90                format!("the file does not parse as TOML: {error}"),
91                "repair the file so it parses as TOML",
92            )];
93        }
94    };
95    let dist = table.get("dist").and_then(toml::Value::as_table);
96    let mut failures = Vec::new();
97    let value = |key: &str| dist.and_then(|dist| dist.get(key));
98    if value("github-attestations").and_then(toml::Value::as_bool) != Some(true) {
99        failures.push(InvariantFailure::new(
100            "attestations-disabled",
101            destination,
102            "github-attestations is not effectively true, so no release artifact is attested",
103            "set github-attestations = true in [dist]",
104        ));
105    }
106    let phase = value("github-attestations-phase").and_then(toml::Value::as_str);
107    if phase != Some("host") {
108        failures.push(InvariantFailure::new(
109            "attestation-phase-not-host",
110            destination,
111            phase.map_or_else(
112                || "github-attestations-phase is unset, so the default phase attests only the per-platform archives and the curled installers ship unattested".to_owned(),
113                |other| format!(
114                    "github-attestations-phase is \"{other}\"; only the host phase attests every asset before the release page exists"
115                ),
116            ),
117            "set github-attestations-phase = \"host\" in [dist]",
118        ));
119    }
120    if value("github-release").and_then(toml::Value::as_str) != Some("host") {
121        failures.push(InvariantFailure::new(
122            "release-phase-unpaired",
123            destination,
124            "github-release is not \"host\", leaving the release creation unpaired with the attest phase",
125            "set github-release = \"host\" in [dist], pairing the release creation with the phase that attests",
126        ));
127    }
128    if value("github-attestations-filters").is_some() {
129        failures.push(InvariantFailure::new(
130            "attestation-filters-narrowed",
131            destination,
132            "github-attestations-filters narrows what is attested below the whole release payload",
133            "remove github-attestations-filters from [dist]; the default [\"*\"] attests every hosted file",
134        ));
135    }
136    // cargo-dist's default puts a plan job on every pull request. The
137    // trunk protection requires one project-owned context beside the
138    // landed title check, and `needs` resolves inside one workflow file,
139    // so a job in the generated file is in no gate's needs: it reports a
140    // status nothing holds, and under the standing arm a red one merges.
141    let mode = value("pr-run-mode").and_then(toml::Value::as_str);
142    if mode != Some("skip") {
143        failures.push(InvariantFailure::new(
144            "pr-run-mode-not-skip",
145            destination,
146            mode.map_or_else(
147                || "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(),
148                |other| format!(
149                    "pr-run-mode is \"{other}\", so the generated workflow reports a job on every pull request that no gate can need"
150                ),
151            ),
152            "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",
153        ));
154    }
155    failures.extend(action_commit_failures(
156        destination,
157        value("github-action-commits").and_then(toml::Value::as_table),
158    ));
159    failures
160}
161
162/// The build that signs is itself pinned by digest: the seed's
163/// `[dist.github-action-commits]` table pins the actions cargo-dist
164/// injects — the attest step among them — and a landed target must carry
165/// the same effective table, or its signer runs code a moved tag can
166/// swap.
167fn action_commit_failures(destination: &str, found: Option<&toml::Table>) -> Vec<InvariantFailure> {
168    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";
169    let mut failures = Vec::new();
170    for (action, commit) in &seed_action_commits() {
171        // Three distinct states, each with its own true reason: an
172        // absent entry falls back to the movable tag, a non-string value
173        // is invalid configuration, and a mismatched string executes an
174        // immutable commit that is just not the payload's.
175        match found.and_then(|table| table.get(action)) {
176            Some(value) => match value.as_str() {
177                Some(pinned) if pinned == commit.as_str() => {}
178                Some(pinned) => failures.push(InvariantFailure::new(
179                    "action-commit-stale",
180                    destination,
181                    format!(
182                        "[dist.github-action-commits] pins {action} at {pinned}, where the payload pins {commit}"
183                    ),
184                    remediation,
185                )),
186                None => failures.push(InvariantFailure::new(
187                    "action-commit-invalid",
188                    destination,
189                    format!(
190                        "[dist.github-action-commits] pins {action} with a non-string value; a pin is a full commit SHA string"
191                    ),
192                    remediation,
193                )),
194            },
195            None => failures.push(InvariantFailure::new(
196                "action-commit-missing",
197                destination,
198                format!(
199                    "[dist.github-action-commits] does not pin {action}, so the workflow runs whatever the movable tag names"
200                ),
201                remediation,
202            )),
203        }
204    }
205    failures
206}
207
208/// The action commits the payload's own seed pins, read from the
209/// embedded snippet so the judgment and the seed cannot drift apart.
210fn seed_action_commits() -> Vec<(String, String)> {
211    let Some(text) = embedded::SNIPPETS
212        .get_file("rust/github/dist-workspace.toml")
213        .and_then(|file| file.contents_utf8())
214    else {
215        return Vec::new();
216    };
217    let Ok(table) = text.parse::<toml::Table>() else {
218        return Vec::new();
219    };
220    table
221        .get("dist")
222        .and_then(toml::Value::as_table)
223        .and_then(|dist| dist.get("github-action-commits"))
224        .and_then(toml::Value::as_table)
225        .map(|commits| {
226            commits
227                .iter()
228                .filter_map(|(action, commit)| {
229                    commit
230                        .as_str()
231                        .map(|commit| (action.clone(), commit.to_owned()))
232                })
233                .collect()
234        })
235        .unwrap_or_default()
236}
237
238/// The generated file the cross-file failures name: cargo-dist writes it
239/// from `dist-workspace.toml`, the payload ships no copy, and the forge
240/// executes it.
241const GENERATED_WORKFLOW: &str = ".github/workflows/release.yml";
242
243/// Judge target-wide relationships, keyed by `(technology, forge)`.
244///
245/// A destination-keyed rule cannot reach a second file: the byte reader
246/// receives only one landed destination. The pair-keyed rules read files
247/// from the target's own disk, including generated files the payload ships
248/// no copy of and target-owned files landed configuration requires.
249#[must_use]
250pub fn target_failures(tech: &str, forge: &str, target: &Utf8Path) -> Vec<InvariantFailure> {
251    match (tech, forge) {
252        ("rust", "github") => {
253            let mut failures = generated_release_workflow(target);
254            failures.extend(dist_profile(target));
255            failures
256        }
257        _ => Vec::new(),
258    }
259}
260
261/// The root manifest configuration GitHub CI builds with. Incomplete
262/// inputs are silent: the landing record reports a missing landed
263/// configuration, while target-owned manifests are outside that record.
264fn dist_profile(target: &Utf8Path) -> Vec<InvariantFailure> {
265    let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
266        return Vec::new();
267    };
268    let Ok(config) = config.parse::<toml::Table>() else {
269        return Vec::new();
270    };
271    let Some(ci) = config
272        .get("dist")
273        .and_then(toml::Value::as_table)
274        .and_then(|dist| dist.get("ci"))
275    else {
276        return Vec::new();
277    };
278    let github = ci.as_str() == Some("github")
279        || ci
280            .as_array()
281            .is_some_and(|values| values.iter().any(|value| value.as_str() == Some("github")));
282    if !github {
283        return Vec::new();
284    }
285
286    let Ok(manifest) = std::fs::read_to_string(target.join("Cargo.toml")) else {
287        return Vec::new();
288    };
289    let Ok(manifest) = manifest.parse::<toml::Table>() else {
290        return Vec::new();
291    };
292    if manifest
293        .get("profile")
294        .and_then(toml::Value::as_table)
295        .and_then(|profile| profile.get("dist"))
296        .is_some_and(toml::Value::is_table)
297    {
298        return Vec::new();
299    }
300
301    vec![InvariantFailure::new(
302        "dist-profile-missing",
303        "Cargo.toml",
304        "dist-workspace.toml enables GitHub CI, whose generated workflow builds with `--profile dist`, but the root Cargo.toml defines no [profile.dist] table",
305        concat!(
306            "add this exact block to Cargo.toml:\n",
307            "\n",
308            "[profile.dist]\n",
309            "inherits = \"release\""
310        ),
311    )]
312}
313
314/// The pair's one generated file. Either file absent reports nothing:
315/// `rk init` lands the configuration and writes no workflow, the operator
316/// generates it afterwards, and a missing `dist-workspace.toml` is already
317/// the record's own `missing` line. An absence is the generator's story.
318fn generated_release_workflow(target: &Utf8Path) -> Vec<InvariantFailure> {
319    let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
320        return Vec::new();
321    };
322    let workflow = match std::fs::read_to_string(target.join(GENERATED_WORKFLOW)) {
323        Ok(text) => text,
324        // Absence alone is silent. A file that is there and cannot be
325        // read as text is not an absent one, and a run that cannot read
326        // what the forge executes has not judged it.
327        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
328        Err(error) => {
329            return vec![InvariantFailure::new(
330                "workflow-file-unreadable",
331                GENERATED_WORKFLOW,
332                format!("the workflow is present and cannot be read as text: {error}"),
333                "repair the file so it reads as UTF-8 text, or regenerate it with dist generate --mode ci",
334            )];
335        }
336    };
337    workflow_matches_configuration(&config, &workflow)
338}
339
340/// The judgment, over the workflow's own text: `dist` is not available to
341/// regenerate and diff — the binding states the devshell carries none —
342/// and the text is what the forge executes. It reads in one direction,
343/// from the workflow to the configuration: a pin the configuration
344/// carries and the workflow never runs is the target's own tuning of its
345/// installers and its platforms, not drift. Every reference the workflow
346/// executes must be immutable whatever the configuration says about it,
347/// because a table entry naming a movable tag pins nothing.
348fn workflow_matches_configuration(config: &str, workflow: &str) -> Vec<InvariantFailure> {
349    let Ok(table) = config.parse::<toml::Table>() else {
350        return Vec::new();
351    };
352    let dist = table.get("dist").and_then(toml::Value::as_table);
353    let pinned = dist
354        .and_then(|dist| dist.get("github-action-commits"))
355        .and_then(toml::Value::as_table);
356    let attested = dist
357        .and_then(|dist| dist.get("github-attestations"))
358        .and_then(toml::Value::as_bool)
359        == Some(true);
360
361    let mut failures = Vec::new();
362    let steps = workflow_uses(workflow);
363    for step in &steps {
364        let (action, reference) = match step {
365            // A value this reader cannot resolve is never a pass: an
366            // alias, an unfamiliar shape, or a form a later generator
367            // emits would otherwise erase a real step from the judgment.
368            Step::Opaque(value) => {
369                failures.push(InvariantFailure::new(
370                    "workflow-step-unreadable",
371                    GENERATED_WORKFLOW,
372                    format!(
373                        "the workflow runs `uses: {value}`, which this check cannot resolve into an action and an immutable reference"
374                    ),
375                    "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",
376                ));
377                continue;
378            }
379            Step::Action(action, reference) => (action, reference),
380        };
381        // A non-string table entry pins nothing, and neither does a
382        // string that is not itself immutable, so both fall through to
383        // the reference check rather than blessing the step.
384        let pin = pinned
385            .and_then(|table| table.get(action.as_str()))
386            .and_then(toml::Value::as_str);
387        if let Some(commit) = pin
388            && commit != reference
389        {
390            failures.push(InvariantFailure::new(
391                "workflow-action-stale",
392                GENERATED_WORKFLOW,
393                format!(
394                    "the workflow runs {action}@{reference}, where dist-workspace.toml pins {commit}"
395                ),
396                "regenerate the workflow from the configuration with dist generate --mode ci and commit it; a hand edit is reverted at the next generate",
397            ));
398            continue;
399        }
400        if !is_immutable(reference) {
401            failures.push(InvariantFailure::new(
402                "workflow-action-unpinned",
403                GENERATED_WORKFLOW,
404                format!(
405                    "the workflow runs {action}@{reference}, which is no immutable reference, so the step runs whatever that name points at today"
406                ),
407                "pin the action at a full commit SHA in [dist.github-action-commits] in dist-workspace.toml, then regenerate with dist generate --mode ci",
408            ));
409        }
410    }
411    if attested
412        && !steps.iter().any(|step| match step {
413            Step::Action(action, _) => {
414                action == "actions/attest" || action.starts_with("actions/attest-")
415            }
416            Step::Opaque(_) => false,
417        })
418    {
419        failures.push(InvariantFailure::new(
420            "workflow-attestation-missing",
421            GENERATED_WORKFLOW,
422            "dist-workspace.toml sets github-attestations = true, and the workflow carries no attest step, so what this workflow builds ships unattested",
423            "regenerate the workflow with dist generate --mode ci and commit it, so the configured attest step is what runs",
424        ));
425    }
426    // The forge executes the workflow, not the configuration: a target
427    // that set skip and never regenerated still reports the job.
428    // This reader asks only whether a request trigger exists at all, and
429    // never which branches it names, so the trunk it passes is immaterial.
430    if crate::setup::workflow_jobs::request_trigger(workflow, crate::config::TRUNK_DEFAULT)
431        .is_some()
432    {
433        failures.push(InvariantFailure::new(
434            "workflow-runs-on-a-request",
435            GENERATED_WORKFLOW,
436            "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",
437            "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",
438        ));
439    }
440    failures
441}
442
443/// One `uses:` value the workflow carries.
444enum Step {
445    /// The judgeable form: an action and the reference it runs at.
446    Action(String, String),
447    /// A value this text reader cannot resolve into the pair — a YAML
448    /// alias, which GitHub Actions has accepted since September 2025, or
449    /// any shape a later generator emits. Carried rather than dropped,
450    /// because a step nobody can read is not a step nobody runs.
451    Opaque(String),
452}
453
454/// Every distinct `uses:` value the workflow carries.
455///
456/// A step is read in either YAML style, block or flow. A commented line
457/// and a value the reader can prove is same-repository —
458/// the workspace-relative `./` form and the `$/` self-repository form,
459/// which resolves to the running commit — are no movable external
460/// reference. Everything else is carried, including a key whose value
461/// sits on another line: a trailing comment and surrounding quotes are
462/// stripped, so the readable tag kept beside a commit does not read as
463/// part of it. One value is reported once however many jobs run it: the
464/// operator fixes the pin, not the steps.
465fn workflow_uses(workflow: &str) -> Vec<Step> {
466    let mut seen: Vec<String> = Vec::new();
467    let mut steps = Vec::new();
468    for fragment in workflow.lines().flat_map(line_fragments) {
469        let fragment = fragment.trim_start();
470        // A step may or may not open its list item on the same fragment.
471        let fragment = fragment
472            .strip_prefix("- ")
473            .map_or(fragment, str::trim_start);
474        let Some(rest) = uses_value(fragment) else {
475            continue;
476        };
477        let rest = before_comment(rest).trim();
478        let rest = rest
479            .strip_prefix('"')
480            .and_then(|rest| rest.strip_suffix('"'))
481            .or_else(|| {
482                rest.strip_prefix('\'')
483                    .and_then(|rest| rest.strip_suffix('\''))
484            })
485            .unwrap_or(rest);
486        if rest.starts_with("./") || rest.starts_with("$/") {
487            continue;
488        }
489        if seen.iter().any(|value| value == rest) {
490            continue;
491        }
492        seen.push(rest.to_owned());
493        steps.push(match rest.split_once('@') {
494            Some((action, reference)) => Step::Action(action.to_owned(), reference.to_owned()),
495            // An empty value is a scalar continued on a later line, which
496            // this line reader does not follow, so it is unreadable
497            // rather than absent.
498            None if rest.is_empty() => Step::Opaque("a value carried on another line".to_owned()),
499            None => Step::Opaque(rest.to_owned()),
500        });
501    }
502    steps
503}
504
505/// One line's mapping fragments.
506///
507/// A step is a mapping in either YAML style. A block line is one
508/// fragment, keeping every comma its scalar carries, because a git ref
509/// may hold one and splitting there would read a movable ref as the
510/// immutable prefix of itself. A line whose item opens a flow collection,
511/// or that carries a `uses` key beside a brace, is its delimiters apart —
512/// except where it also carries a quote, which can hold a delimiter
513/// inside a scalar: the reader does not guess there, and hands on a
514/// stand-in that reads as a step it cannot resolve. Every other braced
515/// line is an expression in some other key's value, never a step.
516fn line_fragments(line: &str) -> Vec<&str> {
517    let item = line.trim_start();
518    let item = item.strip_prefix("- ").map_or(item, str::trim_start);
519    let flow = item.starts_with('{')
520        || item.starts_with('[')
521        || ((line.contains('{') || line.contains('[')) && line.contains("uses"));
522    if !flow {
523        return vec![line];
524    }
525    if line.contains(QUOTES) {
526        return vec![UNSPLITTABLE_FLOW_LINE];
527    }
528    line.split(['{', '}', '[', ']', ',']).collect()
529}
530
531/// The scalar before its comment. A hash opens a YAML comment only where
532/// a space precedes it, and a git ref may carry one, so the readable tag
533/// kept beside a commit is stripped while `<sha>#dev` stays whole.
534pub(crate) fn before_comment(value: &str) -> &str {
535    let mut previous = ' ';
536    for (index, character) in value.char_indices() {
537        if character == '#' && (previous == ' ' || previous == '\t') {
538            return &value[..index];
539        }
540        previous = character;
541    }
542    value
543}
544
545/// The two quote characters a YAML scalar is written with, named by code
546/// point because the artifact-body scan reads a lone quote in these
547/// sources as a literal opening.
548const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
549
550/// The stand-in a quoted flow line becomes: it names no action, so the
551/// judgment reads it as a step it cannot resolve.
552const UNSPLITTABLE_FLOW_LINE: &str = "uses: a flow-style step carrying a quoted value";
553
554/// The value of a `uses` mapping key, however the key is spelled: bare or
555/// quoted, as YAML permits for any implicit key, and padded before its
556/// colon. A key whose name merely starts with `uses` is not this key.
557fn uses_value(line: &str) -> Option<&str> {
558    let rest = line
559        .strip_prefix("\"uses\"")
560        .or_else(|| line.strip_prefix("'uses'"))
561        .or_else(|| line.strip_prefix("uses"))?;
562    rest.trim_start().strip_prefix(':')
563}
564
565/// An immutable execution reference: a full commit SHA, or the image
566/// digest a `docker://` step pins, which no tag move can swap.
567fn is_immutable(reference: &str) -> bool {
568    let digest = reference
569        .strip_prefix("sha256:")
570        .filter(|digest| digest.len() == 64);
571    let commit = Some(reference).filter(|reference| reference.len() == 40);
572    digest
573        .or(commit)
574        .is_some_and(|value| value.chars().all(|char| char.is_ascii_hexdigit()))
575}
576
577#[cfg(test)]
578mod tests {
579    #![allow(clippy::expect_used)]
580
581    use camino::Utf8Path;
582
583    use super::{failures, target_failures, workflow_matches_configuration};
584
585    const CLEAN: &str = r#"
586[dist]
587pr-run-mode = "skip"
588github-attestations = true
589github-attestations-phase = "host"
590github-release = "host"
591
592[dist.github-action-commits]
593"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
594"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
595"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
596"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
597"#;
598
599    /// The correct configuration fails nothing, whatever the whitespace
600    /// and key order, and the payload's own seed is the exemplar: the
601    /// judgment is over the effective TOML, and the seed must satisfy
602    /// the rule it seeds.
603    #[test]
604    fn the_seeded_configuration_is_judged_effectively() {
605        assert!(failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes()).is_empty());
606        let seed = crate::embedded::SNIPPETS
607            .get_file("rust/github/dist-workspace.toml")
608            .and_then(|file| file.contents_utf8())
609            .expect("the seed is embedded");
610        assert!(
611            failures("rust", "github", "dist-workspace.toml", seed.as_bytes()).is_empty(),
612            "the payload's own seed satisfies the invariants it seeds"
613        );
614    }
615
616    /// A missing or stale action-commit table fails: the signer's own
617    /// steps would otherwise run whatever a movable tag names.
618    #[test]
619    fn a_missing_or_stale_action_commit_table_fails() {
620        let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
621        let found = failures("rust", "github", "dist-workspace.toml", missing.as_bytes());
622        assert!(
623            found
624                .iter()
625                .any(|failure| failure.code == "action-commit-missing"),
626            "a missing entry falls back to the movable tag: {found:?}"
627        );
628        let stale = CLEAN.replace(
629            "d23441a48e516b6c34aea4fa41551a30e30af803",
630            "0000000000000000000000000000000000000000",
631        );
632        let found = failures("rust", "github", "dist-workspace.toml", stale.as_bytes());
633        assert!(
634            found
635                .iter()
636                .any(|failure| failure.code == "action-commit-stale"
637                    && failure.reason.contains("actions/checkout")
638                    && failure
639                        .reason
640                        .contains("0000000000000000000000000000000000000000")),
641            "a mismatch names the found and expected commits: {found:?}"
642        );
643        let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
644        let found_invalid = failures("rust", "github", "dist-workspace.toml", invalid.as_bytes());
645        assert!(
646            found_invalid
647                .iter()
648                .any(|failure| failure.code == "action-commit-invalid"
649                    && failure.reason.contains("actions/checkout")),
650            "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
651        );
652        assert!(
653            !found
654                .iter()
655                .any(|failure| failure.reason.contains("actions/attest")),
656            "only the stale action is named: {found:?}"
657        );
658    }
659
660    /// Every degraded form fails with its own code: a commented key, a
661    /// false value, the default phase, an unpaired release phase, a
662    /// narrowing filter, and malformed TOML.
663    #[test]
664    fn each_degraded_form_fails_with_its_code() {
665        let cases: &[(&str, &str)] = &[
666            (
667                "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
668                "attestations-disabled",
669            ),
670            (
671                "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
672                "attestations-disabled",
673            ),
674            (
675                "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
676                "attestation-phase-not-host",
677            ),
678            (
679                "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
680                "attestation-phase-not-host",
681            ),
682            (
683                "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
684                "release-phase-unpaired",
685            ),
686            (
687                "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
688                "attestation-filters-narrowed",
689            ),
690            ("not toml at [all", "unparsable-configuration"),
691        ];
692        for (text, code) in cases {
693            let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
694            assert!(
695                found.iter().any(|failure| failure.code == *code),
696                "{text:?} must fail with {code}, got {found:?}"
697            );
698        }
699    }
700
701    /// Every run mode but `skip` puts a job on a pull request that no
702    /// gate can need, and an unset key is the default `plan`, so the
703    /// absent case carries its own reason rather than the value's.
704    #[test]
705    fn a_configuration_that_reports_on_a_request_fails() {
706        let absent = CLEAN.replace("pr-run-mode = \"skip\"\n", "");
707        let found = failures("rust", "github", "dist-workspace.toml", absent.as_bytes());
708        assert!(
709            found
710                .iter()
711                .any(|failure| failure.code == "pr-run-mode-not-skip"
712                    && failure.reason.contains("unset")
713                    && failure.reason.contains("plan")),
714            "an unset key defaults to plan and says so: {found:?}"
715        );
716        for other in ["plan", "upload"] {
717            let text = CLEAN.replace("\"skip\"", &format!("\"{other}\""));
718            let found = failures("rust", "github", "dist-workspace.toml", text.as_bytes());
719            assert!(
720                found
721                    .iter()
722                    .any(|failure| failure.code == "pr-run-mode-not-skip"
723                        && failure.reason.contains(other)),
724                "{other} fails and is named: {found:?}"
725            );
726        }
727        // A non-string value pins nothing either, and falls to the same
728        // code rather than passing on a shape the reader cannot use.
729        let non_string = CLEAN.replace("\"skip\"", "3");
730        assert!(
731            failures(
732                "rust",
733                "github",
734                "dist-workspace.toml",
735                non_string.as_bytes()
736            )
737            .iter()
738            .any(|failure| failure.code == "pr-run-mode-not-skip"),
739            "a non-string run mode is not skip"
740        );
741        assert!(
742            !failures("rust", "github", "dist-workspace.toml", CLEAN.as_bytes())
743                .iter()
744                .any(|failure| failure.code == "pr-run-mode-not-skip"),
745            "skip fails nothing"
746        );
747    }
748
749    /// The forge executes the workflow, not the configuration: a target
750    /// that set `skip` and never regenerated still reports the job, in
751    /// whichever form of `on` the generator left behind.
752    #[test]
753    fn a_generated_workflow_that_triggers_on_a_request_fails() {
754        let attest = "      - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
755        for trigger in [
756            "on:\n  pull_request:\n",
757            "on: [push, pull_request]\n",
758            "on: pull_request_target\n",
759        ] {
760            let workflow = format!("{trigger}jobs:\n  plan:\n    steps:\n{attest}");
761            assert!(
762                workflow_matches_configuration(CLEAN, &workflow)
763                    .iter()
764                    .any(|failure| failure.code == "workflow-runs-on-a-request"
765                        && failure.destination == super::GENERATED_WORKFLOW),
766                "{trigger:?} reports a check no gate can need"
767            );
768        }
769        let tag_only =
770            format!("on:\n  push:\n    tags:\n      - '**'\njobs:\n  plan:\n    steps:\n{attest}");
771        assert!(
772            workflow_matches_configuration(CLEAN, &tag_only).is_empty(),
773            "a tag-only workflow reports nothing on a request"
774        );
775    }
776
777    /// A workflow generated from the clean configuration fails nothing.
778    /// Both indentations cargo-dist emits parse, a commented line is no
779    /// step, and a readable tag kept beside a commit is not the commit.
780    #[test]
781    fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
782        let workflow = "\
783jobs:
784  plan:
785    steps:
786      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
787      # - uses: actions/checkout@v4
788      - name: Upload
789        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
790      - name: Attest
791        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
792";
793        let found = workflow_matches_configuration(CLEAN, workflow);
794        assert!(
795            found.is_empty(),
796            "the generated workflow is clean: {found:?}"
797        );
798    }
799
800    /// A workflow left behind by a configuration change fails, whether
801    /// the reference it kept is a movable tag or a superseded commit: the
802    /// forge executes the workflow, never the configuration.
803    #[test]
804    fn a_workflow_left_at_a_movable_tag_fails() {
805        for stale in ["v4", "0000000000000000000000000000000000000000"] {
806            let workflow = format!(
807                "steps:\n  - uses: actions/checkout@{stale}\n  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
808            );
809            let found = workflow_matches_configuration(CLEAN, &workflow);
810            assert!(
811                found
812                    .iter()
813                    .any(|failure| failure.code == "workflow-action-stale"
814                        && failure.destination == ".github/workflows/release.yml"
815                        && failure.reason.contains("actions/checkout")
816                        && failure.reason.contains(stale)
817                        && failure
818                            .reason
819                            .contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
820                "{stale} names both sides of the disagreement: {found:?}"
821            );
822        }
823        let twice = "steps:\n  - uses: actions/checkout@v4\n  - uses: actions/checkout@v4\n  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
824        assert_eq!(
825            workflow_matches_configuration(CLEAN, twice).len(),
826            1,
827            "one reference is one failure, however many jobs run it"
828        );
829    }
830
831    /// A configured attestation the workflow does not carry fails: the
832    /// configuration is the only place the operator reads, and the
833    /// release ships unattested. The build-provenance variant satisfies
834    /// it, so a cargo-dist version that renames the step is no failure.
835    #[test]
836    fn a_configured_attestation_with_no_attest_step_fails() {
837        let bare = "steps:\n  - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
838        let found = workflow_matches_configuration(CLEAN, bare);
839        assert!(
840            found
841                .iter()
842                .any(|failure| failure.code == "workflow-attestation-missing"),
843            "an unattested workflow fails: {found:?}"
844        );
845        assert!(
846            !found
847                .iter()
848                .any(|failure| failure.code.starts_with("workflow-action-")),
849            "the pinned step itself is clean: {found:?}"
850        );
851        let variant = format!(
852            "{bare}  - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
853        );
854        assert!(
855            workflow_matches_configuration(CLEAN, &variant).is_empty(),
856            "the build-provenance variant is an attest step"
857        );
858    }
859
860    /// A reference is judged for itself: an action the configuration
861    /// pins with a movable tag, or with a non-string value, fails just as
862    /// one the configuration never names, because a table entry naming a
863    /// tag pins nothing. A pin the workflow never runs fails nothing:
864    /// which actions cargo-dist emits follows the target's own installers
865    /// and platforms, which a seeded file leaves the target to tune.
866    #[test]
867    fn a_movable_reference_fails_whatever_the_configuration_says() {
868        let attest = "  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
869        let movable = format!("steps:\n  - uses: third/party@v1\n{attest}");
870        let found = workflow_matches_configuration(CLEAN, &movable);
871        assert!(
872            found
873                .iter()
874                .any(|failure| failure.code == "workflow-action-unpinned"
875                    && failure.reason.contains("third/party")),
876            "an action the configuration never names fails: {found:?}"
877        );
878        // The configuration agreeing with the movable tag is the hole
879        // this case exists to hold shut.
880        let agreed = CLEAN.replace(
881            "[dist.github-action-commits]",
882            "[dist.github-action-commits]\n\"third/party\" = \"v1\"",
883        );
884        let found = workflow_matches_configuration(&agreed, &movable);
885        assert!(
886            found
887                .iter()
888                .any(|failure| failure.code == "workflow-action-unpinned"
889                    && failure.reason.contains("third/party")),
890            "a table entry naming the same movable tag pins nothing: {found:?}"
891        );
892        let non_string = CLEAN.replace(
893            "[dist.github-action-commits]",
894            "[dist.github-action-commits]\n\"third/party\" = 1",
895        );
896        assert!(
897            workflow_matches_configuration(&non_string, &movable)
898                .iter()
899                .any(|failure| failure.code == "workflow-action-unpinned"),
900            "a non-string entry pins nothing either"
901        );
902        let pinned = format!(
903            "steps:\n  - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
904        );
905        assert!(
906            workflow_matches_configuration(CLEAN, &pinned).is_empty(),
907            "a commit-pinned action the configuration does not name is the target's own"
908        );
909        assert!(
910            workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
911            "a pin no step runs is the target's tuning, not drift"
912        );
913    }
914
915    /// Every real `uses:` shape reaches the judgment: a padded or quoted
916    /// key, a flow mapping, a compact flow sequence, a ref carrying a
917    /// comma or a hash. A local `./` or `$/` step is the repository's own
918    /// file at the running commit, and a `docker://` image pinned by
919    /// digest is immutable.
920    #[test]
921    fn every_real_step_shape_reaches_the_judgment() {
922        let attest = "  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
923        let padded = format!("steps:\n  - uses : actions/checkout@v4\n{attest}");
924        assert!(
925            workflow_matches_configuration(CLEAN, &padded)
926                .iter()
927                .any(|failure| failure.code == "workflow-action-stale"),
928            "a padded key is the same mapping"
929        );
930        let quoted = format!("steps:\n  - \"uses\": actions/checkout@v4\n{attest}");
931        assert!(
932            workflow_matches_configuration(CLEAN, &quoted)
933                .iter()
934                .any(|failure| failure.code == "workflow-action-stale"),
935            "a quoted key is the same mapping"
936        );
937        let flow =
938            format!("steps:\n  - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
939        assert!(
940            workflow_matches_configuration(CLEAN, &flow)
941                .iter()
942                .any(|failure| failure.code == "workflow-action-stale"),
943            "a flow-style step is the same mapping"
944        );
945        // A git ref may carry a comma, so a block line is never split on
946        // one: the immutable-looking prefix is not the reference.
947        let comma = format!(
948            "steps:\n  - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
949        );
950        assert!(
951            workflow_matches_configuration(CLEAN, &comma)
952                .iter()
953                .any(|failure| failure.code == "workflow-action-unpinned"
954                    && failure.reason.contains(",dev")),
955            "the whole reference is judged, never its prefix"
956        );
957        // A hash opens a comment only after a space, and a git ref may
958        // carry one, so the reference is never read as its prefix.
959        let hashed = format!(
960            "steps:\n  - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
961        );
962        assert!(
963            workflow_matches_configuration(CLEAN, &hashed)
964                .iter()
965                .any(|failure| failure.code == "workflow-action-unpinned"
966                    && failure.reason.contains("#dev")),
967            "an adjacent hash is scalar content, not a comment"
968        );
969        // A compact single-pair mapping is a flow-sequence entry.
970        let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
971        assert!(
972            workflow_matches_configuration(CLEAN, &compact)
973                .iter()
974                .any(|failure| failure.code == "workflow-action-unpinned"
975                    && failure.reason.contains("third/party")),
976            "a compact flow sequence carries its uses key"
977        );
978        assert!(
979            workflow_matches_configuration(CLEAN, &format!("steps:\n  - usesful: no\n{attest}"))
980                .is_empty(),
981            "a key that merely starts with uses is another key"
982        );
983        for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
984            let local = format!("steps:\n  - uses: {same_repository}\n{attest}");
985            assert!(
986                workflow_matches_configuration(CLEAN, &local).is_empty(),
987                "{same_repository} is the repository's own file at the running commit"
988            );
989        }
990        let tagged = format!("steps:\n  - uses: docker://alpine:3.8\n{attest}");
991        assert!(
992            workflow_matches_configuration(CLEAN, &tagged)
993                .iter()
994                .any(|failure| failure.code == "workflow-step-unreadable"),
995            "a docker image with no digest is not immutable"
996        );
997        let digested = format!(
998            "steps:\n  - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
999        );
1000        assert!(
1001            workflow_matches_configuration(CLEAN, &digested).is_empty(),
1002            "a docker image pinned by digest is immutable"
1003        );
1004    }
1005
1006    /// A value the reader cannot resolve is reported rather than passed.
1007    /// GitHub Actions accepts YAML aliases, a scalar may continue on
1008    /// another line, and a quote may hold a flow delimiter the reader
1009    /// would otherwise split on: a step nobody can read is not a step
1010    /// nobody runs. A braced expression in another key's value is no step
1011    /// at all, and a generated workflow is full of them.
1012    #[test]
1013    fn a_step_the_reader_cannot_resolve_is_reported() {
1014        let attest = "  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1015        let aliased = format!("steps:\n  - uses: *checkout\n{attest}");
1016        assert!(
1017            workflow_matches_configuration(CLEAN, &aliased)
1018                .iter()
1019                .any(|failure| failure.code == "workflow-step-unreadable"
1020                    && failure.reason.contains("*checkout")),
1021            "an alias is unreadable, never clean"
1022        );
1023        let continued = format!("steps:\n  - uses:\n      actions/checkout@v4\n{attest}");
1024        assert!(
1025            workflow_matches_configuration(CLEAN, &continued)
1026                .iter()
1027                .any(|failure| failure.code == "workflow-step-unreadable"),
1028            "a value on another line is unreadable, never clean"
1029        );
1030        let quoted_flow = format!("steps:\n  - {{ uses: \"third/party@1,dev\" }}\n{attest}");
1031        assert!(
1032            workflow_matches_configuration(CLEAN, &quoted_flow)
1033                .iter()
1034                .any(|failure| failure.code == "workflow-step-unreadable"),
1035            "a quoted flow line is not split on a guess"
1036        );
1037        let expression = format!(
1038            "jobs:\n  host:\n    if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n    steps:\n{attest}"
1039        );
1040        assert!(
1041            workflow_matches_configuration(CLEAN, &expression).is_empty(),
1042            "an expression is not a step this reader cannot resolve"
1043        );
1044    }
1045
1046    /// The cross-file judgment needs both files and its own pair. Either
1047    /// one absent reports nothing: release-kit writes neither the
1048    /// workflow nor a record of it, so an absence is the generator's
1049    /// story and a fresh landing never fails on its first day.
1050    #[test]
1051    fn the_cross_file_judgment_needs_both_files() {
1052        let dir = tempfile::tempdir().expect("a scratch directory");
1053        let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
1054        let broken = "steps:\n  - uses: actions/checkout@v4\n";
1055        assert!(
1056            target_failures("rust", "github", target).is_empty(),
1057            "an empty target"
1058        );
1059        std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
1060        assert!(
1061            target_failures("rust", "github", target).is_empty(),
1062            "a configuration with no generated workflow"
1063        );
1064        std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
1065        std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
1066        assert!(
1067            !target_failures("rust", "github", target).is_empty(),
1068            "both files present, and they disagree"
1069        );
1070        for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
1071            assert!(
1072                target_failures(tech, forge, target).is_empty(),
1073                "{tech}/{forge} generates no artifact workflow"
1074            );
1075        }
1076        // A workflow that is there and cannot be read as text is not an
1077        // absent one: only absence is silent.
1078        std::fs::write(
1079            target.join(".github/workflows/release.yml"),
1080            [0x66, 0xff, 0xfe],
1081        )
1082        .expect("the workflow");
1083        assert!(
1084            target_failures("rust", "github", target)
1085                .iter()
1086                .any(|failure| failure.code == "workflow-file-unreadable"),
1087            "a present workflow that does not read as text is reported"
1088        );
1089        std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
1090        assert!(
1091            target_failures("rust", "github", target).is_empty(),
1092            "a workflow with no configuration to judge it against"
1093        );
1094    }
1095
1096    /// The key is the pair plus the destination: the same bytes under
1097    /// another pair or another destination fail nothing, so a second pair
1098    /// sharing a destination cannot silently inherit this rule.
1099    #[test]
1100    fn the_rule_is_keyed_by_pair_and_destination() {
1101        let broken = b"[dist]\ngithub-attestations = false\n";
1102        assert!(failures("rust", "gitlab", "dist-workspace.toml", broken).is_empty());
1103        assert!(failures("bash", "github", "dist-workspace.toml", broken).is_empty());
1104        assert!(failures("rust", "github", "release-plz.toml", broken).is_empty());
1105    }
1106}