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