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 declared_name: Option<String>,
225 trigger: Trigger,
226 jobs: Vec<Job>,
227}
228
229#[must_use]
232pub fn read_gate(target: &Utf8Path, required_check: &str, trunk: &str) -> GateReport {
233 let (workflows, unreadable) = read_workflows(&target.join(".github/workflows"), trunk);
234 let mut report = GateReport {
235 reading: GateReading::NoRequestWorkflows,
236 gate_condition: None,
237 gate_trigger: Trigger::default(),
238 reporting: 0,
239 unreadable,
240 };
241 if workflows.iter().all(|workflow| workflow.jobs.is_empty()) {
242 return report;
243 }
244 judge(&mut report, &workflows, required_check);
245 report
246}
247
248fn read_workflows(dir: &Utf8Path, trunk: &str) -> (Vec<Workflow>, Vec<String>) {
252 let mut unreadable: Vec<String> = Vec::new();
253 let mut workflows: Vec<Workflow> = Vec::new();
254 match std::fs::read_dir(dir) {
255 Ok(entries) => {
256 let mut names: Vec<String> = Vec::new();
257 for entry in entries {
258 match entry {
259 Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
260 Err(_) => unreadable.push(dir.to_string()),
261 }
262 }
263 names.sort();
264 for name in names {
265 let is_workflow = std::path::Path::new(&name)
266 .extension()
267 .is_some_and(|ext| ext == "yml" || ext == "yaml");
268 if !is_workflow {
269 continue;
270 }
271 let Ok(text) = std::fs::read_to_string(dir.join(&name)) else {
272 unreadable.push(name);
273 continue;
274 };
275 if let Some(trigger) = request_trigger(&text, trunk) {
276 workflows.push(Workflow {
277 name,
278 declared_name: declared_name(&text),
279 trigger,
280 jobs: jobs(&text),
281 });
282 }
283 }
284 }
285 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
286 Err(_) => unreadable.push(dir.to_string()),
287 }
288 (workflows, unreadable)
289}
290
291fn judge(report: &mut GateReport, workflows: &[Workflow], required_check: &str) {
297 report.reporting = workflows
301 .iter()
302 .flat_map(|workflow| &workflow.jobs)
303 .filter(|job| job.context() == Some(required_check))
304 .count();
305 let Some((workflow, gate)) = workflows.iter().find_map(|workflow| {
306 workflow
307 .jobs
308 .iter()
309 .find(|job| job.context() == Some(required_check))
310 .map(|job| (workflow, job))
311 }) else {
312 let unproven = workflows
313 .iter()
314 .flat_map(|workflow| &workflow.jobs)
315 .find(|job| job.id == required_check && job.context().is_none());
316 report.reading = unproven.map_or_else(
317 || GateReading::NoSuchJob {
318 contexts: workflows
321 .iter()
322 .flat_map(|workflow| workflow.jobs.iter().filter_map(Job::context))
323 .map(str::to_owned)
324 .collect(),
325 },
326 |job| GateReading::UnprovenGateName {
327 job: job.id.clone(),
328 },
329 );
330 return;
331 };
332 report.gate_condition = Some(gate.condition.clone());
333 report.gate_trigger = workflow.trigger.clone();
334 report.reading = match &gate.needs {
338 Needs::Opaque => GateReading::OpaqueNeeds {
339 workflow: workflow.name.clone(),
340 },
341 Needs::None | Needs::Listed(_) => GateReading::Gated,
342 };
343}
344
345#[must_use]
351pub fn faults(report: &GateReport, required_check: &str, trunk: &str) -> Option<String> {
352 let mut parts: Vec<String> = Vec::new();
353 match &report.reading {
354 GateReading::Gated => {}
355 GateReading::NoRequestWorkflows => parts.push(format!(
356 "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"
357 )),
358 GateReading::NoSuchJob { contexts } => parts.push(format!(
359 "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 [{}]",
360 contexts.join(", ")
361 )),
362 GateReading::UnprovenGateName { job } => parts.push(format!(
363 "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"
364 )),
365 GateReading::OpaqueNeeds { workflow } => parts.push(format!(
366 "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"
367 )),
368 }
369 if report.reporting > 1 {
370 parts.push(format!(
371 "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",
372 report.reporting
373 ));
374 }
375 match &report.gate_condition {
376 None | Some(Condition::Proven) => {}
377 Some(Condition::Absent) => parts.push(format!(
378 "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() }}}}"
379 )),
380 Some(Condition::UnquotedTag(raw)) => parts.push(format!(
381 "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"
382 )),
383 Some(Condition::Other(expression)) => parts.push(format!(
384 "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"
385 )),
386 }
387 if report.gate_trigger.paths_filtered {
388 parts.push(format!(
389 "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"
390 ));
391 }
392 if let Some(filter) = &report.gate_trigger.misses_trunk {
393 parts.push(format!(
394 "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"
395 ));
396 }
397 if let Some(filter) = &report.gate_trigger.types_filtered {
398 parts.push(format!(
399 "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"
400 ));
401 }
402 if !report.unreadable.is_empty() {
403 parts.push(format!(
404 "[{}] could not be read, so no job there is judged and the context is not proven unique",
405 report.unreadable.join(", ")
406 ));
407 }
408 (!parts.is_empty()).then(|| parts.join("; "))
409}
410
411fn declared_name(workflow: &str) -> Option<String> {
418 for line in workflow.lines() {
419 if is_blank(line) || indent(line) > 0 {
420 continue;
421 }
422 let (key, value) = key_value(line)?;
423 if key != "name" {
424 continue;
425 }
426 let value = unquote(before_comment(value).trim());
427 if value.is_empty() || value.contains("${{") {
428 return None;
429 }
430 return Some(value.to_owned());
431 }
432 None
433}
434
435#[must_use]
446pub fn waking_workflow_fault(
447 target: &Utf8Path,
448 required_workflow: &str,
449 required_check: &str,
450 trunk: &str,
451) -> Option<String> {
452 let (workflows, _) = read_workflows(&target.join(".github/workflows"), trunk);
453 let Some(waking) = workflows
454 .iter()
455 .find(|workflow| workflow.declared_name.as_deref() == Some(required_workflow))
456 else {
457 let named: Vec<&str> = workflows
458 .iter()
459 .filter_map(|workflow| workflow.declared_name.as_deref())
460 .collect();
461 return Some(format!(
462 "no workflow in .github/workflows declares name: {required_workflow} and runs on a pull request, so the release gate never wakes and the release request stays open; a workflow_run trigger names the name: value and never the filename, and the names that do run on a request are [{}]",
463 named.join(", ")
464 ));
465 };
466 if !waking
467 .jobs
468 .iter()
469 .any(|job| job.context() == Some(required_check))
470 {
471 return Some(format!(
472 "the workflow {required_workflow} carries no job reporting the context {required_check}, so the gate would wake on one workflow and judge a check another workflow reports; name one workflow that carries the check"
473 ));
474 }
475 if let Some(filter) = &waking.trigger.misses_trunk {
476 return Some(format!(
477 "the pull_request trigger of {required_workflow} reads {filter}, which does not prove it runs for a request against {trunk}, so the gate never wakes for a release request"
478 ));
479 }
480 None
481}
482
483pub(crate) fn request_trigger(workflow: &str, trunk: &str) -> Option<Trigger> {
491 let mut in_on = false;
492 let mut event_indent: Option<usize> = None;
493 let mut in_request_event = false;
494 let mut filter_indent: Option<usize> = None;
495 let mut filters: Vec<(String, Vec<String>)> = Vec::new();
496 let mut found: Option<Trigger> = None;
497 let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
498 if let Some(trigger) = found {
499 trigger.merge(Trigger::from_filters(filters, trunk));
500 }
501 filters.clear();
502 };
503 for line in workflow.lines() {
504 if is_blank(line) {
505 continue;
506 }
507 let depth = indent(line);
508 if depth == 0 {
509 if in_request_event {
510 close_event(&mut filters, &mut found);
511 }
512 in_on = false;
513 in_request_event = false;
514 event_indent = None;
515 let Some((key, value)) = key_value(line) else {
516 continue;
517 };
518 if key != "on" {
519 continue;
520 }
521 if value.is_empty() {
522 in_on = true;
523 continue;
524 }
525 if list_items(value).iter().any(|item| is_request_event(item)) {
526 found.get_or_insert_with(Trigger::default);
527 }
528 continue;
529 }
530 if !in_on {
531 continue;
532 }
533 let event_depth = *event_indent.get_or_insert(depth);
534 if depth == event_depth {
535 if in_request_event {
536 close_event(&mut filters, &mut found);
537 }
538 filter_indent = None;
539 let item = line.trim_start();
540 let item = item.strip_prefix("- ").map_or(item, str::trim_start);
541 let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
542 in_request_event = is_request_event(key);
543 if in_request_event {
544 found.get_or_insert_with(Trigger::default);
545 }
546 continue;
547 }
548 if !in_request_event || depth <= event_depth {
549 continue;
550 }
551 let filter_depth = *filter_indent.get_or_insert(depth);
552 if depth == filter_depth {
553 if let Some((key, value)) = key_value(line) {
554 let items = if value.is_empty() {
555 Vec::new()
556 } else {
557 list_items(value).into_iter().map(str::to_owned).collect()
558 };
559 filters.push((key.to_owned(), items));
560 }
561 continue;
562 }
563 if let Some(item) = line.trim_start().strip_prefix("- ")
565 && let Some((_, items)) = filters.last_mut()
566 {
567 items.push(unquote(before_comment(item).trim()).to_owned());
568 }
569 }
570 if in_request_event {
571 close_event(&mut filters, &mut found);
572 }
573 found
574}
575
576fn is_request_event(name: &str) -> bool {
577 matches!(name, "pull_request" | "pull_request_target")
578}
579
580fn jobs(workflow: &str) -> Vec<Job> {
585 let mut found: Vec<Job> = Vec::new();
586 let mut in_jobs = false;
587 let mut job_indent: Option<usize> = None;
588 let mut property_indent: Option<usize> = None;
589 let mut reading_needs_list = false;
590 for line in workflow.lines() {
591 if is_blank(line) {
592 continue;
593 }
594 let depth = indent(line);
595 if depth == 0 {
596 in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
597 job_indent = None;
598 property_indent = None;
599 reading_needs_list = false;
600 continue;
601 }
602 if !in_jobs {
603 continue;
604 }
605 let job_depth = *job_indent.get_or_insert(depth);
606 if depth == job_depth {
607 reading_needs_list = false;
608 property_indent = None;
609 if let Some((id, _)) = key_value(line) {
610 found.push(Job {
611 id: id.to_owned(),
612 name: Name::Id,
613 reusable: false,
614 needs: Needs::None,
615 condition: Condition::Absent,
616 });
617 }
618 continue;
619 }
620 if depth < job_depth {
621 continue;
622 }
623 let Some(job) = found.last_mut() else {
624 continue;
625 };
626 let property_depth = *property_indent.get_or_insert(depth);
627 if reading_needs_list
628 && depth > property_depth
629 && let Some(item) = line.trim_start().strip_prefix("- ")
630 {
631 if let Needs::Listed(ids) = &mut job.needs {
632 ids.push(unquote(before_comment(item).trim()).to_owned());
633 }
634 continue;
635 }
636 reading_needs_list = false;
637 if depth != property_depth {
638 continue;
639 }
640 let Some((key, value)) = key_value(line) else {
641 continue;
642 };
643 match key {
644 "name" => {
645 let value = unquote(before_comment(value).trim());
646 if value.contains("${{") || value.is_empty() {
649 job.name = Name::Unproven;
650 } else {
651 job.name = Name::Fixed(value.to_owned());
652 }
653 }
654 "uses" => job.reusable = true,
658 "if" => job.condition = condition(value),
659 "needs" => {
660 let value = before_comment(value).trim();
661 if value.is_empty() {
662 job.needs = Needs::Listed(Vec::new());
663 reading_needs_list = true;
664 } else if value.starts_with(['|', '>', '*', '&', '$']) {
665 job.needs = Needs::Opaque;
666 } else {
667 job.needs =
668 Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
669 }
670 }
671 _ => {}
672 }
673 }
674 found
675}
676
677fn condition(value: &str) -> Condition {
693 let raw = before_comment(value).trim();
694 let value = unquote(raw);
695 let inner = value
696 .strip_prefix("${{")
697 .and_then(|rest| rest.strip_suffix("}}"))
698 .map_or(value, str::trim);
699 if raw.starts_with('!') {
700 return Condition::UnquotedTag(raw.to_owned());
701 }
702 if inner == "always()" || inner == "!cancelled()" {
703 Condition::Proven
704 } else if inner.is_empty() {
705 Condition::Other("(a value carried on another line)".to_owned())
706 } else {
707 Condition::Other(inner.to_owned())
708 }
709}
710
711fn list_items(value: &str) -> Vec<&str> {
716 let value = before_comment(value).trim();
717 let inner = value
718 .strip_prefix('[')
719 .and_then(|rest| rest.strip_suffix(']'))
720 .unwrap_or(value);
721 let mut items = Vec::new();
722 let mut quote: Option<char> = None;
723 let mut escaped = false;
724 let mut start = 0;
725 for (index, character) in inner.char_indices() {
726 if let Some(open) = quote {
727 if escaped {
730 escaped = false;
731 } else if open == QUOTES[0] && character == '\\' {
732 escaped = true;
733 } else if character == open {
734 quote = None;
735 }
736 } else if QUOTES.contains(&character) {
737 quote = Some(character);
738 } else if character == ',' {
739 items.push(&inner[start..index]);
740 start = index + 1;
741 }
742 }
743 items.push(&inner[start..]);
744 items
745 .into_iter()
746 .map(|item| unquote(item.trim()))
747 .filter(|item| !item.is_empty())
748 .collect()
749}
750
751fn key_value(line: &str) -> Option<(&str, &str)> {
754 let line = line.trim();
755 let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
756 let quote = line.chars().next()?;
757 let end = quoted.find(quote)?;
758 ("ed[..end], quoted[end + 1..].trim_start())
759 } else {
760 let end = line.find(':')?;
761 (&line[..end], &line[end..])
762 };
763 let value = rest.strip_prefix(':')?;
764 if !(value.is_empty() || value.starts_with([' ', '\t'])) {
765 return None;
766 }
767 let key = key.trim();
768 if key.is_empty() || key.contains([' ', '\t']) {
769 return None;
770 }
771 Some((key, value.trim()))
772}
773
774const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
778
779fn unquote(value: &str) -> &str {
780 value
781 .strip_prefix(QUOTES[0])
782 .and_then(|rest| rest.strip_suffix(QUOTES[0]))
783 .or_else(|| {
784 value
785 .strip_prefix('\'')
786 .and_then(|rest| rest.strip_suffix('\''))
787 })
788 .unwrap_or(value)
789}
790
791fn indent(line: &str) -> usize {
792 line.len() - line.trim_start_matches(' ').len()
793}
794
795fn is_blank(line: &str) -> bool {
796 let trimmed = line.trim();
797 trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 fn report(text: &str, check: &str) -> GateReport {
805 let dir = tempfile::tempdir().expect("a tempdir");
806 let workflows = dir.path().join(".github/workflows");
807 std::fs::create_dir_all(&workflows).expect("the workflows dir");
808 std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
809 read_gate(
810 Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
811 check,
812 "master",
813 )
814 }
815
816 fn unfiltered() -> Trigger {
817 Trigger::default()
818 }
819
820 #[test]
821 fn the_trigger_is_read_in_every_on_form() {
822 assert_eq!(
823 request_trigger(
824 "on:\n push:\n pull_request:\n branches: [master]\n",
825 "master"
826 ),
827 Some(unfiltered())
828 );
829 assert_eq!(
830 request_trigger(
831 "on:\n push:\n pull_request:\n branches: [main]\n",
832 "master"
833 ),
834 Some(Trigger {
835 misses_trunk: Some("branches: [main]".to_owned()),
836 ..Trigger::default()
837 })
838 );
839 assert_eq!(
840 request_trigger(
841 "on:\n pull_request:\n branches-ignore:\n - master\n types: [opened]\n",
842 "master"
843 ),
844 Some(Trigger {
845 misses_trunk: Some("branches-ignore: [master]".to_owned()),
846 types_filtered: Some("types: [opened]".to_owned()),
847 ..Trigger::default()
848 })
849 );
850 assert_eq!(
851 request_trigger(
852 "on:\n pull_request:\n branches: ['**']\n types: [opened, synchronize, reopened]\n",
853 "master"
854 ),
855 Some(unfiltered())
856 );
857 assert_eq!(
858 request_trigger(
859 "on:\n pull_request:\n branches: ['**', '!master']\n",
860 "master"
861 ),
862 Some(Trigger {
863 misses_trunk: Some("branches: [**, !master]".to_owned()),
864 ..Trigger::default()
865 })
866 );
867 assert_eq!(
868 request_trigger(
869 "on:\n pull_request:\n branches: ['!master', '**']\n",
870 "master"
871 ),
872 Some(Trigger {
873 misses_trunk: Some("branches: [!master, **]".to_owned()),
874 ..Trigger::default()
875 })
876 );
877 assert_eq!(
878 request_trigger(
879 "on:\n pull_request:\n branches-ignore: ['mast*']\n",
880 "master"
881 ),
882 Some(Trigger {
883 misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
884 ..Trigger::default()
885 })
886 );
887 assert_eq!(
888 request_trigger(
889 "on:\n pull_request:\n branches-ignore: [dependabot]\n",
890 "master"
891 ),
892 Some(unfiltered())
893 );
894 assert_eq!(
895 request_trigger(
896 "on:\n pull_request:\n branches-ignore: ['ma[as]ter']\n",
897 "master"
898 ),
899 Some(Trigger {
900 misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
901 ..Trigger::default()
902 })
903 );
904 assert_eq!(
905 request_trigger(
906 "on:\n pull_request:\n branches: [\"release/**\", 'a,b', master]\n",
907 "master"
908 ),
909 Some(unfiltered())
910 );
911 assert_eq!(
912 request_trigger(
913 "on:\n pull_request:\n branches: [\"topic\\\",master,tail\"]\n",
914 "master"
915 ),
916 Some(Trigger {
917 misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
918 ..Trigger::default()
919 })
920 );
921 assert_eq!(
922 request_trigger("on: [push, pull_request]\n", "master"),
923 Some(unfiltered())
924 );
925 assert_eq!(
926 request_trigger("on: pull_request_target\n", "master"),
927 Some(unfiltered())
928 );
929 assert_eq!(
930 request_trigger("on:\n - push\n - pull_request\n", "master"),
931 Some(unfiltered())
932 );
933 assert_eq!(
934 request_trigger("\"on\":\n pull_request:\n", "master"),
935 Some(unfiltered())
936 );
937 assert_eq!(request_trigger("on: push\n", "master"), None);
938 assert_eq!(
939 request_trigger(
940 "on:\n push:\n workflow_dispatch:\njobs:\n pull_request:\n",
941 "master"
942 ),
943 None
944 );
945 assert_eq!(
946 request_trigger(
947 "on:\n pull_request:\n paths:\n - 'docs/**'\n push:\n",
948 "master"
949 ),
950 Some(Trigger {
951 paths_filtered: true,
952 ..Trigger::default()
953 })
954 );
955 assert_eq!(
956 request_trigger(
957 "on:\n push:\n paths: [x]\n pull_request:\n branches: [master]\n",
958 "master"
959 ),
960 Some(unfiltered())
961 );
962 }
963
964 #[test]
965 fn jobs_read_names_needs_and_conditions_in_every_form() {
966 let text = "\
967jobs:
968 lint:
969 runs-on: ubuntu-latest
970 build:
971 name: \"Build it\" # the context
972 needs: lint
973 docs:
974 needs: [lint, build]
975 gate:
976 name: gate-${{ matrix.os }}
977 if: ${{ always() }}
978 needs:
979 - lint
980 - 'docs'
981 steps:
982 - uses: x@y
983 with:
984 needs: nothing
985 odd:
986 if: always() && needs.lint.result == 'success'
987 needs: ${{ fromJSON(x) }}
988 called:
989 uses: org/repo/.github/workflows/x.yml@main
990 name: called
991 named-first:
992 name: gate
993 uses: org/repo/.github/workflows/x.yml@main
994";
995 let found = jobs(text);
996 let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
997 assert_eq!(
998 ids,
999 [
1000 "lint",
1001 "build",
1002 "docs",
1003 "gate",
1004 "odd",
1005 "called",
1006 "named-first"
1007 ]
1008 );
1009 assert_eq!(found[0].needs, Needs::None);
1010 assert_eq!(found[0].condition, Condition::Absent);
1011 assert_eq!(found[1].context(), Some("Build it"));
1012 assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
1013 assert_eq!(
1014 found[2].needs,
1015 Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
1016 );
1017 assert_eq!(found[3].name, Name::Unproven);
1018 assert_eq!(found[3].context(), None);
1019 assert_eq!(found[3].condition, Condition::Proven);
1020 assert_eq!(
1021 found[3].needs,
1022 Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
1023 );
1024 assert_eq!(
1025 found[4].condition,
1026 Condition::Other("always() && needs.lint.result == 'success'".to_owned())
1027 );
1028 assert_eq!(found[4].needs, Needs::Opaque);
1029 assert!(found[5].reusable);
1030 assert_eq!(found[5].context(), None);
1031 assert!(found[6].reusable);
1032 assert_eq!(found[6].context(), None);
1033 }
1034
1035 #[test]
1036 fn flow_lists_keep_quoted_scalars_whole() {
1037 assert_eq!(list_items("[a, b]"), ["a", "b"]);
1038 assert_eq!(list_items("a"), ["a"]);
1039 assert_eq!(list_items("\"a\" # c"), ["a"]);
1040 assert_eq!(
1041 list_items("['ma[as]ter', \"x,y\", z]"),
1042 ["ma[as]ter", "x,y", "z"]
1043 );
1044 assert_eq!(list_items("[]"), Vec::<&str>::new());
1045 assert_eq!(
1046 list_items("[\"topic\\\",master,tail\", x]"),
1047 ["topic\\\",master,tail", "x"]
1048 );
1049 }
1050
1051 #[test]
1052 fn a_nested_jobs_key_opens_no_region() {
1053 let text = "\
1054jobs:
1055 call:
1056 uses: org/repo/.github/workflows/x.yml@main
1057 with:
1058 jobs: 3
1059 other:
1060 strategy:
1061 matrix:
1062 jobs: [a, b]
1063";
1064 let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
1065 assert_eq!(ids, ["call", "other"]);
1066 }
1067
1068 #[test]
1069 fn a_condition_is_proven_only_as_always_or_a_readable_not_cancelled() {
1070 assert_eq!(condition("always()"), Condition::Proven);
1071 assert_eq!(condition("${{ always() }}"), Condition::Proven);
1072 assert_eq!(condition("'${{always()}}'"), Condition::Proven);
1073 assert_eq!(condition("${{ !cancelled() }}"), Condition::Proven);
1075 assert_eq!(condition("'!cancelled()'"), Condition::Proven);
1076 assert_eq!(condition("\"!cancelled()\""), Condition::Proven);
1077 assert_eq!(
1079 condition("!cancelled()"),
1080 Condition::UnquotedTag("!cancelled()".to_owned())
1081 );
1082 assert_eq!(
1083 condition("${{ always() && false }}"),
1084 Condition::Other("always() && false".to_owned())
1085 );
1086 assert_eq!(
1087 condition("'!cancelled() && x'"),
1088 Condition::Other("!cancelled() && x".to_owned())
1089 );
1090 assert_eq!(
1091 condition("!always()"),
1092 Condition::UnquotedTag("!always()".to_owned())
1093 );
1094 assert_eq!(
1095 condition(""),
1096 Condition::Other("(a value carried on another line)".to_owned())
1097 );
1098 }
1099
1100 #[test]
1101 fn read_gate_judges_the_gates_shape() {
1102 let gated = report(
1103 "on: [pull_request]\njobs:\n lint:\n test:\n if: always()\n needs: [lint]\n",
1104 "test",
1105 );
1106 assert_eq!(gated.reading, GateReading::Gated);
1107 assert_eq!(gated.gate_condition, Some(Condition::Proven));
1108 assert_eq!(gated.gate_trigger, Trigger::default());
1109 assert_eq!(gated.reporting, 1);
1110 assert!(gated.unreadable.is_empty());
1111
1112 let subset = report(
1115 "on: [pull_request]\njobs:\n lint:\n build:\n docs:\n pr-title:\n test:\n if: always()\n needs: lint\n",
1116 "test",
1117 );
1118 assert_eq!(subset.reading, GateReading::Gated);
1119 assert_eq!(faults(&subset, "test", "master"), None);
1120
1121 let missing = report("on: [pull_request]\njobs:\n lint:\n unit:\n", "test");
1122 assert_eq!(
1123 missing.reading,
1124 GateReading::NoSuchJob {
1125 contexts: vec!["lint".to_owned(), "unit".to_owned()]
1126 }
1127 );
1128 assert_eq!(missing.gate_condition, None);
1129
1130 let dynamic = report(
1131 "on: [pull_request]\njobs:\n lint:\n test:\n name: test-${{ matrix.os }}\n needs: [lint]\n",
1132 "test",
1133 );
1134 assert_eq!(
1135 dynamic.reading,
1136 GateReading::UnprovenGateName {
1137 job: "test".to_owned()
1138 }
1139 );
1140
1141 let opaque = report(
1142 "on: [pull_request]\njobs:\n lint:\n test:\n needs: *all\n",
1143 "test",
1144 );
1145 assert_eq!(
1146 opaque.reading,
1147 GateReading::OpaqueNeeds {
1148 workflow: "ci.yml".to_owned()
1149 }
1150 );
1151
1152 let filtered = report(
1153 "on:\n pull_request:\n paths: ['src/**']\njobs:\n test:\n if: always()\n",
1154 "test",
1155 );
1156 assert_eq!(filtered.reading, GateReading::Gated);
1157 assert!(filtered.gate_trigger.paths_filtered);
1158
1159 let off_trunk = report(
1160 "on:\n pull_request:\n branches: [main]\njobs:\n test:\n if: always()\n",
1161 "test",
1162 );
1163 assert_eq!(
1164 off_trunk.gate_trigger.misses_trunk,
1165 Some("branches: [main]".to_owned())
1166 );
1167
1168 let reusable = report(
1169 "on: [pull_request]\njobs:\n test:\n uses: org/repo/.github/workflows/x.yml@main\n name: test\n",
1170 "test",
1171 );
1172 assert_eq!(
1173 reusable.reading,
1174 GateReading::UnprovenGateName {
1175 job: "test".to_owned()
1176 }
1177 );
1178
1179 let push_only = report("on: push\njobs:\n lint:\n test:\n", "test");
1180 assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1181
1182 let duplicated = report(
1185 "on: [pull_request]\njobs:\n test:\n if: always()\n other:\n name: test\n",
1186 "test",
1187 );
1188 assert_eq!(duplicated.reporting, 2);
1189 let text = faults(&duplicated, "test", "master").expect("a fault");
1190 assert!(
1191 text.contains("no longer stands for the gate alone"),
1192 "{text}"
1193 );
1194
1195 let dir = tempfile::tempdir().expect("a tempdir");
1196 let empty = read_gate(
1197 Utf8Path::from_path(dir.path()).expect("utf-8"),
1198 "test",
1199 "master",
1200 );
1201 assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1202 assert!(empty.unreadable.is_empty());
1203 }
1204
1205 #[test]
1211 fn the_waking_workflow_contains_the_named_check() {
1212 fn target(files: &[(&str, &str)]) -> tempfile::TempDir {
1213 let dir = tempfile::tempdir().expect("a tempdir");
1214 let workflows = dir.path().join(".github/workflows");
1215 std::fs::create_dir_all(&workflows).expect("the directory exists");
1216 for (name, text) in files {
1217 std::fs::write(workflows.join(name), text).expect("the workflow writes");
1218 }
1219 dir
1220 }
1221 fn fault(dir: &tempfile::TempDir, workflow: &str, check: &str) -> Option<String> {
1222 waking_workflow_fault(
1223 Utf8Path::from_path(dir.path()).expect("utf-8"),
1224 workflow,
1225 check,
1226 "master",
1227 )
1228 }
1229
1230 let sound = target(&[(
1231 "ci.yml",
1232 "name: ci\non: [pull_request]\njobs:\n gate:\n if: always()\n",
1233 )]);
1234 assert_eq!(fault(&sound, "ci", "gate"), None);
1235
1236 let unnamed = target(&[(
1239 "ci.yml",
1240 "on: [pull_request]\njobs:\n gate:\n if: always()\n",
1241 )]);
1242 let text = fault(&unnamed, "ci", "gate").expect("a fault");
1243 assert!(text.contains("never the filename"), "{text}");
1244
1245 let split = target(&[
1248 ("ci.yml", "name: ci\non: [pull_request]\njobs:\n build:\n"),
1249 (
1250 "checks.yml",
1251 "name: checks\non: [pull_request]\njobs:\n gate:\n if: always()\n",
1252 ),
1253 ]);
1254 let text = fault(&split, "ci", "gate").expect("a fault");
1255 assert!(text.contains("carries no job reporting"), "{text}");
1256
1257 let off_trunk = target(&[(
1260 "ci.yml",
1261 "name: ci\non:\n pull_request:\n branches: [main]\njobs:\n gate:\n if: always()\n",
1262 )]);
1263 let text = fault(&off_trunk, "ci", "gate").expect("a fault");
1264 assert!(text.contains("against master"), "{text}");
1265 }
1266
1267 #[test]
1268 fn an_unreadable_workflow_is_named_not_skipped() {
1269 let dir = tempfile::tempdir().expect("a tempdir");
1270 let workflows = dir.path().join(".github/workflows");
1271 std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1272 std::fs::write(
1273 workflows.join("ci.yml"),
1274 "on: [pull_request]\njobs:\n test:\n if: always()\n",
1275 )
1276 .expect("the workflow writes");
1277 let report = read_gate(
1278 Utf8Path::from_path(dir.path()).expect("utf-8"),
1279 "test",
1280 "master",
1281 );
1282 assert_eq!(report.reading, GateReading::Gated);
1283 assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1284 let text = faults(&report, "test", "master").expect("a fault");
1285 assert!(text.contains("[broken.yml] could not be read"), "{text}");
1286 assert!(!text.contains("stands for the gate alone"), "{text}");
1289 }
1290
1291 #[test]
1292 fn fault_texts_are_one_line_each() {
1293 let base = || GateReport {
1294 reading: GateReading::Gated,
1295 gate_condition: Some(Condition::Proven),
1296 gate_trigger: Trigger::default(),
1297 reporting: 1,
1298 unreadable: Vec::new(),
1299 };
1300 assert_eq!(faults(&base(), "test", "master"), None);
1301 let cases = [
1302 GateReport {
1303 reading: GateReading::NoRequestWorkflows,
1304 gate_condition: None,
1305 ..base()
1306 },
1307 GateReport {
1308 reading: GateReading::NoSuchJob {
1309 contexts: vec!["lint".to_owned()],
1310 },
1311 gate_condition: None,
1312 ..base()
1313 },
1314 GateReport {
1315 reading: GateReading::UnprovenGateName {
1316 job: "test".to_owned(),
1317 },
1318 gate_condition: None,
1319 ..base()
1320 },
1321 GateReport {
1322 reading: GateReading::OpaqueNeeds {
1323 workflow: "ci.yml".to_owned(),
1324 },
1325 gate_condition: Some(Condition::Absent),
1326 ..base()
1327 },
1328 GateReport {
1329 gate_condition: Some(Condition::Other("always() && x".to_owned())),
1330 ..base()
1331 },
1332 GateReport {
1333 gate_condition: Some(Condition::UnquotedTag("!cancelled()".to_owned())),
1334 ..base()
1335 },
1336 GateReport {
1337 reporting: 2,
1338 ..base()
1339 },
1340 GateReport {
1341 gate_trigger: Trigger {
1342 paths_filtered: true,
1343 misses_trunk: Some("branches: [main]".to_owned()),
1344 types_filtered: Some("types: [opened]".to_owned()),
1345 },
1346 ..base()
1347 },
1348 GateReport {
1349 unreadable: vec!["x.yml".to_owned()],
1350 ..base()
1351 },
1352 ];
1353 for case in &cases {
1354 let text = faults(case, "test", "master").expect("a fault");
1355 assert!(!text.contains('\n'), "{text}");
1356 assert!(
1357 text.starts_with(|c: char| c.is_lowercase() || c == '['),
1358 "{text}"
1359 );
1360 }
1361 }
1362}