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