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