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