1use camino::Utf8Path;
21
22use crate::landing::invariants::before_comment;
23
24#[derive(Debug, PartialEq, Eq)]
26pub enum GateReading {
27 NoRequestWorkflows,
29 Gated,
32 NoSuchJob {
34 contexts: Vec<String>,
36 },
37 UnprovenGateName {
41 job: String,
43 },
44 OpaqueNeeds {
46 workflow: String,
48 },
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Condition {
54 Absent,
56 Proven,
61 UnquotedTag(String),
65 Other(String),
68}
69
70#[derive(Debug, PartialEq, Eq)]
72pub struct GateReport {
73 pub reading: GateReading,
75 pub gate_condition: Option<Condition>,
77 pub gate_trigger: Trigger,
79 pub reporting: usize,
83 pub unreadable: Vec<String>,
85}
86
87#[derive(Debug, PartialEq, Eq)]
89struct Job {
90 id: String,
91 name: Name,
92 reusable: bool,
95 needs: Needs,
96 condition: Condition,
97}
98
99#[derive(Debug, PartialEq, Eq)]
101enum Name {
102 Id,
104 Fixed(String),
106 Unproven,
108}
109
110impl Job {
111 fn context(&self) -> Option<&str> {
113 if self.reusable {
114 return None;
115 }
116 match &self.name {
117 Name::Id => Some(&self.id),
118 Name::Fixed(name) => Some(name),
119 Name::Unproven => None,
120 }
121 }
122}
123
124#[derive(Debug, PartialEq, Eq)]
126enum Needs {
127 None,
129 Listed(Vec<String>),
131 Opaque,
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
141pub struct Trigger {
142 pub paths_filtered: bool,
144 pub misses_trunk: Option<String>,
146 pub types_filtered: Option<String>,
149}
150
151impl Trigger {
152 fn from_filters(filters: &[(String, Vec<String>)], trunk: &str) -> Self {
154 let mut trigger = Self::default();
155 for (key, items) in filters {
156 match key.as_str() {
157 "paths" | "paths-ignore" => trigger.paths_filtered = true,
158 "branches" => {
161 let negated = items.iter().any(|item| item.starts_with('!'));
162 if negated || !items.iter().any(|item| covers_trunk(item, trunk)) {
163 trigger.misses_trunk = Some(format!("branches: [{}]", items.join(", ")));
164 }
165 }
166 "branches-ignore" => {
170 if items
171 .iter()
172 .any(|item| covers_trunk(item, trunk) || is_glob(item))
173 {
174 trigger.misses_trunk =
175 Some(format!("branches-ignore: [{}]", items.join(", ")));
176 }
177 }
178 "types" => {
179 let needed = ["opened", "synchronize", "reopened"];
180 if !needed
181 .iter()
182 .all(|kind| items.iter().any(|item| item == kind))
183 {
184 trigger.types_filtered = Some(format!("types: [{}]", items.join(", ")));
185 }
186 }
187 _ => {}
188 }
189 }
190 trigger
191 }
192
193 fn merge(&mut self, other: Self) {
196 self.paths_filtered |= other.paths_filtered;
197 if self.misses_trunk.is_none() {
198 self.misses_trunk = other.misses_trunk;
199 }
200 if self.types_filtered.is_none() {
201 self.types_filtered = other.types_filtered;
202 }
203 }
204}
205
206fn covers_trunk(pattern: &str, trunk: &str) -> bool {
209 pattern == trunk || pattern == "*" || pattern == "**"
210}
211
212fn is_glob(pattern: &str) -> bool {
215 pattern.contains(['*', '?', '[', ']', '+', '!'])
216}
217
218struct Workflow {
220 name: String,
221 trigger: Trigger,
222 jobs: Vec<Job>,
223}
224
225#[must_use]
228pub fn read_gate(target: &Utf8Path, required_check: &str, trunk: &str) -> GateReport {
229 let (workflows, unreadable) = read_workflows(&target.join(".github/workflows"), trunk);
230 let mut report = GateReport {
231 reading: GateReading::NoRequestWorkflows,
232 gate_condition: None,
233 gate_trigger: Trigger::default(),
234 reporting: 0,
235 unreadable,
236 };
237 if workflows.iter().all(|workflow| workflow.jobs.is_empty()) {
238 return report;
239 }
240 judge(&mut report, &workflows, required_check);
241 report
242}
243
244fn read_workflows(dir: &Utf8Path, trunk: &str) -> (Vec<Workflow>, Vec<String>) {
248 let mut unreadable: Vec<String> = Vec::new();
249 let mut workflows: Vec<Workflow> = Vec::new();
250 match std::fs::read_dir(dir) {
251 Ok(entries) => {
252 let mut names: Vec<String> = Vec::new();
253 for entry in entries {
254 match entry {
255 Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
256 Err(_) => unreadable.push(dir.to_string()),
257 }
258 }
259 names.sort();
260 for name in names {
261 let is_workflow = std::path::Path::new(&name)
262 .extension()
263 .is_some_and(|ext| ext == "yml" || ext == "yaml");
264 if !is_workflow {
265 continue;
266 }
267 let Ok(text) = std::fs::read_to_string(dir.join(&name)) else {
268 unreadable.push(name);
269 continue;
270 };
271 if let Some(trigger) = request_trigger(&text, trunk) {
272 workflows.push(Workflow {
273 name,
274 trigger,
275 jobs: jobs(&text),
276 });
277 }
278 }
279 }
280 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
281 Err(_) => unreadable.push(dir.to_string()),
282 }
283 (workflows, unreadable)
284}
285
286fn judge(report: &mut GateReport, workflows: &[Workflow], required_check: &str) {
292 report.reporting = workflows
296 .iter()
297 .flat_map(|workflow| &workflow.jobs)
298 .filter(|job| job.context() == Some(required_check))
299 .count();
300 let Some((workflow, gate)) = workflows.iter().find_map(|workflow| {
301 workflow
302 .jobs
303 .iter()
304 .find(|job| job.context() == Some(required_check))
305 .map(|job| (workflow, job))
306 }) else {
307 let unproven = workflows
308 .iter()
309 .flat_map(|workflow| &workflow.jobs)
310 .find(|job| job.id == required_check && job.context().is_none());
311 report.reading = unproven.map_or_else(
312 || GateReading::NoSuchJob {
313 contexts: workflows
316 .iter()
317 .flat_map(|workflow| workflow.jobs.iter().filter_map(Job::context))
318 .map(str::to_owned)
319 .collect(),
320 },
321 |job| GateReading::UnprovenGateName {
322 job: job.id.clone(),
323 },
324 );
325 return;
326 };
327 report.gate_condition = Some(gate.condition.clone());
328 report.gate_trigger = workflow.trigger.clone();
329 report.reading = match &gate.needs {
333 Needs::Opaque => GateReading::OpaqueNeeds {
334 workflow: workflow.name.clone(),
335 },
336 Needs::None | Needs::Listed(_) => GateReading::Gated,
337 };
338}
339
340#[must_use]
346pub fn faults(report: &GateReport, required_check: &str, trunk: &str) -> Option<String> {
347 let mut parts: Vec<String> = Vec::new();
348 match &report.reading {
349 GateReading::Gated => {}
350 GateReading::NoRequestWorkflows => parts.push(format!(
351 "no workflow in .github/workflows runs on a pull request, so the required check {required_check} never reports and every merge hangs; name a job that runs on a pull request, or remove the required context"
352 )),
353 GateReading::NoSuchJob { contexts } => parts.push(format!(
354 "no job in .github/workflows reports the context {required_check} on a pull request, so the required check never reports and every merge hangs; the contexts that do report are [{}]",
355 contexts.join(", ")
356 )),
357 GateReading::UnprovenGateName { job } => parts.push(format!(
358 "the job {job} names itself by an expression or runs a reusable workflow, so the context it reports is not in the file and {required_check} is not proven to exist; give the job a literal name equal to the required context"
359 )),
360 GateReading::OpaqueNeeds { workflow } => parts.push(format!(
361 "the needs value of {required_check} in {workflow} is an anchor, an alias, or an expression, which this reader refuses rather than interprets; write it as a literal list of job ids"
362 )),
363 }
364 if report.reporting > 1 {
365 parts.push(format!(
366 "the context {required_check} is reported by {} jobs on a pull request, so the required check no longer stands for the gate alone: every reporter of a required name must pass, and a job outside the gate can hold or release the merge; rename all but one",
367 report.reporting
368 ));
369 }
370 match &report.gate_condition {
371 None | Some(Condition::Proven) => {}
372 Some(Condition::Absent) => parts.push(format!(
373 "the job {required_check} runs under no if condition, so a needed job that fails skips it and the forge reads a skip as success; use if: always(), or if: ${{{{ !cancelled() }}}}"
374 )),
375 Some(Condition::UnquotedTag(raw)) => parts.push(format!(
376 "the condition of {required_check} reads {raw}, and an unquoted scalar opening with ! is a YAML tag rather than text, so the workflow does not parse and the check never reports; write it as ${{{{ !cancelled() }}}} or quote it"
377 )),
378 Some(Condition::Other(expression)) => parts.push(format!(
379 "the job {required_check} runs under the condition {expression}, which this reader cannot prove holds when a needed job fails; always() or ${{{{ !cancelled() }}}} is the proven form"
380 )),
381 }
382 if report.gate_trigger.paths_filtered {
383 parts.push(format!(
384 "the pull_request trigger of the workflow carrying {required_check} filters by paths, so a request outside them never reports the check and its merge hangs"
385 ));
386 }
387 if let Some(filter) = &report.gate_trigger.misses_trunk {
388 parts.push(format!(
389 "the pull_request trigger of the workflow carrying {required_check} reads {filter}, which does not prove it runs for a request against {trunk}, so the check would never report there"
390 ));
391 }
392 if let Some(filter) = &report.gate_trigger.types_filtered {
393 parts.push(format!(
394 "the pull_request trigger of the workflow carrying {required_check} reads {filter}, which leaves out one of opened, reopened, and synchronize, so a request in that state never reports the check"
395 ));
396 }
397 if !report.unreadable.is_empty() {
398 parts.push(format!(
399 "[{}] could not be read, so no job there is judged and the context is not proven unique",
400 report.unreadable.join(", ")
401 ));
402 }
403 (!parts.is_empty()).then(|| parts.join("; "))
404}
405
406pub(crate) fn request_trigger(workflow: &str, trunk: &str) -> Option<Trigger> {
414 let mut in_on = false;
415 let mut event_indent: Option<usize> = None;
416 let mut in_request_event = false;
417 let mut filter_indent: Option<usize> = None;
418 let mut filters: Vec<(String, Vec<String>)> = Vec::new();
419 let mut found: Option<Trigger> = None;
420 let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
421 if let Some(trigger) = found {
422 trigger.merge(Trigger::from_filters(filters, trunk));
423 }
424 filters.clear();
425 };
426 for line in workflow.lines() {
427 if is_blank(line) {
428 continue;
429 }
430 let depth = indent(line);
431 if depth == 0 {
432 if in_request_event {
433 close_event(&mut filters, &mut found);
434 }
435 in_on = false;
436 in_request_event = false;
437 event_indent = None;
438 let Some((key, value)) = key_value(line) else {
439 continue;
440 };
441 if key != "on" {
442 continue;
443 }
444 if value.is_empty() {
445 in_on = true;
446 continue;
447 }
448 if list_items(value).iter().any(|item| is_request_event(item)) {
449 found.get_or_insert_with(Trigger::default);
450 }
451 continue;
452 }
453 if !in_on {
454 continue;
455 }
456 let event_depth = *event_indent.get_or_insert(depth);
457 if depth == event_depth {
458 if in_request_event {
459 close_event(&mut filters, &mut found);
460 }
461 filter_indent = None;
462 let item = line.trim_start();
463 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
464 let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
465 in_request_event = is_request_event(key);
466 if in_request_event {
467 found.get_or_insert_with(Trigger::default);
468 }
469 continue;
470 }
471 if !in_request_event || depth <= event_depth {
472 continue;
473 }
474 let filter_depth = *filter_indent.get_or_insert(depth);
475 if depth == filter_depth {
476 if let Some((key, value)) = key_value(line) {
477 let items = if value.is_empty() {
478 Vec::new()
479 } else {
480 list_items(value).into_iter().map(str::to_owned).collect()
481 };
482 filters.push((key.to_owned(), items));
483 }
484 continue;
485 }
486 if let Some(item) = line.trim_start().strip_prefix("- ")
488 && let Some((_, items)) = filters.last_mut()
489 {
490 items.push(unquote(before_comment(item).trim()).to_owned());
491 }
492 }
493 if in_request_event {
494 close_event(&mut filters, &mut found);
495 }
496 found
497}
498
499fn is_request_event(name: &str) -> bool {
500 matches!(name, "pull_request" | "pull_request_target")
501}
502
503fn jobs(workflow: &str) -> Vec<Job> {
508 let mut found: Vec<Job> = Vec::new();
509 let mut in_jobs = false;
510 let mut job_indent: Option<usize> = None;
511 let mut property_indent: Option<usize> = None;
512 let mut reading_needs_list = false;
513 for line in workflow.lines() {
514 if is_blank(line) {
515 continue;
516 }
517 let depth = indent(line);
518 if depth == 0 {
519 in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
520 job_indent = None;
521 property_indent = None;
522 reading_needs_list = false;
523 continue;
524 }
525 if !in_jobs {
526 continue;
527 }
528 let job_depth = *job_indent.get_or_insert(depth);
529 if depth == job_depth {
530 reading_needs_list = false;
531 property_indent = None;
532 if let Some((id, _)) = key_value(line) {
533 found.push(Job {
534 id: id.to_owned(),
535 name: Name::Id,
536 reusable: false,
537 needs: Needs::None,
538 condition: Condition::Absent,
539 });
540 }
541 continue;
542 }
543 if depth < job_depth {
544 continue;
545 }
546 let Some(job) = found.last_mut() else {
547 continue;
548 };
549 let property_depth = *property_indent.get_or_insert(depth);
550 if reading_needs_list
551 && depth > property_depth
552 && let Some(item) = line.trim_start().strip_prefix("- ")
553 {
554 if let Needs::Listed(ids) = &mut job.needs {
555 ids.push(unquote(before_comment(item).trim()).to_owned());
556 }
557 continue;
558 }
559 reading_needs_list = false;
560 if depth != property_depth {
561 continue;
562 }
563 let Some((key, value)) = key_value(line) else {
564 continue;
565 };
566 match key {
567 "name" => {
568 let value = scalar(before_comment(value).trim());
569 if value.contains("${{") || value.is_empty() {
572 job.name = Name::Unproven;
573 } else {
574 job.name = Name::Fixed(value);
575 }
576 }
577 "uses" => job.reusable = true,
581 "if" => job.condition = condition(value),
582 "needs" => {
583 let value = before_comment(value).trim();
584 if value.is_empty() {
585 job.needs = Needs::Listed(Vec::new());
586 reading_needs_list = true;
587 } else if value.starts_with(['|', '>', '*', '&', '$']) {
588 job.needs = Needs::Opaque;
589 } else {
590 job.needs =
591 Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
592 }
593 }
594 _ => {}
595 }
596 }
597 found
598}
599
600fn condition(value: &str) -> Condition {
616 let raw = before_comment(value).trim();
617 let value = unquote(raw);
618 let inner = value
619 .strip_prefix("${{")
620 .and_then(|rest| rest.strip_suffix("}}"))
621 .map_or(value, str::trim);
622 if raw.starts_with('!') {
623 return Condition::UnquotedTag(raw.to_owned());
624 }
625 if inner == "always()" || inner == "!cancelled()" {
626 Condition::Proven
627 } else if inner.is_empty() {
628 Condition::Other("(a value carried on another line)".to_owned())
629 } else {
630 Condition::Other(inner.to_owned())
631 }
632}
633
634fn list_items(value: &str) -> Vec<&str> {
639 let value = before_comment(value).trim();
640 let inner = value
641 .strip_prefix('[')
642 .and_then(|rest| rest.strip_suffix(']'))
643 .unwrap_or(value);
644 let mut items = Vec::new();
645 let mut quote: Option<char> = None;
646 let mut escaped = false;
647 let mut start = 0;
648 for (index, character) in inner.char_indices() {
649 if let Some(open) = quote {
650 if escaped {
653 escaped = false;
654 } else if open == QUOTES[0] && character == '\\' {
655 escaped = true;
656 } else if character == open {
657 quote = None;
658 }
659 } else if QUOTES.contains(&character) {
660 quote = Some(character);
661 } else if character == ',' {
662 items.push(&inner[start..index]);
663 start = index + 1;
664 }
665 }
666 items.push(&inner[start..]);
667 items
668 .into_iter()
669 .map(|item| unquote(item.trim()))
670 .filter(|item| !item.is_empty())
671 .collect()
672}
673
674fn key_value(line: &str) -> Option<(&str, &str)> {
677 let line = line.trim();
678 let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
679 let quote = line.chars().next()?;
680 let end = quoted.find(quote)?;
681 ("ed[..end], quoted[end + 1..].trim_start())
682 } else {
683 let end = line.find(':')?;
684 (&line[..end], &line[end..])
685 };
686 let value = rest.strip_prefix(':')?;
687 if !(value.is_empty() || value.starts_with([' ', '\t'])) {
688 return None;
689 }
690 let key = key.trim();
691 if key.is_empty() || key.contains([' ', '\t']) {
692 return None;
693 }
694 Some((key, value.trim()))
695}
696
697const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
701
702fn unquote(value: &str) -> &str {
703 value
704 .strip_prefix(QUOTES[0])
705 .and_then(|rest| rest.strip_suffix(QUOTES[0]))
706 .or_else(|| {
707 value
708 .strip_prefix('\'')
709 .and_then(|rest| rest.strip_suffix('\''))
710 })
711 .unwrap_or(value)
712}
713
714fn scalar(value: &str) -> String {
721 if value.starts_with(QUOTES[0])
722 && let Ok(decoded) = serde_json::from_str::<String>(value)
723 {
724 return decoded;
725 }
726 unquote(value).replace("''", "'")
727}
728
729fn indent(line: &str) -> usize {
730 line.len() - line.trim_start_matches(' ').len()
731}
732
733fn is_blank(line: &str) -> bool {
734 let trimmed = line.trim();
735 trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 fn report(text: &str, check: &str) -> GateReport {
743 let dir = tempfile::tempdir().expect("a tempdir");
744 let workflows = dir.path().join(".github/workflows");
745 std::fs::create_dir_all(&workflows).expect("the workflows dir");
746 std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
747 read_gate(
748 Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
749 check,
750 "master",
751 )
752 }
753
754 fn unfiltered() -> Trigger {
755 Trigger::default()
756 }
757
758 #[test]
759 fn the_trigger_is_read_in_every_on_form() {
760 assert_eq!(
761 request_trigger(
762 "on:\n push:\n pull_request:\n branches: [master]\n",
763 "master"
764 ),
765 Some(unfiltered())
766 );
767 assert_eq!(
768 request_trigger(
769 "on:\n push:\n pull_request:\n branches: [main]\n",
770 "master"
771 ),
772 Some(Trigger {
773 misses_trunk: Some("branches: [main]".to_owned()),
774 ..Trigger::default()
775 })
776 );
777 assert_eq!(
778 request_trigger(
779 "on:\n pull_request:\n branches-ignore:\n - master\n types: [opened]\n",
780 "master"
781 ),
782 Some(Trigger {
783 misses_trunk: Some("branches-ignore: [master]".to_owned()),
784 types_filtered: Some("types: [opened]".to_owned()),
785 ..Trigger::default()
786 })
787 );
788 assert_eq!(
789 request_trigger(
790 "on:\n pull_request:\n branches: ['**']\n types: [opened, synchronize, reopened]\n",
791 "master"
792 ),
793 Some(unfiltered())
794 );
795 assert_eq!(
796 request_trigger(
797 "on:\n pull_request:\n branches: ['**', '!master']\n",
798 "master"
799 ),
800 Some(Trigger {
801 misses_trunk: Some("branches: [**, !master]".to_owned()),
802 ..Trigger::default()
803 })
804 );
805 assert_eq!(
806 request_trigger(
807 "on:\n pull_request:\n branches: ['!master', '**']\n",
808 "master"
809 ),
810 Some(Trigger {
811 misses_trunk: Some("branches: [!master, **]".to_owned()),
812 ..Trigger::default()
813 })
814 );
815 assert_eq!(
816 request_trigger(
817 "on:\n pull_request:\n branches-ignore: ['mast*']\n",
818 "master"
819 ),
820 Some(Trigger {
821 misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
822 ..Trigger::default()
823 })
824 );
825 assert_eq!(
826 request_trigger(
827 "on:\n pull_request:\n branches-ignore: [dependabot]\n",
828 "master"
829 ),
830 Some(unfiltered())
831 );
832 assert_eq!(
833 request_trigger(
834 "on:\n pull_request:\n branches-ignore: ['ma[as]ter']\n",
835 "master"
836 ),
837 Some(Trigger {
838 misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
839 ..Trigger::default()
840 })
841 );
842 assert_eq!(
843 request_trigger(
844 "on:\n pull_request:\n branches: [\"release/**\", 'a,b', master]\n",
845 "master"
846 ),
847 Some(unfiltered())
848 );
849 assert_eq!(
850 request_trigger(
851 "on:\n pull_request:\n branches: [\"topic\\\",master,tail\"]\n",
852 "master"
853 ),
854 Some(Trigger {
855 misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
856 ..Trigger::default()
857 })
858 );
859 assert_eq!(
860 request_trigger("on: [push, pull_request]\n", "master"),
861 Some(unfiltered())
862 );
863 assert_eq!(
864 request_trigger("on: pull_request_target\n", "master"),
865 Some(unfiltered())
866 );
867 assert_eq!(
868 request_trigger("on:\n - push\n - pull_request\n", "master"),
869 Some(unfiltered())
870 );
871 assert_eq!(
872 request_trigger("\"on\":\n pull_request:\n", "master"),
873 Some(unfiltered())
874 );
875 assert_eq!(request_trigger("on: push\n", "master"), None);
876 assert_eq!(
877 request_trigger(
878 "on:\n push:\n workflow_dispatch:\njobs:\n pull_request:\n",
879 "master"
880 ),
881 None
882 );
883 assert_eq!(
884 request_trigger(
885 "on:\n pull_request:\n paths:\n - 'docs/**'\n push:\n",
886 "master"
887 ),
888 Some(Trigger {
889 paths_filtered: true,
890 ..Trigger::default()
891 })
892 );
893 assert_eq!(
894 request_trigger(
895 "on:\n push:\n paths: [x]\n pull_request:\n branches: [master]\n",
896 "master"
897 ),
898 Some(unfiltered())
899 );
900 }
901
902 #[test]
903 fn jobs_read_names_needs_and_conditions_in_every_form() {
904 let text = "\
905jobs:
906 lint:
907 runs-on: ubuntu-latest
908 build:
909 name: \"Build it\" # the context
910 needs: lint
911 docs:
912 needs: [lint, build]
913 gate:
914 name: gate-${{ matrix.os }}
915 if: ${{ always() }}
916 needs:
917 - lint
918 - 'docs'
919 steps:
920 - uses: x@y
921 with:
922 needs: nothing
923 odd:
924 if: always() && needs.lint.result == 'success'
925 needs: ${{ fromJSON(x) }}
926 called:
927 uses: org/repo/.github/workflows/x.yml@main
928 name: called
929 named-first:
930 name: gate
931 uses: org/repo/.github/workflows/x.yml@main
932";
933 let found = jobs(text);
934 let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
935 assert_eq!(
936 ids,
937 [
938 "lint",
939 "build",
940 "docs",
941 "gate",
942 "odd",
943 "called",
944 "named-first"
945 ]
946 );
947 assert_eq!(found[0].needs, Needs::None);
948 assert_eq!(found[0].condition, Condition::Absent);
949 assert_eq!(found[1].context(), Some("Build it"));
950 assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
951 assert_eq!(
952 found[2].needs,
953 Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
954 );
955 assert_eq!(found[3].name, Name::Unproven);
956 assert_eq!(found[3].context(), None);
957 assert_eq!(found[3].condition, Condition::Proven);
958 assert_eq!(
959 found[3].needs,
960 Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
961 );
962 assert_eq!(
963 found[4].condition,
964 Condition::Other("always() && needs.lint.result == 'success'".to_owned())
965 );
966 assert_eq!(found[4].needs, Needs::Opaque);
967 assert!(found[5].reusable);
968 assert_eq!(found[5].context(), None);
969 assert!(found[6].reusable);
970 assert_eq!(found[6].context(), None);
971 }
972
973 #[test]
974 fn flow_lists_keep_quoted_scalars_whole() {
975 assert_eq!(list_items("[a, b]"), ["a", "b"]);
976 assert_eq!(list_items("a"), ["a"]);
977 assert_eq!(list_items("\"a\" # c"), ["a"]);
978 assert_eq!(
979 list_items("['ma[as]ter', \"x,y\", z]"),
980 ["ma[as]ter", "x,y", "z"]
981 );
982 assert_eq!(list_items("[]"), Vec::<&str>::new());
983 assert_eq!(
984 list_items("[\"topic\\\",master,tail\", x]"),
985 ["topic\\\",master,tail", "x"]
986 );
987 }
988
989 #[test]
990 fn a_nested_jobs_key_opens_no_region() {
991 let text = "\
992jobs:
993 call:
994 uses: org/repo/.github/workflows/x.yml@main
995 with:
996 jobs: 3
997 other:
998 strategy:
999 matrix:
1000 jobs: [a, b]
1001";
1002 let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
1003 assert_eq!(ids, ["call", "other"]);
1004 }
1005
1006 #[test]
1007 fn a_condition_is_proven_only_as_always_or_a_readable_not_cancelled() {
1008 assert_eq!(condition("always()"), Condition::Proven);
1009 assert_eq!(condition("${{ always() }}"), Condition::Proven);
1010 assert_eq!(condition("'${{always()}}'"), Condition::Proven);
1011 assert_eq!(condition("${{ !cancelled() }}"), Condition::Proven);
1013 assert_eq!(condition("'!cancelled()'"), Condition::Proven);
1014 assert_eq!(condition("\"!cancelled()\""), Condition::Proven);
1015 assert_eq!(
1017 condition("!cancelled()"),
1018 Condition::UnquotedTag("!cancelled()".to_owned())
1019 );
1020 assert_eq!(
1021 condition("${{ always() && false }}"),
1022 Condition::Other("always() && false".to_owned())
1023 );
1024 assert_eq!(
1025 condition("'!cancelled() && x'"),
1026 Condition::Other("!cancelled() && x".to_owned())
1027 );
1028 assert_eq!(
1029 condition("!always()"),
1030 Condition::UnquotedTag("!always()".to_owned())
1031 );
1032 assert_eq!(
1033 condition(""),
1034 Condition::Other("(a value carried on another line)".to_owned())
1035 );
1036 }
1037
1038 #[test]
1039 fn read_gate_judges_the_gates_shape() {
1040 let gated = report(
1041 "on: [pull_request]\njobs:\n lint:\n test:\n if: always()\n needs: [lint]\n",
1042 "test",
1043 );
1044 assert_eq!(gated.reading, GateReading::Gated);
1045 assert_eq!(gated.gate_condition, Some(Condition::Proven));
1046 assert_eq!(gated.gate_trigger, Trigger::default());
1047 assert_eq!(gated.reporting, 1);
1048 assert!(gated.unreadable.is_empty());
1049
1050 let subset = report(
1053 "on: [pull_request]\njobs:\n lint:\n build:\n docs:\n pr-title:\n test:\n if: always()\n needs: lint\n",
1054 "test",
1055 );
1056 assert_eq!(subset.reading, GateReading::Gated);
1057 assert_eq!(faults(&subset, "test", "master"), None);
1058
1059 let missing = report("on: [pull_request]\njobs:\n lint:\n unit:\n", "test");
1060 assert_eq!(
1061 missing.reading,
1062 GateReading::NoSuchJob {
1063 contexts: vec!["lint".to_owned(), "unit".to_owned()]
1064 }
1065 );
1066 assert_eq!(missing.gate_condition, None);
1067
1068 let dynamic = report(
1069 "on: [pull_request]\njobs:\n lint:\n test:\n name: test-${{ matrix.os }}\n needs: [lint]\n",
1070 "test",
1071 );
1072 assert_eq!(
1073 dynamic.reading,
1074 GateReading::UnprovenGateName {
1075 job: "test".to_owned()
1076 }
1077 );
1078
1079 let opaque = report(
1080 "on: [pull_request]\njobs:\n lint:\n test:\n needs: *all\n",
1081 "test",
1082 );
1083 assert_eq!(
1084 opaque.reading,
1085 GateReading::OpaqueNeeds {
1086 workflow: "ci.yml".to_owned()
1087 }
1088 );
1089
1090 let filtered = report(
1091 "on:\n pull_request:\n paths: ['src/**']\njobs:\n test:\n if: always()\n",
1092 "test",
1093 );
1094 assert_eq!(filtered.reading, GateReading::Gated);
1095 assert!(filtered.gate_trigger.paths_filtered);
1096
1097 let off_trunk = report(
1098 "on:\n pull_request:\n branches: [main]\njobs:\n test:\n if: always()\n",
1099 "test",
1100 );
1101 assert_eq!(
1102 off_trunk.gate_trigger.misses_trunk,
1103 Some("branches: [main]".to_owned())
1104 );
1105
1106 let reusable = report(
1107 "on: [pull_request]\njobs:\n test:\n uses: org/repo/.github/workflows/x.yml@main\n name: test\n",
1108 "test",
1109 );
1110 assert_eq!(
1111 reusable.reading,
1112 GateReading::UnprovenGateName {
1113 job: "test".to_owned()
1114 }
1115 );
1116
1117 let push_only = report("on: push\njobs:\n lint:\n test:\n", "test");
1118 assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1119
1120 let duplicated = report(
1123 "on: [pull_request]\njobs:\n test:\n if: always()\n other:\n name: test\n",
1124 "test",
1125 );
1126 assert_eq!(duplicated.reporting, 2);
1127 let text = faults(&duplicated, "test", "master").expect("a fault");
1128 assert!(
1129 text.contains("no longer stands for the gate alone"),
1130 "{text}"
1131 );
1132
1133 let dir = tempfile::tempdir().expect("a tempdir");
1134 let empty = read_gate(
1135 Utf8Path::from_path(dir.path()).expect("utf-8"),
1136 "test",
1137 "master",
1138 );
1139 assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1140 assert!(empty.unreadable.is_empty());
1141 }
1142
1143 #[test]
1144 fn an_unreadable_workflow_is_named_not_skipped() {
1145 let dir = tempfile::tempdir().expect("a tempdir");
1146 let workflows = dir.path().join(".github/workflows");
1147 std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1148 std::fs::write(
1149 workflows.join("ci.yml"),
1150 "on: [pull_request]\njobs:\n test:\n if: always()\n",
1151 )
1152 .expect("the workflow writes");
1153 let report = read_gate(
1154 Utf8Path::from_path(dir.path()).expect("utf-8"),
1155 "test",
1156 "master",
1157 );
1158 assert_eq!(report.reading, GateReading::Gated);
1159 assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1160 let text = faults(&report, "test", "master").expect("a fault");
1161 assert!(text.contains("[broken.yml] could not be read"), "{text}");
1162 assert!(!text.contains("stands for the gate alone"), "{text}");
1165 }
1166
1167 #[test]
1168 fn fault_texts_are_one_line_each() {
1169 let base = || GateReport {
1170 reading: GateReading::Gated,
1171 gate_condition: Some(Condition::Proven),
1172 gate_trigger: Trigger::default(),
1173 reporting: 1,
1174 unreadable: Vec::new(),
1175 };
1176 assert_eq!(faults(&base(), "test", "master"), None);
1177 let cases = [
1178 GateReport {
1179 reading: GateReading::NoRequestWorkflows,
1180 gate_condition: None,
1181 ..base()
1182 },
1183 GateReport {
1184 reading: GateReading::NoSuchJob {
1185 contexts: vec!["lint".to_owned()],
1186 },
1187 gate_condition: None,
1188 ..base()
1189 },
1190 GateReport {
1191 reading: GateReading::UnprovenGateName {
1192 job: "test".to_owned(),
1193 },
1194 gate_condition: None,
1195 ..base()
1196 },
1197 GateReport {
1198 reading: GateReading::OpaqueNeeds {
1199 workflow: "ci.yml".to_owned(),
1200 },
1201 gate_condition: Some(Condition::Absent),
1202 ..base()
1203 },
1204 GateReport {
1205 gate_condition: Some(Condition::Other("always() && x".to_owned())),
1206 ..base()
1207 },
1208 GateReport {
1209 gate_condition: Some(Condition::UnquotedTag("!cancelled()".to_owned())),
1210 ..base()
1211 },
1212 GateReport {
1213 reporting: 2,
1214 ..base()
1215 },
1216 GateReport {
1217 gate_trigger: Trigger {
1218 paths_filtered: true,
1219 misses_trunk: Some("branches: [main]".to_owned()),
1220 types_filtered: Some("types: [opened]".to_owned()),
1221 },
1222 ..base()
1223 },
1224 GateReport {
1225 unreadable: vec!["x.yml".to_owned()],
1226 ..base()
1227 },
1228 ];
1229 for case in &cases {
1230 let text = faults(case, "test", "master").expect("a fault");
1231 assert!(!text.contains('\n'), "{text}");
1232 assert!(
1233 text.starts_with(|c: char| c.is_lowercase() || c == '['),
1234 "{text}"
1235 );
1236 }
1237 }
1238}