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