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