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 seeds ship 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;
28use crate::profile::RegistryAnswer;
29
30/// One invariant a landed file's effective configuration violates: a
31/// stable code, the destination, why, and exactly what to write — the
32/// operator is told the remediation, never just what was not found.
33#[derive(Debug, Clone, Serialize)]
34pub struct InvariantFailure {
35    /// The stable machine code of the failed rule.
36    pub code: &'static str,
37    /// The landed destination the failure is about.
38    pub destination: String,
39    /// Why the configuration violates the invariant.
40    pub reason: String,
41    /// Exactly what to write to satisfy the rule.
42    pub remediation: &'static str,
43}
44
45impl InvariantFailure {
46    fn new(
47        code: &'static str,
48        destination: &str,
49        reason: impl Into<String>,
50        remediation: &'static str,
51    ) -> Self {
52        Self {
53            code,
54            destination: destination.to_owned(),
55            reason: reason.into(),
56            remediation,
57        }
58    }
59}
60
61/// Judge one landed file against the rules its `(tech, forge,
62/// destination)` key owns.
63///
64/// A destination no rule owns fails nothing. `registry` is the resolved
65/// `profile.release.registry` answer, absent where the target never
66/// answered it.
67#[must_use]
68pub fn failures(
69    tech: &str,
70    forge: &str,
71    destination: &str,
72    bytes: &[u8],
73    registry: Option<RegistryAnswer>,
74) -> Vec<InvariantFailure> {
75    match (tech, forge, destination) {
76        ("rust", "github", "dist-workspace.toml") => dist_workspace(destination, bytes),
77        ("rust", _, "release-plz.toml") => release_plz(destination, bytes, registry),
78        _ => Vec::new(),
79    }
80}
81
82/// The parsed TOML of a landed configuration, or the one failure that
83/// says it cannot be judged.
84fn parsed(destination: &str, bytes: &[u8]) -> Result<toml::Table, InvariantFailure> {
85    let text = std::str::from_utf8(bytes).map_err(|_| {
86        InvariantFailure::new(
87            "unparsable-configuration",
88            destination,
89            "the file is not UTF-8, so its configuration cannot be judged",
90            "repair the file so it parses as TOML",
91        )
92    })?;
93    text.parse().map_err(|error| {
94        InvariantFailure::new(
95            "unparsable-configuration",
96            destination,
97            format!("the file does not parse as TOML: {error}"),
98            "repair the file so it parses as TOML",
99        )
100    })
101}
102
103/// One boolean key of `release-plz.toml`, as the file states it.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum Flag {
106    /// The key is not set.
107    Absent,
108    /// The key is set to a boolean.
109    Set(bool),
110    /// The key is set to a value of another type.
111    Malformed,
112}
113
114impl Flag {
115    /// A package value over the workspace one.
116    const fn over(self, workspace: Self) -> Self {
117        match self {
118            Self::Absent => workspace,
119            set => set,
120        }
121    }
122}
123
124/// Every scope of `release-plz.toml` that states `publish` or `git_only`:
125/// `[workspace]`, then each `[[package]]` that sets either, a package value
126/// over the workspace one.
127///
128/// A present value of another type is not an absent key: release-plz
129/// refuses it, so it is reported once by scope and key into `failures`
130/// and judged no further, never read as a default or a contradiction.
131fn release_plz_scopes(
132    destination: &str,
133    table: &toml::Table,
134    failures: &mut Vec<InvariantFailure>,
135) -> Vec<(String, Flag, Flag)> {
136    let mut flag = |scope_name: &str, scope: Option<&toml::Table>, key: &str| -> Flag {
137        match scope.and_then(|scope| scope.get(key)) {
138            None => Flag::Absent,
139            Some(toml::Value::Boolean(value)) => Flag::Set(*value),
140            Some(other) => {
141                failures.push(InvariantFailure::new(
142                    "release-plz-flag-not-boolean",
143                    destination,
144                    format!(
145                        "{scope_name} sets {key} = {other}, which is no boolean, so release-plz refuses the file"
146                    ),
147                    "write publish and git_only as true or false, unquoted",
148                ));
149                Flag::Malformed
150            }
151        }
152    };
153    let workspace = table.get("workspace").and_then(toml::Value::as_table);
154    let workspace_flags = (
155        flag("[workspace]", workspace, "publish"),
156        flag("[workspace]", workspace, "git_only"),
157    );
158    let mut scopes = vec![(
159        "[workspace]".to_owned(),
160        workspace_flags.0,
161        workspace_flags.1,
162    )];
163    for package in table
164        .get("package")
165        .and_then(toml::Value::as_array)
166        .into_iter()
167        .flatten()
168        .filter_map(toml::Value::as_table)
169    {
170        let name = format!(
171            "the [[package]] {}",
172            package
173                .get("name")
174                .and_then(toml::Value::as_str)
175                .unwrap_or("an unnamed package")
176        );
177        let (publish, git_only) = (
178            flag(&name, Some(package), "publish"),
179            flag(&name, Some(package), "git_only"),
180        );
181        if publish == Flag::Absent && git_only == Flag::Absent {
182            continue;
183        }
184        scopes.push((
185            name,
186            publish.over(workspace_flags.0),
187            git_only.over(workspace_flags.1),
188        ));
189    }
190    scopes
191}
192
193/// The rust release-plz configuration: `publish` and `git_only` agree
194/// with each other and with where the crate publishes. release-plz
195/// compares with the registry unless `git_only` is on, so a crate that
196/// publishes nothing and reads no tags never moves past its last
197/// published version; release-plz refuses `publish = true` beside
198/// `git_only = true`. The judgment reads `[workspace]` and every
199/// `[[package]]` that overrides either key, a package value over the
200/// workspace one.
201///
202/// SATISFIES landing:a-seeded-file-still-carries-the-invariants
203fn release_plz(
204    destination: &str,
205    bytes: &[u8],
206    registry: Option<RegistryAnswer>,
207) -> Vec<InvariantFailure> {
208    let table = match parsed(destination, bytes) {
209        Ok(table) => table,
210        Err(failure) => return vec![failure],
211    };
212    let mut failures = Vec::new();
213    let scopes = release_plz_scopes(destination, &table, &mut failures);
214    let answer = registry.unwrap_or(RegistryAnswer::CratesIo);
215    for (scope, publish, git_only) in scopes {
216        if publish == Flag::Malformed || git_only == Flag::Malformed {
217            continue;
218        }
219        let git_only = git_only == Flag::Set(true);
220        if publish == Flag::Set(false) && !git_only {
221            failures.push(InvariantFailure::new(
222                "publish-off-without-git-only",
223                destination,
224                format!(
225                    "{scope} sets publish = false without git_only = true, so release-plz compares with the registry and never proposes a version past the last published one"
226                ),
227                "set git_only = true beside publish = false, and set profile.release.registry = \"none\" in .release-kit/config.toml",
228            ));
229        }
230        if publish == Flag::Set(true) && git_only {
231            failures.push(InvariantFailure::new(
232                "git-only-with-publish",
233                destination,
234                format!(
235                    "{scope} sets git_only = true beside publish = true, a pair release-plz refuses"
236                ),
237                "remove publish = true, or set publish = false",
238            ));
239        }
240        match (answer, git_only) {
241            (RegistryAnswer::None, false) => failures.push(InvariantFailure::new(
242                "registry-answer-disagrees",
243                destination,
244                format!(
245                    "profile.release.registry is none, and {scope} does not set git_only = true, so release-plz reads the registry for a crate that publishes to none"
246                ),
247                "set publish = false and git_only = true in [workspace] of release-plz.toml, or set profile.release.registry = \"crates-io\" in .release-kit/config.toml",
248            )),
249            (RegistryAnswer::CratesIo, true) => failures.push(InvariantFailure::new(
250                "registry-answer-disagrees",
251                destination,
252                format!(
253                    "profile.release.registry is crates-io or unanswered, and {scope} sets git_only = true, so release-plz never publishes the crate"
254                ),
255                "set profile.release.registry = \"none\" in .release-kit/config.toml, or remove git_only from release-plz.toml and set publish = true",
256            )),
257            _ => {}
258        }
259    }
260    failures
261}
262
263/// The rust/github attestation configuration: attestations on, minted in
264/// the `host` phase where every hosted asset is gathered before the
265/// release page exists, the release creation paired with that phase, and
266/// no narrowing filter — the default `["*"]` covers every hosted file,
267/// where an enumerated list goes quiet when an archive format moves.
268fn dist_workspace(destination: &str, bytes: &[u8]) -> Vec<InvariantFailure> {
269    let Ok(text) = std::str::from_utf8(bytes) else {
270        return vec![InvariantFailure::new(
271            "unparsable-configuration",
272            destination,
273            "the file is not UTF-8, so its configuration cannot be judged",
274            "repair the file so it parses as TOML",
275        )];
276    };
277    let table: toml::Table = match text.parse() {
278        Ok(table) => table,
279        Err(error) => {
280            return vec![InvariantFailure::new(
281                "unparsable-configuration",
282                destination,
283                format!("the file does not parse as TOML: {error}"),
284                "repair the file so it parses as TOML",
285            )];
286        }
287    };
288    let dist = table.get("dist").and_then(toml::Value::as_table);
289    let mut failures = Vec::new();
290    let value = |key: &str| dist.and_then(|dist| dist.get(key));
291    if value("github-attestations").and_then(toml::Value::as_bool) != Some(true) {
292        failures.push(InvariantFailure::new(
293            "attestations-disabled",
294            destination,
295            "github-attestations is not effectively true, so no release artifact is attested",
296            "set github-attestations = true in [dist]",
297        ));
298    }
299    let phase = value("github-attestations-phase").and_then(toml::Value::as_str);
300    if phase != Some("host") {
301        failures.push(InvariantFailure::new(
302            "attestation-phase-not-host",
303            destination,
304            phase.map_or_else(
305                || "github-attestations-phase is unset, so the default phase attests only the per-platform archives and the curled installers ship unattested".to_owned(),
306                |other| format!(
307                    "github-attestations-phase is \"{other}\"; only the host phase attests every asset before the release page exists"
308                ),
309            ),
310            "set github-attestations-phase = \"host\" in [dist]",
311        ));
312    }
313    if value("github-release").and_then(toml::Value::as_str) != Some("host") {
314        failures.push(InvariantFailure::new(
315            "release-phase-unpaired",
316            destination,
317            "github-release is not \"host\", leaving the release creation unpaired with the attest phase",
318            "set github-release = \"host\" in [dist], pairing the release creation with the phase that attests",
319        ));
320    }
321    if value("github-attestations-filters").is_some() {
322        failures.push(InvariantFailure::new(
323            "attestation-filters-narrowed",
324            destination,
325            "github-attestations-filters narrows what is attested below the whole set of release artifacts",
326            "remove github-attestations-filters from [dist]; the default [\"*\"] attests every hosted file",
327        ));
328    }
329    // cargo-dist's default puts a plan job on every pull request. The
330    // trunk protection requires one project-owned context beside the
331    // landed title check, and `needs` resolves inside one workflow file,
332    // so a job in the generated file is in no gate's needs: it reports a
333    // status nothing holds, and under the standing arm a red one merges.
334    let mode = value("pr-run-mode").and_then(toml::Value::as_str);
335    if mode != Some("skip") {
336        failures.push(InvariantFailure::new(
337            "pr-run-mode-not-skip",
338            destination,
339            mode.map_or_else(
340                || "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(),
341                |other| format!(
342                    "pr-run-mode is \"{other}\", so the generated workflow reports a job on every pull request that no gate can need"
343                ),
344            ),
345            "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",
346        ));
347    }
348    failures.extend(action_commit_failures(
349        destination,
350        value("github-action-commits").and_then(toml::Value::as_table),
351    ));
352    failures
353}
354
355/// The build that signs is itself pinned by digest: the seed's
356/// `[dist.github-action-commits]` table pins the actions cargo-dist
357/// injects — the attest step among them — and a landed target must carry
358/// the same effective table, or its signer runs code a moved tag can
359/// swap.
360fn action_commit_failures(destination: &str, found: Option<&toml::Table>) -> Vec<InvariantFailure> {
361    let remediation = "bring the [dist.github-action-commits] table to the embedded seed's (rk snippet rust/github/dist-workspace.toml) and regenerate with dist generate --mode ci";
362    let mut failures = Vec::new();
363    for (action, commit) in &seed_action_commits() {
364        // Three distinct states, each with its own true reason: an
365        // absent entry falls back to the movable tag, a non-string value
366        // is invalid configuration, and a mismatched string executes an
367        // immutable commit that is just not the seed's.
368        match found.and_then(|table| table.get(action)) {
369            Some(value) => match value.as_str() {
370                Some(pinned) if pinned == commit.as_str() => {}
371                Some(pinned) => failures.push(InvariantFailure::new(
372                    "action-commit-stale",
373                    destination,
374                    format!(
375                        "[dist.github-action-commits] pins {action} at {pinned}, where the seed pins {commit}"
376                    ),
377                    remediation,
378                )),
379                None => failures.push(InvariantFailure::new(
380                    "action-commit-invalid",
381                    destination,
382                    format!(
383                        "[dist.github-action-commits] pins {action} with a non-string value; a pin is a full commit SHA string"
384                    ),
385                    remediation,
386                )),
387            },
388            None => failures.push(InvariantFailure::new(
389                "action-commit-missing",
390                destination,
391                format!(
392                    "[dist.github-action-commits] does not pin {action}, so the workflow runs whatever the movable tag names"
393                ),
394                remediation,
395            )),
396        }
397    }
398    failures
399}
400
401/// The action commits the embedded seed pins, read from the
402/// embedded snippet so the judgment and the seed cannot drift apart.
403fn seed_action_commits() -> Vec<(String, String)> {
404    let Some(text) = embedded::SNIPPETS
405        .get_file("rust/github/dist-workspace.toml")
406        .and_then(|file| file.contents_utf8())
407    else {
408        return Vec::new();
409    };
410    let Ok(table) = text.parse::<toml::Table>() else {
411        return Vec::new();
412    };
413    table
414        .get("dist")
415        .and_then(toml::Value::as_table)
416        .and_then(|dist| dist.get("github-action-commits"))
417        .and_then(toml::Value::as_table)
418        .map(|commits| {
419            commits
420                .iter()
421                .filter_map(|(action, commit)| {
422                    commit
423                        .as_str()
424                        .map(|commit| (action.clone(), commit.to_owned()))
425                })
426                .collect()
427        })
428        .unwrap_or_default()
429}
430
431/// The generated file the cross-file failures name: cargo-dist writes it
432/// from `dist-workspace.toml`, the seeds ship no copy, and the forge
433/// executes it.
434const GENERATED_WORKFLOW: &str = ".github/workflows/release.yml";
435
436/// Judge target-wide relationships, keyed by `(technology, forge)`.
437///
438/// A destination-keyed rule cannot reach a second file: the byte reader
439/// receives only one landed destination. The pair-keyed rules read files
440/// from the target's own disk, including generated files the seeds ship
441/// no copy of and target-owned files landed configuration requires.
442#[must_use]
443pub fn target_failures(tech: &str, forge: &str, target: &Utf8Path) -> Vec<InvariantFailure> {
444    match (tech, forge) {
445        ("rust", "github") => {
446            let mut failures = generated_release_workflow(target);
447            failures.extend(dist_profile(target));
448            failures
449        }
450        _ => Vec::new(),
451    }
452}
453
454/// The code of a gate that names no check: the judgment then reads every
455/// pull-request workflow, which it states as a limitation.
456pub const GATE_UNNAMED: &str = "gate-unnamed";
457
458/// The code of a `dist build` that runs under a condition: it proves no
459/// merge, which the judgment states as a limitation.
460pub const DIST_BUILD_CONDITIONAL: &str = "dist-build-conditional";
461
462/// The directory every forge workflow lives in.
463const WORKFLOW_DIR: &str = ".github/workflows";
464
465/// What the dist target judgment found: the failures `--check` counts,
466/// and the limitations a report states without counting them.
467#[derive(Debug, Default)]
468pub struct DistGate {
469    /// The failures, each a violation under `--check`.
470    pub failures: Vec<InvariantFailure>,
471    /// The limitations, as a stable code and the reason.
472    pub limitations: Vec<(&'static str, String)>,
473}
474
475/// One workflow the gate can reach, parsed.
476struct GatedWorkflow {
477    /// The path, relative to the target.
478    path: String,
479    /// Whether it runs on `pull_request` or `merge_group`.
480    on_requests: bool,
481    /// Whether that request trigger carries a `paths` or `paths-ignore`
482    /// filter, so the workflow does not run on every request.
483    filtered: bool,
484    /// The workflow-level environment.
485    env: std::collections::BTreeMap<String, String>,
486    /// Every job, by id, in file order.
487    jobs: Vec<(String, yaml_serde::Value)>,
488}
489
490/// What an artifacts matrix covers.
491enum PlanCredit {
492    /// Every target, from an unnarrowed plan.
493    Every,
494    /// The triples a narrowing `--target` names.
495    Named(std::collections::BTreeSet<String>),
496}
497
498/// What one `dist build` step proves.
499enum Credit {
500    /// The triples it builds before a merge.
501    Targets(std::collections::BTreeSet<String>),
502    /// It runs under a condition, so it proves no merge.
503    Conditional,
504    /// Its workflow runs only on the requests a path filter selects.
505    Filtered,
506    /// Its arguments or its matrix cannot be resolved.
507    Unreadable(String),
508}
509
510/// SATISFIES landing:a-seeded-file-still-carries-the-invariants
511///
512/// Every target of `dist-workspace.toml` is built by a job the gate needs
513/// before a merge. The gate is the job whose check context is
514/// `setup.required_check`, or, where that names nothing, every job of
515/// every pull-request workflow. A target counts as built where a judged
516/// job runs `dist build` with no condition and either names the triple,
517/// takes it from a literal matrix list, or consumes `matrix.dist_args`
518/// from the `artifacts_matrix` of an unnarrowed `dist plan` in a judged
519/// job. The generated `release.yml` runs on the tag and proves nothing
520/// before a merge, so it is not read. An absent configuration or an
521/// absent workflow directory reports nothing. `dev_run` is the run prefix
522/// of the recorded `dev_environment`, empty where none is recorded: a
523/// `dist` command after exactly that prefix, word for word, runs in the
524/// target's own environment and is read like a bare one.
525#[must_use]
526pub fn dist_targets_built(target: &Utf8Path, required_check: &str, dev_run: &str) -> DistGate {
527    let recorded: Vec<&str> = dev_run.split_whitespace().collect();
528    let mut gate = DistGate::default();
529    let Some(targets) = dist_targets(target) else {
530        return gate;
531    };
532    let workflows = gated_workflows(target);
533    if workflows.is_empty() {
534        return gate;
535    }
536    let Some(judged) = judged_jobs(&workflows, required_check, &mut gate) else {
537        return gate;
538    };
539    let mut built = std::collections::BTreeSet::new();
540    for (index, id) in &judged {
541        let workflow = &workflows[*index];
542        let Some(job) = job_of(workflow, id) else {
543            continue;
544        };
545        let peers: Vec<&str> = judged
546            .iter()
547            .filter(|(other, _)| other == index)
548            .map(|(_, peer)| peer.as_str())
549            .collect();
550        for step in steps_of(job) {
551            let Some(run) = step.get("run").and_then(yaml_serde::Value::as_str) else {
552                continue;
553            };
554            if !run.contains("dist build") {
555                continue;
556            }
557            match credit(workflow, job, step, run, &peers, &targets, &recorded) {
558                Credit::Targets(triples) => built.extend(triples),
559                Credit::Conditional => gate.limitations.push((
560                    DIST_BUILD_CONDITIONAL,
561                    format!(
562                        "{} job {id} runs dist build under a condition, so it proves no merge and counts no target",
563                        workflow.path
564                    ),
565                )),
566                Credit::Filtered => gate.limitations.push((
567                    DIST_BUILD_CONDITIONAL,
568                    format!(
569                        "{} job {id} runs dist build in a workflow whose request trigger carries a paths filter, so it proves no merge and counts no target",
570                        workflow.path
571                    ),
572                )),
573                Credit::Unreadable(what) => gate.failures.push(InvariantFailure::new(
574                    "dist-build-unreadable",
575                    &workflow.path,
576                    format!("job {id} runs dist build with {what}, which the judgment cannot resolve"),
577                    "write the dist build step in the shape rk binding rust gives, or name its targets literally",
578                )),
579            }
580        }
581    }
582    for triple in targets.iter().filter(|triple| !built.contains(*triple)) {
583        gate.failures.push(InvariantFailure::new(
584            "dist-target-unbuilt",
585            "dist-workspace.toml",
586            format!(
587                "no job the gate needs builds {triple} with dist build before a merge, so a target the code cannot build fails only after the tag exists"
588            ),
589            "add the dist-plan and dist-build jobs of rk binding rust to the gate's needs, or drop the target from dist-workspace.toml",
590        ));
591    }
592    gate
593}
594
595/// The targets `dist-workspace.toml` lists, or `None` where nothing is
596/// there to judge.
597fn dist_targets(target: &Utf8Path) -> Option<Vec<String>> {
598    let text = std::fs::read_to_string(target.join("dist-workspace.toml")).ok()?;
599    let table: toml::Table = text.parse().ok()?;
600    let targets = table
601        .get("dist")
602        .and_then(toml::Value::as_table)
603        .and_then(|dist| dist.get("targets"))
604        .and_then(toml::Value::as_array)?;
605    let targets: Vec<String> = targets
606        .iter()
607        .filter_map(toml::Value::as_str)
608        .map(str::to_owned)
609        .collect();
610    (!targets.is_empty()).then_some(targets)
611}
612
613/// Every workflow under `.github/workflows/` but the generated one, in
614/// name order, each parsed. A file that does not parse is skipped: the
615/// forge cannot run it either.
616fn gated_workflows(target: &Utf8Path) -> Vec<GatedWorkflow> {
617    let Ok(entries) = std::fs::read_dir(target.join(WORKFLOW_DIR)) else {
618        return Vec::new();
619    };
620    let mut names: Vec<String> = entries
621        .filter_map(Result::ok)
622        .filter_map(|entry| entry.file_name().into_string().ok())
623        .filter(|name| {
624            std::path::Path::new(name)
625                .extension()
626                .is_some_and(|extension| extension == "yml" || extension == "yaml")
627                && format!("{WORKFLOW_DIR}/{name}") != GENERATED_WORKFLOW
628        })
629        .collect();
630    names.sort();
631    names
632        .into_iter()
633        .filter_map(|name| {
634            let path = format!("{WORKFLOW_DIR}/{name}");
635            let text = std::fs::read_to_string(target.join(&path)).ok()?;
636            let value: yaml_serde::Value = yaml_serde::from_str(&text).ok()?;
637            let jobs = value
638                .get("jobs")
639                .and_then(yaml_serde::Value::as_mapping)
640                .map(|jobs| {
641                    jobs.iter()
642                        .filter_map(|(id, job)| Some((id.as_str()?.to_owned(), job.clone())))
643                        .collect()
644                })
645                .unwrap_or_default();
646            let (on_requests, filtered) = request_trigger(&value);
647            Some(GatedWorkflow {
648                path,
649                on_requests,
650                filtered,
651                env: env_of(value.get("env")),
652                jobs,
653            })
654        })
655        .collect()
656}
657
658/// Whether a workflow's trigger names `pull_request` or `merge_group`,
659/// and whether that trigger carries a path filter. YAML 1.1 reads an
660/// unquoted `on` as `true`, so both keys are read.
661fn request_trigger(workflow: &yaml_serde::Value) -> (bool, bool) {
662    let trigger = workflow
663        .get("on")
664        .or_else(|| workflow.get(yaml_serde::Value::Bool(true)));
665    let request = |name: &str| matches!(name, "pull_request" | "merge_group");
666    match trigger {
667        Some(yaml_serde::Value::String(name)) => (request(name), false),
668        Some(yaml_serde::Value::Sequence(names)) => (
669            names
670                .iter()
671                .filter_map(yaml_serde::Value::as_str)
672                .any(request),
673            false,
674        ),
675        Some(yaml_serde::Value::Mapping(events)) => {
676            let requests: Vec<&yaml_serde::Value> = events
677                .iter()
678                .filter(|(name, _)| name.as_str().is_some_and(request))
679                .map(|(_, config)| config)
680                .collect();
681            let filtered = requests.iter().any(|config| {
682                config.get("paths").is_some() || config.get("paths-ignore").is_some()
683            });
684            (!requests.is_empty(), filtered)
685        }
686        _ => (false, false),
687    }
688}
689
690/// The jobs the gate judges, as `(workflow index, job id)`, or `None`
691/// where the gate cannot prove any target and a failure says why.
692fn judged_jobs(
693    workflows: &[GatedWorkflow],
694    required_check: &str,
695    gate: &mut DistGate,
696) -> Option<Vec<(usize, String)>> {
697    if required_check.is_empty() {
698        gate.limitations.push((
699            GATE_UNNAMED,
700            "setup.required_check names no gate, so every job of every pull-request workflow is judged".to_owned(),
701        ));
702        return Some(
703            workflows
704                .iter()
705                .enumerate()
706                .filter(|(_, workflow)| workflow.on_requests)
707                .flat_map(|(index, workflow)| {
708                    workflow.jobs.iter().map(move |(id, _)| (index, id.clone()))
709                })
710                .collect(),
711        );
712    }
713    let remediation =
714        "name the gate's job in setup.required_check, and run its workflow on pull_request";
715    // A check context is a job's name, or its id where it has none.
716    let matches: Vec<(usize, &str)> = workflows
717        .iter()
718        .enumerate()
719        .flat_map(|(index, workflow)| {
720            workflow.jobs.iter().filter_map(move |(id, job)| {
721                let context = job
722                    .get("name")
723                    .and_then(yaml_serde::Value::as_str)
724                    .unwrap_or(id);
725                (context == required_check).then_some((index, id.as_str()))
726            })
727        })
728        .collect();
729    let [(index, gate_id)] = matches.as_slice() else {
730        gate.failures.push(InvariantFailure::new(
731            "gate-unresolved",
732            "setup.required_check",
733            format!(
734                "setup.required_check is \"{required_check}\", and {} jobs of the workflows carry that check context, where exactly one must",
735                matches.len()
736            ),
737            remediation,
738        ));
739        return None;
740    };
741    let workflow = &workflows[*index];
742    if !workflow.on_requests {
743        gate.failures.push(InvariantFailure::new(
744            "gate-not-on-requests",
745            &workflow.path,
746            format!(
747                "the gate job {gate_id} runs in a workflow that runs on neither pull_request nor merge_group, so its jobs prove nothing before a merge"
748            ),
749            remediation,
750        ));
751        return None;
752    }
753    let mut judged = vec![(*index, (*gate_id).to_owned())];
754    let mut next = 0;
755    while next < judged.len() {
756        let (_, id) = judged[next].clone();
757        next += 1;
758        for need in job_of(workflow, &id).map(needs_of).unwrap_or_default() {
759            if !judged.iter().any(|(_, seen)| *seen == need) {
760                judged.push((*index, need));
761            }
762        }
763    }
764    Some(judged)
765}
766
767fn job_of<'w>(workflow: &'w GatedWorkflow, id: &str) -> Option<&'w yaml_serde::Value> {
768    workflow
769        .jobs
770        .iter()
771        .find(|(job_id, _)| job_id == id)
772        .map(|(_, job)| job)
773}
774
775/// A job's `needs`, a string or a list.
776fn needs_of(job: &yaml_serde::Value) -> Vec<String> {
777    match job.get("needs") {
778        Some(yaml_serde::Value::String(one)) => vec![one.clone()],
779        Some(yaml_serde::Value::Sequence(many)) => many
780            .iter()
781            .filter_map(yaml_serde::Value::as_str)
782            .map(str::to_owned)
783            .collect(),
784        _ => Vec::new(),
785    }
786}
787
788fn steps_of(job: &yaml_serde::Value) -> &[yaml_serde::Value] {
789    job.get("steps")
790        .and_then(yaml_serde::Value::as_sequence)
791        .map_or(&[], Vec::as_slice)
792}
793
794/// A string-valued `env` mapping.
795fn env_of(env: Option<&yaml_serde::Value>) -> std::collections::BTreeMap<String, String> {
796    env.and_then(yaml_serde::Value::as_mapping)
797        .map(|env| {
798            env.iter()
799                .filter_map(|(name, value)| {
800                    let value = match value {
801                        yaml_serde::Value::String(text) => text.clone(),
802                        yaml_serde::Value::Bool(flag) => flag.to_string(),
803                        yaml_serde::Value::Number(number) => number.to_string(),
804                        _ => return None,
805                    };
806                    Some((name.as_str()?.to_owned(), value))
807                })
808                .collect()
809        })
810        .unwrap_or_default()
811}
812
813/// An expression with every space inside `${{ }}` removed, so one token
814/// stands for one expression.
815fn squeeze(text: &str) -> String {
816    let mut out = String::with_capacity(text.len());
817    let mut rest = text;
818    while let Some(start) = rest.find("${{") {
819        out.push_str(&rest[..start]);
820        let Some(end) = rest[start..].find("}}") else {
821            out.push_str(&rest[start..]);
822            return out;
823        };
824        out.extend(
825            rest[start..start + end + 2]
826                .chars()
827                .filter(|c| !c.is_whitespace()),
828        );
829        rest = &rest[start + end + 2..];
830    }
831    out.push_str(rest);
832    out
833}
834
835/// The text with each `$NAME` and `${NAME}` of `env` replaced by its value.
836fn expand(text: &str, env: &std::collections::BTreeMap<String, String>) -> String {
837    let mut out = text.to_owned();
838    for (name, value) in env {
839        out = out.replace(&format!("${{{name}}}"), value);
840        let marker = format!("${name}");
841        let mut expanded = String::with_capacity(out.len());
842        let mut rest = out.as_str();
843        while let Some(at) = rest.find(&marker) {
844            let after = &rest[at + marker.len()..];
845            expanded.push_str(&rest[..at]);
846            if after
847                .chars()
848                .next()
849                .is_some_and(|c| c.is_ascii_alphanumeric() || c == '_')
850            {
851                expanded.push_str(&marker);
852            } else {
853                expanded.push_str(value);
854            }
855            rest = after;
856        }
857        expanded.push_str(rest);
858        out = expanded;
859    }
860    out
861}
862
863/// One `dist` invocation a step runs: its arguments, and the file its
864/// standard output is redirected to, where it is.
865struct Invocation {
866    /// The arguments after the subcommand, unquoted.
867    arguments: Vec<String>,
868    /// The target of a `>` redirection.
869    stdout: Option<String>,
870}
871
872/// How a shell word was quoted: whether any part of it sat inside double
873/// quotes, and whether any part sat inside single quotes.
874#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
875struct Quoting {
876    /// A part of the word was double-quoted.
877    double: bool,
878    /// A part of the word was single-quoted.
879    single: bool,
880}
881
882impl Quoting {
883    const fn any(self) -> bool {
884        self.double || self.single
885    }
886}
887
888/// The words of each shell command segment of one line, each with how it
889/// was quoted, read up to the first unquoted comment.
890fn segments_of(line: &str) -> Vec<Vec<(String, Quoting)>> {
891    let mut segments = vec![Vec::new()];
892    let mut word = String::new();
893    let mut quoting = Quoting::default();
894    let mut in_word = false;
895    let mut chars = line.chars().peekable();
896    let mut quote: Option<char> = None;
897    let finish = |segments: &mut Vec<Vec<(String, Quoting)>>,
898                  word: &mut String,
899                  quoting: &mut Quoting,
900                  in_word: &mut bool| {
901        if *in_word && let Some(segment) = segments.last_mut() {
902            segment.push((std::mem::take(word), *quoting));
903        }
904        *quoting = Quoting::default();
905        *in_word = false;
906    };
907    while let Some(c) = chars.next() {
908        if let Some(open) = quote {
909            if c == open {
910                quote = None;
911            } else {
912                word.push(c);
913            }
914            continue;
915        }
916        match c {
917            '\u{22}' => {
918                quote = Some(c);
919                quoting.double = true;
920                in_word = true;
921            }
922            '\u{27}' => {
923                quote = Some(c);
924                quoting.single = true;
925                in_word = true;
926            }
927            '#' if !in_word => break,
928            ';' | '|' | '&' => {
929                finish(&mut segments, &mut word, &mut quoting, &mut in_word);
930                while chars.peek().is_some_and(|next| matches!(next, '|' | '&')) {
931                    chars.next();
932                }
933                segments.push(Vec::new());
934            }
935            c if c.is_whitespace() => {
936                finish(&mut segments, &mut word, &mut quoting, &mut in_word);
937            }
938            c => {
939                word.push(c);
940                in_word = true;
941            }
942        }
943    }
944    finish(&mut segments, &mut word, &mut quoting, &mut in_word);
945    segments
946}
947
948/// Whether the words before `dist` are a wrapper the binding names: none,
949/// `nix develop` with any flake reference or flag before `--command`,
950/// `mise exec --`, or `devbox run --`, every word unquoted.
951fn wrapper_supported(prefix: &[(String, Quoting)], recorded: &[&str]) -> bool {
952    if prefix.iter().any(|(_, quoting)| quoting.any()) {
953        return false;
954    }
955    let words: Vec<&str> = prefix.iter().map(|(word, _)| word.as_str()).collect();
956    if !recorded.is_empty() && words == recorded {
957        return true;
958    }
959    matches!(
960        words.as_slice(),
961        [] | ["mise", "exec", "--"] | ["devbox", "run", "--"] | ["nix", "develop", .., "--command"]
962    )
963}
964
965/// Every `dist <command>` a run executes, in the grammar the binding
966/// writes: the subcommand at the start of a command segment, or after a
967/// wrapper the binding names or the target's recorded run prefix. Text inside quotes or after a comment is no
968/// command. An unquoted `dist <command>` after any other words is
969/// unreadable rather than credited.
970fn invocations(run: &str, command: &str, recorded: &[&str]) -> Result<Vec<Invocation>, String> {
971    let mut found = Vec::new();
972    for line in run.lines() {
973        for words in segments_of(&squeeze(line)) {
974            let Some(at) = words.windows(2).position(|pair| {
975                !pair[0].1.any() && !pair[1].1.any() && pair[0].0 == "dist" && pair[1].0 == command
976            }) else {
977                continue;
978            };
979            if !wrapper_supported(&words[..at], recorded) {
980                let prefix: Vec<&str> = words[..at].iter().map(|(word, _)| word.as_str()).collect();
981                return Err(format!(
982                    "dist {command} after {}, which is no wrapper the binding names",
983                    prefix.join(" ")
984                ));
985            }
986            let mut arguments = Vec::new();
987            let mut stdout = None;
988            let mut rest = words[at + 2..].iter();
989            while let Some((word, _)) = rest.next() {
990                if word == ">" {
991                    stdout = rest.next().map(|(file, _)| file.clone());
992                } else if let Some(file) = word.strip_prefix('>') {
993                    stdout = Some(file.to_owned());
994                } else if word.starts_with('<') || word.starts_with("2>") {
995                    rest.next();
996                } else {
997                    arguments.push(word.clone());
998                }
999            }
1000            found.push(Invocation { arguments, stdout });
1001        }
1002    }
1003    Ok(found)
1004}
1005
1006/// Whether a run writes output `name` as `jq -c . <file>`, in the one
1007/// executable shape the binding writes.
1008///
1009/// The shape is `echo`, the export word `<name>=$(jq -c . <file>)`, `>>`,
1010/// and `$GITHUB_OUTPUT`. The export word must be double-quoted, the
1011/// quoting inside which the command substitution runs, and never
1012/// single-quoted, which writes the literal text. The destination is never
1013/// single-quoted either, which would name a file `$GITHUB_OUTPUT`. A
1014/// comment is no export.
1015fn exports_plan(run: &str, name: &str, file: &str) -> bool {
1016    let wanted = format!("{name}=$(jq -c . {file})");
1017    run.lines().any(|line| {
1018        segments_of(line)
1019            .iter()
1020            .any(|words| match words.as_slice() {
1021                [
1022                    (echo, echo_quoting),
1023                    (export, export_quoting),
1024                    (append, append_quoting),
1025                    (sink, sink_quoting),
1026                ] => {
1027                    echo == "echo"
1028                        && !echo_quoting.any()
1029                        && export_quoting.double
1030                        && !export_quoting.single
1031                        && export.replace("'.'", ".") == wanted
1032                        && append == ">>"
1033                        && !append_quoting.any()
1034                        && !sink_quoting.single
1035                        && (sink == "$GITHUB_OUTPUT" || sink == "${GITHUB_OUTPUT}")
1036                }
1037                _ => false,
1038            })
1039    })
1040}
1041
1042/// The values of every `--target` argument, in both spellings.
1043fn target_arguments(arguments: &[String]) -> Vec<String> {
1044    flag_values(arguments, "--target", None)
1045}
1046
1047/// The values of a long flag, as `--flag=value` or `--flag value`, and of
1048/// its short form `-x value` or `-xvalue` where it has one.
1049fn flag_values(arguments: &[String], long: &str, short: Option<&str>) -> Vec<String> {
1050    let mut found = Vec::new();
1051    let mut iter = arguments.iter();
1052    while let Some(argument) = iter.next() {
1053        if let Some(value) = argument.strip_prefix(&format!("{long}=")) {
1054            found.push(value.to_owned());
1055        } else if argument == long || short.is_some_and(|short| argument == short) {
1056            if let Some(value) = iter.next() {
1057                found.push(value.clone());
1058            }
1059        } else if let Some(value) = short.and_then(|short| argument.strip_prefix(short)) {
1060            found.push(value.to_owned());
1061        }
1062    }
1063    found
1064}
1065
1066/// The literal triples one `--target` value names: a triple, or a matrix
1067/// key whose list is literal in a matrix no `include` or `exclude`
1068/// reshapes.
1069fn triples_of(
1070    value: &str,
1071    job: &yaml_serde::Value,
1072) -> Result<std::collections::BTreeSet<String>, String> {
1073    if let Some(key) = value
1074        .strip_prefix("${{matrix.")
1075        .and_then(|rest| rest.strip_suffix("}}"))
1076    {
1077        let matrix = job
1078            .get("strategy")
1079            .and_then(|strategy| strategy.get("matrix"));
1080        if matrix.is_some_and(|matrix| {
1081            matrix.get("include").is_some() || matrix.get("exclude").is_some()
1082        }) {
1083            return Err(format!(
1084                "--target from matrix.{key} in a matrix that include or exclude reshapes"
1085            ));
1086        }
1087        let list = matrix
1088            .and_then(|matrix| matrix.get(key))
1089            .and_then(yaml_serde::Value::as_sequence)
1090            .ok_or_else(|| format!("--target from matrix.{key}, which is no literal list"))?;
1091        return list
1092            .iter()
1093            .map(|item| {
1094                item.as_str()
1095                    .map(str::to_owned)
1096                    .ok_or_else(|| format!("--target from matrix.{key}, which holds a non-string"))
1097            })
1098            .collect();
1099    }
1100    if value.contains('$') {
1101        return Err(format!("--target {value}"));
1102    }
1103    Ok(std::iter::once(value.to_owned()).collect())
1104}
1105
1106/// Whether the artifact mode builds the target-specific artifacts: `local`
1107/// and `all` do, `global` and `lies` do not, and no mode at all is the
1108/// fuzzy `host` mode, which the judgment cannot read.
1109fn builds_local_artifacts(arguments: &[String]) -> Result<bool, String> {
1110    let modes = flag_values(arguments, "--artifacts", Some("-a"));
1111    match modes.as_slice() {
1112        [] => Err("no --artifacts, so dist builds in its host mode".to_owned()),
1113        [mode] if mode == "local" || mode == "all" => Ok(true),
1114        [mode] if mode == "global" || mode == "lies" => Ok(false),
1115        _ => Err(format!("--artifacts {}", modes.join(" "))),
1116    }
1117}
1118
1119/// What one `dist build` step of a judged job proves.
1120fn credit(
1121    workflow: &GatedWorkflow,
1122    job: &yaml_serde::Value,
1123    step: &yaml_serde::Value,
1124    run: &str,
1125    peers: &[&str],
1126    targets: &[String],
1127    recorded: &[&str],
1128) -> Credit {
1129    if job.get("if").is_some() || step.get("if").is_some() {
1130        return Credit::Conditional;
1131    }
1132    if workflow.filtered {
1133        return Credit::Filtered;
1134    }
1135    let mut env = workflow.env.clone();
1136    env.extend(env_of(job.get("env")));
1137    env.extend(env_of(step.get("env")));
1138    let builds = match invocations(&expand(run, &env), "build", recorded) {
1139        Ok(builds) => builds,
1140        Err(what) => return Credit::Unreadable(what),
1141    };
1142    let mut triples = std::collections::BTreeSet::new();
1143    for build in builds {
1144        let arguments = &build.arguments;
1145        let named = target_arguments(arguments);
1146        // A fixed target proves its own triple, whatever matrix runs it,
1147        // and only where it builds the target-specific artifacts.
1148        if !named.is_empty() {
1149            match builds_local_artifacts(arguments) {
1150                Ok(true) => {}
1151                Ok(false) => continue,
1152                Err(what) => return Credit::Unreadable(what),
1153            }
1154            for value in named {
1155                match triples_of(&value, job) {
1156                    Ok(found) => triples.extend(found),
1157                    Err(what) => return Credit::Unreadable(what),
1158                }
1159            }
1160            continue;
1161        }
1162        if arguments
1163            .iter()
1164            .any(|argument| argument == "${{matrix.dist_args}}")
1165        {
1166            // The plan's own dist_args carry --artifacts=local.
1167            if !flag_values(arguments, "--artifacts", Some("-a")).is_empty() {
1168                return Credit::Unreadable(
1169                    "--artifacts beside matrix.dist_args, which already carries it".to_owned(),
1170                );
1171            }
1172            match planned_triples(workflow, job, peers, recorded) {
1173                Ok(PlanCredit::Every) => triples.extend(targets.iter().cloned()),
1174                Ok(PlanCredit::Named(found)) => triples.extend(found),
1175                Err(what) => return Credit::Unreadable(what),
1176            }
1177        }
1178    }
1179    Credit::Targets(triples)
1180}
1181
1182/// The triples the `artifacts_matrix` of a judged job's `dist plan`
1183/// covers: every target where the plan is unnarrowed, the named ones
1184/// where `--target` narrows it. The output the matrix reads must come
1185/// from that plan, in the pattern the binding writes: the plan into a
1186/// file, and `jq -c . <file>` into the output.
1187fn planned_triples(
1188    workflow: &GatedWorkflow,
1189    job: &yaml_serde::Value,
1190    peers: &[&str],
1191    recorded: &[&str],
1192) -> Result<PlanCredit, String> {
1193    let matrix = job
1194        .get("strategy")
1195        .and_then(|strategy| strategy.get("matrix"))
1196        .and_then(yaml_serde::Value::as_str)
1197        .map(squeeze)
1198        .ok_or_else(|| "matrix.dist_args from a matrix that is no expression".to_owned())?;
1199    let inner = matrix
1200        .strip_prefix("${{fromJson(needs.")
1201        .or_else(|| matrix.strip_prefix("${{fromJSON(needs."))
1202        .and_then(|rest| rest.strip_suffix(").ci.github.artifacts_matrix}}"))
1203        .ok_or_else(|| format!("matrix.dist_args from the matrix {matrix}"))?;
1204    let (planner, output) = inner
1205        .split_once(".outputs.")
1206        .ok_or_else(|| format!("matrix.dist_args from the matrix {matrix}"))?;
1207    if !peers.contains(&planner) {
1208        return Err(format!(
1209            "matrix.dist_args from job {planner}, which the gate does not need"
1210        ));
1211    }
1212    let planner_job =
1213        job_of(workflow, planner).ok_or_else(|| format!("matrix.dist_args from job {planner}"))?;
1214    let source = planner_job
1215        .get("outputs")
1216        .and_then(|outputs| outputs.get(output))
1217        .and_then(yaml_serde::Value::as_str)
1218        .map(squeeze)
1219        .ok_or_else(|| format!("matrix.dist_args from {planner}.outputs.{output}"))?;
1220    let (step_id, written) = source
1221        .strip_prefix("${{steps.")
1222        .and_then(|rest| rest.strip_suffix("}}"))
1223        .and_then(|rest| rest.split_once(".outputs."))
1224        .ok_or_else(|| format!("matrix.dist_args from {planner}.outputs.{output} = {source}"))?;
1225    let plan = steps_of(planner_job)
1226        .iter()
1227        .find(|step| step.get("id").and_then(yaml_serde::Value::as_str) == Some(step_id))
1228        .and_then(|step| step.get("run"))
1229        .and_then(yaml_serde::Value::as_str)
1230        .ok_or_else(|| format!("matrix.dist_args from step {step_id} of {planner}"))?;
1231    let plans = invocations(plan, "plan", recorded)?;
1232    let [invocation] = plans.as_slice() else {
1233        return Err(format!(
1234            "matrix.dist_args from step {step_id} of {planner}, which runs dist plan {} times",
1235            plans.len()
1236        ));
1237    };
1238    let file = invocation.stdout.as_deref().ok_or_else(|| {
1239        format!("matrix.dist_args from step {step_id} of {planner}, whose dist plan writes no file")
1240    })?;
1241    // The output carries that file and nothing else.
1242    if !exports_plan(plan, written, file) {
1243        return Err(format!(
1244            "matrix.dist_args from {planner}.outputs.{output}, which carries no jq -c . {file} as {written}"
1245        ));
1246    }
1247    let mut json = false;
1248    let mut narrowed = std::collections::BTreeSet::new();
1249    let mut iter = invocation.arguments.iter();
1250    while let Some(argument) = iter.next() {
1251        match argument.as_str() {
1252            "--output-format=json" => json = true,
1253            "--output-format" if iter.next().is_some_and(|value| value == "json") => json = true,
1254            "--target" => match iter.next() {
1255                Some(value) if !value.contains('$') => {
1256                    narrowed.insert(value.clone());
1257                }
1258                _ => return Err("dist plan --target with no literal value".to_owned()),
1259            },
1260            other => match other.strip_prefix("--target=") {
1261                Some(value) if !value.contains('$') => {
1262                    narrowed.insert(value.to_owned());
1263                }
1264                _ => return Err(format!("dist plan {other}")),
1265            },
1266        }
1267    }
1268    if !json {
1269        return Err("dist plan with no --output-format=json".to_owned());
1270    }
1271    Ok(if narrowed.is_empty() {
1272        PlanCredit::Every
1273    } else {
1274        PlanCredit::Named(narrowed)
1275    })
1276}
1277
1278/// The root manifest configuration GitHub CI builds with. Incomplete
1279/// inputs are silent: the landing record reports a missing landed
1280/// configuration, while target-owned manifests are outside that record.
1281fn dist_profile(target: &Utf8Path) -> Vec<InvariantFailure> {
1282    let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
1283        return Vec::new();
1284    };
1285    let Ok(config) = config.parse::<toml::Table>() else {
1286        return Vec::new();
1287    };
1288    let Some(ci) = config
1289        .get("dist")
1290        .and_then(toml::Value::as_table)
1291        .and_then(|dist| dist.get("ci"))
1292    else {
1293        return Vec::new();
1294    };
1295    let github = ci.as_str() == Some("github")
1296        || ci
1297            .as_array()
1298            .is_some_and(|values| values.iter().any(|value| value.as_str() == Some("github")));
1299    if !github {
1300        return Vec::new();
1301    }
1302
1303    let Ok(manifest) = std::fs::read_to_string(target.join("Cargo.toml")) else {
1304        return Vec::new();
1305    };
1306    let Ok(manifest) = manifest.parse::<toml::Table>() else {
1307        return Vec::new();
1308    };
1309    if manifest
1310        .get("profile")
1311        .and_then(toml::Value::as_table)
1312        .and_then(|profile| profile.get("dist"))
1313        .is_some_and(toml::Value::is_table)
1314    {
1315        return Vec::new();
1316    }
1317
1318    vec![InvariantFailure::new(
1319        "dist-profile-missing",
1320        "Cargo.toml",
1321        "dist-workspace.toml enables GitHub CI, whose generated workflow builds with `--profile dist`, but the root Cargo.toml defines no [profile.dist] table",
1322        concat!(
1323            "add this exact block to Cargo.toml:\n",
1324            "\n",
1325            "[profile.dist]\n",
1326            "inherits = \"release\""
1327        ),
1328    )]
1329}
1330
1331/// The pair's one generated file. Either file absent reports nothing:
1332/// `rk init` lands the configuration and writes no workflow, the operator
1333/// generates it afterwards, and a missing `dist-workspace.toml` is already
1334/// the record's own `missing` line. An absence is the generator's story.
1335fn generated_release_workflow(target: &Utf8Path) -> Vec<InvariantFailure> {
1336    let Ok(config) = std::fs::read_to_string(target.join("dist-workspace.toml")) else {
1337        return Vec::new();
1338    };
1339    let workflow = match std::fs::read_to_string(target.join(GENERATED_WORKFLOW)) {
1340        Ok(text) => text,
1341        // Absence alone is silent. A file that is there and cannot be
1342        // read as text is not an absent one, and a run that cannot read
1343        // what the forge executes has not judged it.
1344        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
1345        Err(error) => {
1346            return vec![InvariantFailure::new(
1347                "workflow-file-unreadable",
1348                GENERATED_WORKFLOW,
1349                format!("the workflow is present and cannot be read as text: {error}"),
1350                "repair the file so it reads as UTF-8 text, or regenerate it with dist generate --mode ci",
1351            )];
1352        }
1353    };
1354    workflow_matches_configuration(&config, &workflow)
1355}
1356
1357/// The judgment, over the workflow's own text: `dist` is not available to
1358/// regenerate and diff — the binding states the devshell carries none —
1359/// and the text is what the forge executes. It reads in one direction,
1360/// from the workflow to the configuration: a pin the configuration
1361/// carries and the workflow never runs is the target's own tuning of its
1362/// installers and its platforms, not drift. Every reference the workflow
1363/// executes must be immutable whatever the configuration says about it,
1364/// because a table entry naming a movable tag pins nothing.
1365fn workflow_matches_configuration(config: &str, workflow: &str) -> Vec<InvariantFailure> {
1366    let Ok(table) = config.parse::<toml::Table>() else {
1367        return Vec::new();
1368    };
1369    let dist = table.get("dist").and_then(toml::Value::as_table);
1370    let pinned = dist
1371        .and_then(|dist| dist.get("github-action-commits"))
1372        .and_then(toml::Value::as_table);
1373    let attested = dist
1374        .and_then(|dist| dist.get("github-attestations"))
1375        .and_then(toml::Value::as_bool)
1376        == Some(true);
1377
1378    let mut failures = Vec::new();
1379    let steps = workflow_uses(workflow);
1380    for step in &steps {
1381        let (action, reference) = match step {
1382            // A value this reader cannot resolve is never a pass: an
1383            // alias, an unfamiliar shape, or a form a later generator
1384            // emits would otherwise erase a real step from the judgment.
1385            Step::Opaque(value) => {
1386                failures.push(InvariantFailure::new(
1387                    "workflow-step-unreadable",
1388                    GENERATED_WORKFLOW,
1389                    format!(
1390                        "the workflow runs `uses: {value}`, which this check cannot resolve into an action and an immutable reference"
1391                    ),
1392                    "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",
1393                ));
1394                continue;
1395            }
1396            Step::Action(action, reference) => (action, reference),
1397        };
1398        // A non-string table entry pins nothing, and neither does a
1399        // string that is not itself immutable, so both fall through to
1400        // the reference check rather than blessing the step.
1401        let pin = pinned
1402            .and_then(|table| table.get(action.as_str()))
1403            .and_then(toml::Value::as_str);
1404        if let Some(commit) = pin
1405            && commit != reference
1406        {
1407            failures.push(InvariantFailure::new(
1408                "workflow-action-stale",
1409                GENERATED_WORKFLOW,
1410                format!(
1411                    "the workflow runs {action}@{reference}, where dist-workspace.toml pins {commit}"
1412                ),
1413                "regenerate the workflow from the configuration with dist generate --mode ci and commit it; a hand edit is reverted at the next generate",
1414            ));
1415            continue;
1416        }
1417        if !is_immutable(reference) {
1418            failures.push(InvariantFailure::new(
1419                "workflow-action-unpinned",
1420                GENERATED_WORKFLOW,
1421                format!(
1422                    "the workflow runs {action}@{reference}, which is no immutable reference, so the step runs whatever that name points at today"
1423                ),
1424                "pin the action at a full commit SHA in [dist.github-action-commits] in dist-workspace.toml, then regenerate with dist generate --mode ci",
1425            ));
1426        }
1427    }
1428    if attested
1429        && !steps.iter().any(|step| match step {
1430            Step::Action(action, _) => {
1431                action == "actions/attest" || action.starts_with("actions/attest-")
1432            }
1433            Step::Opaque(_) => false,
1434        })
1435    {
1436        failures.push(InvariantFailure::new(
1437            "workflow-attestation-missing",
1438            GENERATED_WORKFLOW,
1439            "dist-workspace.toml sets github-attestations = true, and the workflow carries no attest step, so what this workflow builds ships unattested",
1440            "regenerate the workflow with dist generate --mode ci and commit it, so the configured attest step is what runs",
1441        ));
1442    }
1443    // The forge executes the workflow, not the configuration: a target
1444    // that set skip and never regenerated still reports the job.
1445    // This reader asks only whether a request trigger exists at all, and
1446    // never which branches it names, so the trunk it passes is immaterial.
1447    if crate::setup::workflow_jobs::request_trigger(workflow, crate::config::TRUNK_DEFAULT)
1448        .is_some()
1449    {
1450        failures.push(InvariantFailure::new(
1451            "workflow-runs-on-a-request",
1452            GENERATED_WORKFLOW,
1453            "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",
1454            "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",
1455        ));
1456    }
1457    failures
1458}
1459
1460/// One `uses:` value the workflow carries.
1461enum Step {
1462    /// The judgeable form: an action and the reference it runs at.
1463    Action(String, String),
1464    /// A value this text reader cannot resolve into the pair — a YAML
1465    /// alias, which GitHub Actions has accepted since September 2025, or
1466    /// any shape a later generator emits. Carried rather than dropped,
1467    /// because a step nobody can read is not a step nobody runs.
1468    Opaque(String),
1469}
1470
1471/// Every distinct `uses:` value the workflow carries.
1472///
1473/// A step is read in either YAML style, block or flow. A commented line
1474/// and a value the reader can prove is same-repository —
1475/// the workspace-relative `./` form and the `$/` self-repository form,
1476/// which resolves to the running commit — are no movable external
1477/// reference. Everything else is carried, including a key whose value
1478/// sits on another line: a trailing comment and surrounding quotes are
1479/// stripped, so the readable tag kept beside a commit does not read as
1480/// part of it. One value is reported once however many jobs run it: the
1481/// operator fixes the pin, not the steps.
1482fn workflow_uses(workflow: &str) -> Vec<Step> {
1483    let mut seen: Vec<String> = Vec::new();
1484    let mut steps = Vec::new();
1485    for fragment in workflow.lines().flat_map(line_fragments) {
1486        let fragment = fragment.trim_start();
1487        // A step may or may not open its list item on the same fragment.
1488        let fragment = fragment
1489            .strip_prefix("- ")
1490            .map_or(fragment, str::trim_start);
1491        let Some(rest) = uses_value(fragment) else {
1492            continue;
1493        };
1494        let rest = before_comment(rest).trim();
1495        let rest = rest
1496            .strip_prefix('"')
1497            .and_then(|rest| rest.strip_suffix('"'))
1498            .or_else(|| {
1499                rest.strip_prefix('\'')
1500                    .and_then(|rest| rest.strip_suffix('\''))
1501            })
1502            .unwrap_or(rest);
1503        if rest.starts_with("./") || rest.starts_with("$/") {
1504            continue;
1505        }
1506        if seen.iter().any(|value| value == rest) {
1507            continue;
1508        }
1509        seen.push(rest.to_owned());
1510        steps.push(match rest.split_once('@') {
1511            Some((action, reference)) => Step::Action(action.to_owned(), reference.to_owned()),
1512            // An empty value is a scalar continued on a later line, which
1513            // this line reader does not follow, so it is unreadable
1514            // rather than absent.
1515            None if rest.is_empty() => Step::Opaque("a value carried on another line".to_owned()),
1516            None => Step::Opaque(rest.to_owned()),
1517        });
1518    }
1519    steps
1520}
1521
1522/// One line's mapping fragments.
1523///
1524/// A step is a mapping in either YAML style. A block line is one
1525/// fragment, keeping every comma its scalar carries, because a git ref
1526/// may hold one and splitting there would read a movable ref as the
1527/// immutable prefix of itself. A line whose item opens a flow collection,
1528/// or that carries a `uses` key beside a brace, is its delimiters apart —
1529/// except where it also carries a quote, which can hold a delimiter
1530/// inside a scalar: the reader does not guess there, and hands on a
1531/// stand-in that reads as a step it cannot resolve. Every other braced
1532/// line is an expression in some other key's value, never a step.
1533fn line_fragments(line: &str) -> Vec<&str> {
1534    let item = line.trim_start();
1535    let item = item.strip_prefix("- ").map_or(item, str::trim_start);
1536    let flow = item.starts_with('{')
1537        || item.starts_with('[')
1538        || ((line.contains('{') || line.contains('[')) && line.contains("uses"));
1539    if !flow {
1540        return vec![line];
1541    }
1542    if line.contains(QUOTES) {
1543        return vec![UNSPLITTABLE_FLOW_LINE];
1544    }
1545    line.split(['{', '}', '[', ']', ',']).collect()
1546}
1547
1548/// The scalar before its comment. A hash opens a YAML comment only where
1549/// a space precedes it, and a git ref may carry one, so the readable tag
1550/// kept beside a commit is stripped while `<sha>#dev` stays whole.
1551pub(crate) fn before_comment(value: &str) -> &str {
1552    let mut previous = ' ';
1553    for (index, character) in value.char_indices() {
1554        if character == '#' && (previous == ' ' || previous == '\t') {
1555            return &value[..index];
1556        }
1557        previous = character;
1558    }
1559    value
1560}
1561
1562/// The two quote characters a YAML scalar is written with, named by code
1563/// point because the artifact-body scan reads a lone quote in these
1564/// sources as a literal opening.
1565const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
1566
1567/// The stand-in a quoted flow line becomes: it names no action, so the
1568/// judgment reads it as a step it cannot resolve.
1569const UNSPLITTABLE_FLOW_LINE: &str = "uses: a flow-style step carrying a quoted value";
1570
1571/// The value of a `uses` mapping key, however the key is spelled: bare or
1572/// quoted, as YAML permits for any implicit key, and padded before its
1573/// colon. A key whose name merely starts with `uses` is not this key.
1574fn uses_value(line: &str) -> Option<&str> {
1575    let rest = line
1576        .strip_prefix("\"uses\"")
1577        .or_else(|| line.strip_prefix("'uses'"))
1578        .or_else(|| line.strip_prefix("uses"))?;
1579    rest.trim_start().strip_prefix(':')
1580}
1581
1582/// An immutable execution reference: a full commit SHA, or the image
1583/// digest a `docker://` step pins, which no tag move can swap.
1584fn is_immutable(reference: &str) -> bool {
1585    let digest = reference
1586        .strip_prefix("sha256:")
1587        .filter(|digest| digest.len() == 64);
1588    let commit = Some(reference).filter(|reference| reference.len() == 40);
1589    digest
1590        .or(commit)
1591        .is_some_and(|value| value.chars().all(|char| char.is_ascii_hexdigit()))
1592}
1593
1594#[cfg(test)]
1595mod tests {
1596    use camino::Utf8Path;
1597
1598    use super::{failures, target_failures, workflow_matches_configuration};
1599
1600    const CLEAN: &str = r#"
1601[dist]
1602pr-run-mode = "skip"
1603github-attestations = true
1604github-attestations-phase = "host"
1605github-release = "host"
1606
1607[dist.github-action-commits]
1608"actions/checkout" = "d23441a48e516b6c34aea4fa41551a30e30af803"
1609"actions/download-artifact" = "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
1610"actions/upload-artifact" = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
1611"actions/attest" = "1e69f48acb82d1966a394da916b4c1698aa569d6"
1612"#;
1613
1614    /// The correct configuration fails nothing, whatever the whitespace
1615    /// and key order, and the embedded seed is the exemplar: the
1616    /// judgment is over the effective TOML, and the seed must satisfy
1617    /// the rule it seeds.
1618    #[test]
1619    fn the_seeded_configuration_is_judged_effectively() {
1620        assert!(
1621            failures(
1622                "rust",
1623                "github",
1624                "dist-workspace.toml",
1625                CLEAN.as_bytes(),
1626                None
1627            )
1628            .is_empty()
1629        );
1630        let seed = crate::embedded::SNIPPETS
1631            .get_file("rust/github/dist-workspace.toml")
1632            .and_then(|file| file.contents_utf8())
1633            .expect("the seed is embedded");
1634        assert!(
1635            failures(
1636                "rust",
1637                "github",
1638                "dist-workspace.toml",
1639                seed.as_bytes(),
1640                None
1641            )
1642            .is_empty(),
1643            "the embedded seed satisfies the invariants it seeds"
1644        );
1645    }
1646
1647    /// A missing or stale action-commit table fails: the signer's own
1648    /// steps would otherwise run whatever a movable tag names.
1649    #[test]
1650    fn a_missing_or_stale_action_commit_table_fails() {
1651        let missing = "[dist]\ngithub-attestations=true\ngithub-attestations-phase='host'\ngithub-release='host'\n";
1652        let found = failures(
1653            "rust",
1654            "github",
1655            "dist-workspace.toml",
1656            missing.as_bytes(),
1657            None,
1658        );
1659        assert!(
1660            found
1661                .iter()
1662                .any(|failure| failure.code == "action-commit-missing"),
1663            "a missing entry falls back to the movable tag: {found:?}"
1664        );
1665        let stale = CLEAN.replace(
1666            "d23441a48e516b6c34aea4fa41551a30e30af803",
1667            "0000000000000000000000000000000000000000",
1668        );
1669        let found = failures(
1670            "rust",
1671            "github",
1672            "dist-workspace.toml",
1673            stale.as_bytes(),
1674            None,
1675        );
1676        assert!(
1677            found
1678                .iter()
1679                .any(|failure| failure.code == "action-commit-stale"
1680                    && failure.reason.contains("actions/checkout")
1681                    && failure
1682                        .reason
1683                        .contains("0000000000000000000000000000000000000000")),
1684            "a mismatch names the found and expected commits: {found:?}"
1685        );
1686        let invalid = CLEAN.replace("\"d23441a48e516b6c34aea4fa41551a30e30af803\"", "123");
1687        let found_invalid = failures(
1688            "rust",
1689            "github",
1690            "dist-workspace.toml",
1691            invalid.as_bytes(),
1692            None,
1693        );
1694        assert!(
1695            found_invalid
1696                .iter()
1697                .any(|failure| failure.code == "action-commit-invalid"
1698                    && failure.reason.contains("actions/checkout")),
1699            "a non-string value is invalid configuration, not an absent pin: {found_invalid:?}"
1700        );
1701        assert!(
1702            !found
1703                .iter()
1704                .any(|failure| failure.reason.contains("actions/attest")),
1705            "only the stale action is named: {found:?}"
1706        );
1707    }
1708
1709    /// Every degraded form fails with its own code: a commented key, a
1710    /// false value, the default phase, an unpaired release phase, a
1711    /// narrowing filter, and malformed TOML.
1712    #[test]
1713    fn each_degraded_form_fails_with_its_code() {
1714        let cases: &[(&str, &str)] = &[
1715            (
1716                "[dist]\n# github-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\n",
1717                "attestations-disabled",
1718            ),
1719            (
1720                "[dist]\ngithub-attestations = false\ngithub-attestations-phase='host'\ngithub-release='host'\n",
1721                "attestations-disabled",
1722            ),
1723            (
1724                "[dist]\ngithub-attestations = true\ngithub-release='host'\n",
1725                "attestation-phase-not-host",
1726            ),
1727            (
1728                "[dist]\ngithub-attestations = true\ngithub-attestations-phase='build-local-artifacts'\ngithub-release='host'\n",
1729                "attestation-phase-not-host",
1730            ),
1731            (
1732                "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='announce'\n",
1733                "release-phase-unpaired",
1734            ),
1735            (
1736                "[dist]\ngithub-attestations = true\ngithub-attestations-phase='host'\ngithub-release='host'\ngithub-attestations-filters=['*.tar.gz']\n",
1737                "attestation-filters-narrowed",
1738            ),
1739            ("not toml at [all", "unparsable-configuration"),
1740        ];
1741        for (text, code) in cases {
1742            let found = failures(
1743                "rust",
1744                "github",
1745                "dist-workspace.toml",
1746                text.as_bytes(),
1747                None,
1748            );
1749            assert!(
1750                found.iter().any(|failure| failure.code == *code),
1751                "{text:?} must fail with {code}, got {found:?}"
1752            );
1753        }
1754    }
1755
1756    /// Every run mode but `skip` puts a job on a pull request that no
1757    /// gate can need, and an unset key is the default `plan`, so the
1758    /// absent case carries its own reason rather than the value's.
1759    #[test]
1760    fn a_configuration_that_reports_on_a_request_fails() {
1761        let absent = CLEAN.replace("pr-run-mode = \"skip\"\n", "");
1762        let found = failures(
1763            "rust",
1764            "github",
1765            "dist-workspace.toml",
1766            absent.as_bytes(),
1767            None,
1768        );
1769        assert!(
1770            found
1771                .iter()
1772                .any(|failure| failure.code == "pr-run-mode-not-skip"
1773                    && failure.reason.contains("unset")
1774                    && failure.reason.contains("plan")),
1775            "an unset key defaults to plan and says so: {found:?}"
1776        );
1777        for other in ["plan", "upload"] {
1778            let text = CLEAN.replace("\"skip\"", &format!("\"{other}\""));
1779            let found = failures(
1780                "rust",
1781                "github",
1782                "dist-workspace.toml",
1783                text.as_bytes(),
1784                None,
1785            );
1786            assert!(
1787                found
1788                    .iter()
1789                    .any(|failure| failure.code == "pr-run-mode-not-skip"
1790                        && failure.reason.contains(other)),
1791                "{other} fails and is named: {found:?}"
1792            );
1793        }
1794        // A non-string value pins nothing either, and falls to the same
1795        // code rather than passing on a shape the reader cannot use.
1796        let non_string = CLEAN.replace("\"skip\"", "3");
1797        assert!(
1798            failures(
1799                "rust",
1800                "github",
1801                "dist-workspace.toml",
1802                non_string.as_bytes(),
1803                None,
1804            )
1805            .iter()
1806            .any(|failure| failure.code == "pr-run-mode-not-skip"),
1807            "a non-string run mode is not skip"
1808        );
1809        assert!(
1810            !failures(
1811                "rust",
1812                "github",
1813                "dist-workspace.toml",
1814                CLEAN.as_bytes(),
1815                None
1816            )
1817            .iter()
1818            .any(|failure| failure.code == "pr-run-mode-not-skip"),
1819            "skip fails nothing"
1820        );
1821    }
1822
1823    /// The forge executes the workflow, not the configuration: a target
1824    /// that set `skip` and never regenerated still reports the job, in
1825    /// whichever form of `on` the generator left behind.
1826    #[test]
1827    fn a_generated_workflow_that_triggers_on_a_request_fails() {
1828        let attest = "      - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1829        for trigger in [
1830            "on:\n  pull_request:\n",
1831            "on: [push, pull_request]\n",
1832            "on: pull_request_target\n",
1833        ] {
1834            let workflow = format!("{trigger}jobs:\n  plan:\n    steps:\n{attest}");
1835            assert!(
1836                workflow_matches_configuration(CLEAN, &workflow)
1837                    .iter()
1838                    .any(|failure| failure.code == "workflow-runs-on-a-request"
1839                        && failure.destination == super::GENERATED_WORKFLOW),
1840                "{trigger:?} reports a check no gate can need"
1841            );
1842        }
1843        let tag_only =
1844            format!("on:\n  push:\n    tags:\n      - '**'\njobs:\n  plan:\n    steps:\n{attest}");
1845        assert!(
1846            workflow_matches_configuration(CLEAN, &tag_only).is_empty(),
1847            "a tag-only workflow reports nothing on a request"
1848        );
1849    }
1850
1851    /// A workflow generated from the clean configuration fails nothing.
1852    /// Both indentations cargo-dist emits parse, a commented line is no
1853    /// step, and a readable tag kept beside a commit is not the commit.
1854    #[test]
1855    fn the_generated_workflow_at_the_configured_commits_fails_nothing() {
1856        let workflow = "\
1857jobs:
1858  plan:
1859    steps:
1860      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
1861      # - uses: actions/checkout@v4
1862      - name: Upload
1863        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
1864      - name: Attest
1865        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v3
1866";
1867        let found = workflow_matches_configuration(CLEAN, workflow);
1868        assert!(
1869            found.is_empty(),
1870            "the generated workflow is clean: {found:?}"
1871        );
1872    }
1873
1874    /// A workflow left behind by a configuration change fails, whether
1875    /// the reference it kept is a movable tag or a superseded commit: the
1876    /// forge executes the workflow, never the configuration.
1877    #[test]
1878    fn a_workflow_left_at_a_movable_tag_fails() {
1879        for stale in ["v4", "0000000000000000000000000000000000000000"] {
1880            let workflow = format!(
1881                "steps:\n  - uses: actions/checkout@{stale}\n  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
1882            );
1883            let found = workflow_matches_configuration(CLEAN, &workflow);
1884            assert!(
1885                found
1886                    .iter()
1887                    .any(|failure| failure.code == "workflow-action-stale"
1888                        && failure.destination == ".github/workflows/release.yml"
1889                        && failure.reason.contains("actions/checkout")
1890                        && failure.reason.contains(stale)
1891                        && failure
1892                            .reason
1893                            .contains("d23441a48e516b6c34aea4fa41551a30e30af803")),
1894                "{stale} names both sides of the disagreement: {found:?}"
1895            );
1896        }
1897        let twice = "steps:\n  - uses: actions/checkout@v4\n  - uses: actions/checkout@v4\n  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1898        assert_eq!(
1899            workflow_matches_configuration(CLEAN, twice).len(),
1900            1,
1901            "one reference is one failure, however many jobs run it"
1902        );
1903    }
1904
1905    /// A configured attestation the workflow does not carry fails: the
1906    /// configuration is the only place the operator reads, and the
1907    /// release ships unattested. The build-provenance variant satisfies
1908    /// it, so a cargo-dist version that renames the step is no failure.
1909    #[test]
1910    fn a_configured_attestation_with_no_attest_step_fails() {
1911        let bare = "steps:\n  - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803\n";
1912        let found = workflow_matches_configuration(CLEAN, bare);
1913        assert!(
1914            found
1915                .iter()
1916                .any(|failure| failure.code == "workflow-attestation-missing"),
1917            "an unattested workflow fails: {found:?}"
1918        );
1919        assert!(
1920            !found
1921                .iter()
1922                .any(|failure| failure.code.starts_with("workflow-action-")),
1923            "the pinned step itself is clean: {found:?}"
1924        );
1925        let variant = format!(
1926            "{bare}  - uses: actions/attest-build-provenance@1e69f48acb82d1966a394da916b4c1698aa569d6\n"
1927        );
1928        assert!(
1929            workflow_matches_configuration(CLEAN, &variant).is_empty(),
1930            "the build-provenance variant is an attest step"
1931        );
1932    }
1933
1934    /// A reference is judged for itself: an action the configuration
1935    /// pins with a movable tag, or with a non-string value, fails just as
1936    /// one the configuration never names, because a table entry naming a
1937    /// tag pins nothing. A pin the workflow never runs fails nothing:
1938    /// which actions cargo-dist emits follows the target's own installers
1939    /// and platforms, which a seeded file leaves the target to tune.
1940    #[test]
1941    fn a_movable_reference_fails_whatever_the_configuration_says() {
1942        let attest = "  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1943        let movable = format!("steps:\n  - uses: third/party@v1\n{attest}");
1944        let found = workflow_matches_configuration(CLEAN, &movable);
1945        assert!(
1946            found
1947                .iter()
1948                .any(|failure| failure.code == "workflow-action-unpinned"
1949                    && failure.reason.contains("third/party")),
1950            "an action the configuration never names fails: {found:?}"
1951        );
1952        // The configuration agreeing with the movable tag is the hole
1953        // this case exists to hold shut.
1954        let agreed = CLEAN.replace(
1955            "[dist.github-action-commits]",
1956            "[dist.github-action-commits]\n\"third/party\" = \"v1\"",
1957        );
1958        let found = workflow_matches_configuration(&agreed, &movable);
1959        assert!(
1960            found
1961                .iter()
1962                .any(|failure| failure.code == "workflow-action-unpinned"
1963                    && failure.reason.contains("third/party")),
1964            "a table entry naming the same movable tag pins nothing: {found:?}"
1965        );
1966        let non_string = CLEAN.replace(
1967            "[dist.github-action-commits]",
1968            "[dist.github-action-commits]\n\"third/party\" = 1",
1969        );
1970        assert!(
1971            workflow_matches_configuration(&non_string, &movable)
1972                .iter()
1973                .any(|failure| failure.code == "workflow-action-unpinned"),
1974            "a non-string entry pins nothing either"
1975        );
1976        let pinned = format!(
1977            "steps:\n  - uses: third/party@1111111111111111111111111111111111111111\n{attest}"
1978        );
1979        assert!(
1980            workflow_matches_configuration(CLEAN, &pinned).is_empty(),
1981            "a commit-pinned action the configuration does not name is the target's own"
1982        );
1983        assert!(
1984            workflow_matches_configuration(CLEAN, &format!("steps:\n{attest}")).is_empty(),
1985            "a pin no step runs is the target's tuning, not drift"
1986        );
1987    }
1988
1989    /// Every real `uses:` shape reaches the judgment: a padded or quoted
1990    /// key, a flow mapping, a compact flow sequence, a ref carrying a
1991    /// comma or a hash. A local `./` or `$/` step is the repository's own
1992    /// file at the running commit, and a `docker://` image pinned by
1993    /// digest is immutable.
1994    #[test]
1995    fn every_real_step_shape_reaches_the_judgment() {
1996        let attest = "  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
1997        let padded = format!("steps:\n  - uses : actions/checkout@v4\n{attest}");
1998        assert!(
1999            workflow_matches_configuration(CLEAN, &padded)
2000                .iter()
2001                .any(|failure| failure.code == "workflow-action-stale"),
2002            "a padded key is the same mapping"
2003        );
2004        let quoted = format!("steps:\n  - \"uses\": actions/checkout@v4\n{attest}");
2005        assert!(
2006            workflow_matches_configuration(CLEAN, &quoted)
2007                .iter()
2008                .any(|failure| failure.code == "workflow-action-stale"),
2009            "a quoted key is the same mapping"
2010        );
2011        let flow =
2012            format!("steps:\n  - {{ uses: actions/checkout@v4, with: {{ ref: main }} }}\n{attest}");
2013        assert!(
2014            workflow_matches_configuration(CLEAN, &flow)
2015                .iter()
2016                .any(|failure| failure.code == "workflow-action-stale"),
2017            "a flow-style step is the same mapping"
2018        );
2019        // A git ref may carry a comma, so a block line is never split on
2020        // one: the immutable-looking prefix is not the reference.
2021        let comma = format!(
2022            "steps:\n  - uses: third/party@1111111111111111111111111111111111111111,dev\n{attest}"
2023        );
2024        assert!(
2025            workflow_matches_configuration(CLEAN, &comma)
2026                .iter()
2027                .any(|failure| failure.code == "workflow-action-unpinned"
2028                    && failure.reason.contains(",dev")),
2029            "the whole reference is judged, never its prefix"
2030        );
2031        // A hash opens a comment only after a space, and a git ref may
2032        // carry one, so the reference is never read as its prefix.
2033        let hashed = format!(
2034            "steps:\n  - uses: third/party@1111111111111111111111111111111111111111#dev\n{attest}"
2035        );
2036        assert!(
2037            workflow_matches_configuration(CLEAN, &hashed)
2038                .iter()
2039                .any(|failure| failure.code == "workflow-action-unpinned"
2040                    && failure.reason.contains("#dev")),
2041            "an adjacent hash is scalar content, not a comment"
2042        );
2043        // A compact single-pair mapping is a flow-sequence entry.
2044        let compact = format!("steps: [ uses: third/party@v1 ]\n{attest}");
2045        assert!(
2046            workflow_matches_configuration(CLEAN, &compact)
2047                .iter()
2048                .any(|failure| failure.code == "workflow-action-unpinned"
2049                    && failure.reason.contains("third/party")),
2050            "a compact flow sequence carries its uses key"
2051        );
2052        assert!(
2053            workflow_matches_configuration(CLEAN, &format!("steps:\n  - usesful: no\n{attest}"))
2054                .is_empty(),
2055            "a key that merely starts with uses is another key"
2056        );
2057        for same_repository in ["./.github/actions/build", "$/.github/actions/build"] {
2058            let local = format!("steps:\n  - uses: {same_repository}\n{attest}");
2059            assert!(
2060                workflow_matches_configuration(CLEAN, &local).is_empty(),
2061                "{same_repository} is the repository's own file at the running commit"
2062            );
2063        }
2064        let tagged = format!("steps:\n  - uses: docker://alpine:3.8\n{attest}");
2065        assert!(
2066            workflow_matches_configuration(CLEAN, &tagged)
2067                .iter()
2068                .any(|failure| failure.code == "workflow-step-unreadable"),
2069            "a docker image with no digest is not immutable"
2070        );
2071        let digested = format!(
2072            "steps:\n  - uses: docker://alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000\n{attest}"
2073        );
2074        assert!(
2075            workflow_matches_configuration(CLEAN, &digested).is_empty(),
2076            "a docker image pinned by digest is immutable"
2077        );
2078    }
2079
2080    /// A value the reader cannot resolve is reported rather than passed.
2081    /// GitHub Actions accepts YAML aliases, a scalar may continue on
2082    /// another line, and a quote may hold a flow delimiter the reader
2083    /// would otherwise split on: a step nobody can read is not a step
2084    /// nobody runs. A braced expression in another key's value is no step
2085    /// at all, and a generated workflow is full of them.
2086    #[test]
2087    fn a_step_the_reader_cannot_resolve_is_reported() {
2088        let attest = "  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6\n";
2089        let aliased = format!("steps:\n  - uses: *checkout\n{attest}");
2090        assert!(
2091            workflow_matches_configuration(CLEAN, &aliased)
2092                .iter()
2093                .any(|failure| failure.code == "workflow-step-unreadable"
2094                    && failure.reason.contains("*checkout")),
2095            "an alias is unreadable, never clean"
2096        );
2097        let continued = format!("steps:\n  - uses:\n      actions/checkout@v4\n{attest}");
2098        assert!(
2099            workflow_matches_configuration(CLEAN, &continued)
2100                .iter()
2101                .any(|failure| failure.code == "workflow-step-unreadable"),
2102            "a value on another line is unreadable, never clean"
2103        );
2104        let quoted_flow = format!("steps:\n  - {{ uses: \"third/party@1,dev\" }}\n{attest}");
2105        assert!(
2106            workflow_matches_configuration(CLEAN, &quoted_flow)
2107                .iter()
2108                .any(|failure| failure.code == "workflow-step-unreadable"),
2109            "a quoted flow line is not split on a guess"
2110        );
2111        let expression = format!(
2112            "jobs:\n  host:\n    if: ${{{{ fromJson(needs.plan.outputs.val).ci != null && x == 'true' }}}}\n    steps:\n{attest}"
2113        );
2114        assert!(
2115            workflow_matches_configuration(CLEAN, &expression).is_empty(),
2116            "an expression is not a step this reader cannot resolve"
2117        );
2118    }
2119
2120    /// The cross-file judgment needs both files and its own pair. Either
2121    /// one absent reports nothing: release-kit writes neither the
2122    /// workflow nor a record of it, so an absence is the generator's
2123    /// story and a fresh landing never fails on its first day.
2124    #[test]
2125    fn the_cross_file_judgment_needs_both_files() {
2126        let dir = tempfile::tempdir().expect("a scratch directory");
2127        let target = Utf8Path::from_path(dir.path()).expect("a utf-8 path");
2128        let broken = "steps:\n  - uses: actions/checkout@v4\n";
2129        assert!(
2130            target_failures("rust", "github", target).is_empty(),
2131            "an empty target"
2132        );
2133        std::fs::write(target.join("dist-workspace.toml"), CLEAN).expect("the configuration");
2134        assert!(
2135            target_failures("rust", "github", target).is_empty(),
2136            "a configuration with no generated workflow"
2137        );
2138        std::fs::create_dir_all(target.join(".github/workflows")).expect("the workflow directory");
2139        std::fs::write(target.join(".github/workflows/release.yml"), broken).expect("the workflow");
2140        assert!(
2141            !target_failures("rust", "github", target).is_empty(),
2142            "both files present, and they disagree"
2143        );
2144        for (tech, forge) in [("rust", "gitlab"), ("bash", "github")] {
2145            assert!(
2146                target_failures(tech, forge, target).is_empty(),
2147                "{tech}/{forge} generates no artifact workflow"
2148            );
2149        }
2150        // A workflow that is there and cannot be read as text is not an
2151        // absent one: only absence is silent.
2152        std::fs::write(
2153            target.join(".github/workflows/release.yml"),
2154            [0x66, 0xff, 0xfe],
2155        )
2156        .expect("the workflow");
2157        assert!(
2158            target_failures("rust", "github", target)
2159                .iter()
2160                .any(|failure| failure.code == "workflow-file-unreadable"),
2161            "a present workflow that does not read as text is reported"
2162        );
2163        std::fs::remove_file(target.join("dist-workspace.toml")).expect("the configuration");
2164        assert!(
2165            target_failures("rust", "github", target).is_empty(),
2166            "a workflow with no configuration to judge it against"
2167        );
2168    }
2169
2170    /// The key is the pair plus the destination: the same bytes under
2171    /// another pair or another destination fail nothing, so a second pair
2172    /// sharing a destination cannot silently inherit this rule.
2173    #[test]
2174    fn the_rule_is_keyed_by_pair_and_destination() {
2175        let broken = b"[dist]\ngithub-attestations = false\n";
2176        assert!(failures("rust", "gitlab", "dist-workspace.toml", broken, None).is_empty());
2177        assert!(failures("bash", "github", "dist-workspace.toml", broken, None).is_empty());
2178        assert!(failures("rust", "github", "release-plz.toml", broken, None).is_empty());
2179    }
2180}