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 = scalar(before_comment(value).trim());
427 if value.is_empty() || value.contains("${{") {
428 return None;
429 }
430 return Some(value);
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 = scalar(before_comment(value).trim());
646 if value.contains("${{") || value.is_empty() {
649 job.name = Name::Unproven;
650 } else {
651 job.name = Name::Fixed(value);
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 scalar(value: &str) -> String {
798 if value.starts_with(QUOTES[0])
799 && let Ok(decoded) = serde_json::from_str::<String>(value)
800 {
801 return decoded;
802 }
803 unquote(value).replace("''", "'")
804}
805
806fn indent(line: &str) -> usize {
807 line.len() - line.trim_start_matches(' ').len()
808}
809
810fn is_blank(line: &str) -> bool {
811 let trimmed = line.trim();
812 trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818
819 fn report(text: &str, check: &str) -> GateReport {
820 let dir = tempfile::tempdir().expect("a tempdir");
821 let workflows = dir.path().join(".github/workflows");
822 std::fs::create_dir_all(&workflows).expect("the workflows dir");
823 std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
824 read_gate(
825 Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
826 check,
827 "master",
828 )
829 }
830
831 fn unfiltered() -> Trigger {
832 Trigger::default()
833 }
834
835 #[test]
836 fn the_trigger_is_read_in_every_on_form() {
837 assert_eq!(
838 request_trigger(
839 "on:\n push:\n pull_request:\n branches: [master]\n",
840 "master"
841 ),
842 Some(unfiltered())
843 );
844 assert_eq!(
845 request_trigger(
846 "on:\n push:\n pull_request:\n branches: [main]\n",
847 "master"
848 ),
849 Some(Trigger {
850 misses_trunk: Some("branches: [main]".to_owned()),
851 ..Trigger::default()
852 })
853 );
854 assert_eq!(
855 request_trigger(
856 "on:\n pull_request:\n branches-ignore:\n - master\n types: [opened]\n",
857 "master"
858 ),
859 Some(Trigger {
860 misses_trunk: Some("branches-ignore: [master]".to_owned()),
861 types_filtered: Some("types: [opened]".to_owned()),
862 ..Trigger::default()
863 })
864 );
865 assert_eq!(
866 request_trigger(
867 "on:\n pull_request:\n branches: ['**']\n types: [opened, synchronize, reopened]\n",
868 "master"
869 ),
870 Some(unfiltered())
871 );
872 assert_eq!(
873 request_trigger(
874 "on:\n pull_request:\n branches: ['**', '!master']\n",
875 "master"
876 ),
877 Some(Trigger {
878 misses_trunk: Some("branches: [**, !master]".to_owned()),
879 ..Trigger::default()
880 })
881 );
882 assert_eq!(
883 request_trigger(
884 "on:\n pull_request:\n branches: ['!master', '**']\n",
885 "master"
886 ),
887 Some(Trigger {
888 misses_trunk: Some("branches: [!master, **]".to_owned()),
889 ..Trigger::default()
890 })
891 );
892 assert_eq!(
893 request_trigger(
894 "on:\n pull_request:\n branches-ignore: ['mast*']\n",
895 "master"
896 ),
897 Some(Trigger {
898 misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
899 ..Trigger::default()
900 })
901 );
902 assert_eq!(
903 request_trigger(
904 "on:\n pull_request:\n branches-ignore: [dependabot]\n",
905 "master"
906 ),
907 Some(unfiltered())
908 );
909 assert_eq!(
910 request_trigger(
911 "on:\n pull_request:\n branches-ignore: ['ma[as]ter']\n",
912 "master"
913 ),
914 Some(Trigger {
915 misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
916 ..Trigger::default()
917 })
918 );
919 assert_eq!(
920 request_trigger(
921 "on:\n pull_request:\n branches: [\"release/**\", 'a,b', master]\n",
922 "master"
923 ),
924 Some(unfiltered())
925 );
926 assert_eq!(
927 request_trigger(
928 "on:\n pull_request:\n branches: [\"topic\\\",master,tail\"]\n",
929 "master"
930 ),
931 Some(Trigger {
932 misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
933 ..Trigger::default()
934 })
935 );
936 assert_eq!(
937 request_trigger("on: [push, pull_request]\n", "master"),
938 Some(unfiltered())
939 );
940 assert_eq!(
941 request_trigger("on: pull_request_target\n", "master"),
942 Some(unfiltered())
943 );
944 assert_eq!(
945 request_trigger("on:\n - push\n - pull_request\n", "master"),
946 Some(unfiltered())
947 );
948 assert_eq!(
949 request_trigger("\"on\":\n pull_request:\n", "master"),
950 Some(unfiltered())
951 );
952 assert_eq!(request_trigger("on: push\n", "master"), None);
953 assert_eq!(
954 request_trigger(
955 "on:\n push:\n workflow_dispatch:\njobs:\n pull_request:\n",
956 "master"
957 ),
958 None
959 );
960 assert_eq!(
961 request_trigger(
962 "on:\n pull_request:\n paths:\n - 'docs/**'\n push:\n",
963 "master"
964 ),
965 Some(Trigger {
966 paths_filtered: true,
967 ..Trigger::default()
968 })
969 );
970 assert_eq!(
971 request_trigger(
972 "on:\n push:\n paths: [x]\n pull_request:\n branches: [master]\n",
973 "master"
974 ),
975 Some(unfiltered())
976 );
977 }
978
979 #[test]
980 fn jobs_read_names_needs_and_conditions_in_every_form() {
981 let text = "\
982jobs:
983 lint:
984 runs-on: ubuntu-latest
985 build:
986 name: \"Build it\" # the context
987 needs: lint
988 docs:
989 needs: [lint, build]
990 gate:
991 name: gate-${{ matrix.os }}
992 if: ${{ always() }}
993 needs:
994 - lint
995 - 'docs'
996 steps:
997 - uses: x@y
998 with:
999 needs: nothing
1000 odd:
1001 if: always() && needs.lint.result == 'success'
1002 needs: ${{ fromJSON(x) }}
1003 called:
1004 uses: org/repo/.github/workflows/x.yml@main
1005 name: called
1006 named-first:
1007 name: gate
1008 uses: org/repo/.github/workflows/x.yml@main
1009";
1010 let found = jobs(text);
1011 let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
1012 assert_eq!(
1013 ids,
1014 [
1015 "lint",
1016 "build",
1017 "docs",
1018 "gate",
1019 "odd",
1020 "called",
1021 "named-first"
1022 ]
1023 );
1024 assert_eq!(found[0].needs, Needs::None);
1025 assert_eq!(found[0].condition, Condition::Absent);
1026 assert_eq!(found[1].context(), Some("Build it"));
1027 assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
1028 assert_eq!(
1029 found[2].needs,
1030 Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
1031 );
1032 assert_eq!(found[3].name, Name::Unproven);
1033 assert_eq!(found[3].context(), None);
1034 assert_eq!(found[3].condition, Condition::Proven);
1035 assert_eq!(
1036 found[3].needs,
1037 Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
1038 );
1039 assert_eq!(
1040 found[4].condition,
1041 Condition::Other("always() && needs.lint.result == 'success'".to_owned())
1042 );
1043 assert_eq!(found[4].needs, Needs::Opaque);
1044 assert!(found[5].reusable);
1045 assert_eq!(found[5].context(), None);
1046 assert!(found[6].reusable);
1047 assert_eq!(found[6].context(), None);
1048 }
1049
1050 #[test]
1051 fn flow_lists_keep_quoted_scalars_whole() {
1052 assert_eq!(list_items("[a, b]"), ["a", "b"]);
1053 assert_eq!(list_items("a"), ["a"]);
1054 assert_eq!(list_items("\"a\" # c"), ["a"]);
1055 assert_eq!(
1056 list_items("['ma[as]ter', \"x,y\", z]"),
1057 ["ma[as]ter", "x,y", "z"]
1058 );
1059 assert_eq!(list_items("[]"), Vec::<&str>::new());
1060 assert_eq!(
1061 list_items("[\"topic\\\",master,tail\", x]"),
1062 ["topic\\\",master,tail", "x"]
1063 );
1064 }
1065
1066 #[test]
1067 fn a_nested_jobs_key_opens_no_region() {
1068 let text = "\
1069jobs:
1070 call:
1071 uses: org/repo/.github/workflows/x.yml@main
1072 with:
1073 jobs: 3
1074 other:
1075 strategy:
1076 matrix:
1077 jobs: [a, b]
1078";
1079 let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
1080 assert_eq!(ids, ["call", "other"]);
1081 }
1082
1083 #[test]
1084 fn a_condition_is_proven_only_as_always_or_a_readable_not_cancelled() {
1085 assert_eq!(condition("always()"), Condition::Proven);
1086 assert_eq!(condition("${{ always() }}"), Condition::Proven);
1087 assert_eq!(condition("'${{always()}}'"), Condition::Proven);
1088 assert_eq!(condition("${{ !cancelled() }}"), Condition::Proven);
1090 assert_eq!(condition("'!cancelled()'"), Condition::Proven);
1091 assert_eq!(condition("\"!cancelled()\""), Condition::Proven);
1092 assert_eq!(
1094 condition("!cancelled()"),
1095 Condition::UnquotedTag("!cancelled()".to_owned())
1096 );
1097 assert_eq!(
1098 condition("${{ always() && false }}"),
1099 Condition::Other("always() && false".to_owned())
1100 );
1101 assert_eq!(
1102 condition("'!cancelled() && x'"),
1103 Condition::Other("!cancelled() && x".to_owned())
1104 );
1105 assert_eq!(
1106 condition("!always()"),
1107 Condition::UnquotedTag("!always()".to_owned())
1108 );
1109 assert_eq!(
1110 condition(""),
1111 Condition::Other("(a value carried on another line)".to_owned())
1112 );
1113 }
1114
1115 #[test]
1116 fn read_gate_judges_the_gates_shape() {
1117 let gated = report(
1118 "on: [pull_request]\njobs:\n lint:\n test:\n if: always()\n needs: [lint]\n",
1119 "test",
1120 );
1121 assert_eq!(gated.reading, GateReading::Gated);
1122 assert_eq!(gated.gate_condition, Some(Condition::Proven));
1123 assert_eq!(gated.gate_trigger, Trigger::default());
1124 assert_eq!(gated.reporting, 1);
1125 assert!(gated.unreadable.is_empty());
1126
1127 let subset = report(
1130 "on: [pull_request]\njobs:\n lint:\n build:\n docs:\n pr-title:\n test:\n if: always()\n needs: lint\n",
1131 "test",
1132 );
1133 assert_eq!(subset.reading, GateReading::Gated);
1134 assert_eq!(faults(&subset, "test", "master"), None);
1135
1136 let missing = report("on: [pull_request]\njobs:\n lint:\n unit:\n", "test");
1137 assert_eq!(
1138 missing.reading,
1139 GateReading::NoSuchJob {
1140 contexts: vec!["lint".to_owned(), "unit".to_owned()]
1141 }
1142 );
1143 assert_eq!(missing.gate_condition, None);
1144
1145 let dynamic = report(
1146 "on: [pull_request]\njobs:\n lint:\n test:\n name: test-${{ matrix.os }}\n needs: [lint]\n",
1147 "test",
1148 );
1149 assert_eq!(
1150 dynamic.reading,
1151 GateReading::UnprovenGateName {
1152 job: "test".to_owned()
1153 }
1154 );
1155
1156 let opaque = report(
1157 "on: [pull_request]\njobs:\n lint:\n test:\n needs: *all\n",
1158 "test",
1159 );
1160 assert_eq!(
1161 opaque.reading,
1162 GateReading::OpaqueNeeds {
1163 workflow: "ci.yml".to_owned()
1164 }
1165 );
1166
1167 let filtered = report(
1168 "on:\n pull_request:\n paths: ['src/**']\njobs:\n test:\n if: always()\n",
1169 "test",
1170 );
1171 assert_eq!(filtered.reading, GateReading::Gated);
1172 assert!(filtered.gate_trigger.paths_filtered);
1173
1174 let off_trunk = report(
1175 "on:\n pull_request:\n branches: [main]\njobs:\n test:\n if: always()\n",
1176 "test",
1177 );
1178 assert_eq!(
1179 off_trunk.gate_trigger.misses_trunk,
1180 Some("branches: [main]".to_owned())
1181 );
1182
1183 let reusable = report(
1184 "on: [pull_request]\njobs:\n test:\n uses: org/repo/.github/workflows/x.yml@main\n name: test\n",
1185 "test",
1186 );
1187 assert_eq!(
1188 reusable.reading,
1189 GateReading::UnprovenGateName {
1190 job: "test".to_owned()
1191 }
1192 );
1193
1194 let push_only = report("on: push\njobs:\n lint:\n test:\n", "test");
1195 assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1196
1197 let duplicated = report(
1200 "on: [pull_request]\njobs:\n test:\n if: always()\n other:\n name: test\n",
1201 "test",
1202 );
1203 assert_eq!(duplicated.reporting, 2);
1204 let text = faults(&duplicated, "test", "master").expect("a fault");
1205 assert!(
1206 text.contains("no longer stands for the gate alone"),
1207 "{text}"
1208 );
1209
1210 let dir = tempfile::tempdir().expect("a tempdir");
1211 let empty = read_gate(
1212 Utf8Path::from_path(dir.path()).expect("utf-8"),
1213 "test",
1214 "master",
1215 );
1216 assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1217 assert!(empty.unreadable.is_empty());
1218 }
1219
1220 #[test]
1226 fn the_waking_workflow_contains_the_named_check() {
1227 fn target(files: &[(&str, &str)]) -> tempfile::TempDir {
1228 let dir = tempfile::tempdir().expect("a tempdir");
1229 let workflows = dir.path().join(".github/workflows");
1230 std::fs::create_dir_all(&workflows).expect("the directory exists");
1231 for (name, text) in files {
1232 std::fs::write(workflows.join(name), text).expect("the workflow writes");
1233 }
1234 dir
1235 }
1236 fn fault(dir: &tempfile::TempDir, workflow: &str, check: &str) -> Option<String> {
1237 waking_workflow_fault(
1238 Utf8Path::from_path(dir.path()).expect("utf-8"),
1239 workflow,
1240 check,
1241 "master",
1242 )
1243 }
1244
1245 let sound = target(&[(
1246 "ci.yml",
1247 "name: ci\non: [pull_request]\njobs:\n gate:\n if: always()\n",
1248 )]);
1249 assert_eq!(fault(&sound, "ci", "gate"), None);
1250
1251 let unnamed = target(&[(
1254 "ci.yml",
1255 "on: [pull_request]\njobs:\n gate:\n if: always()\n",
1256 )]);
1257 let text = fault(&unnamed, "ci", "gate").expect("a fault");
1258 assert!(text.contains("never the filename"), "{text}");
1259
1260 let split = target(&[
1263 ("ci.yml", "name: ci\non: [pull_request]\njobs:\n build:\n"),
1264 (
1265 "checks.yml",
1266 "name: checks\non: [pull_request]\njobs:\n gate:\n if: always()\n",
1267 ),
1268 ]);
1269 let text = fault(&split, "ci", "gate").expect("a fault");
1270 assert!(text.contains("carries no job reporting"), "{text}");
1271
1272 let off_trunk = target(&[(
1275 "ci.yml",
1276 "name: ci\non:\n pull_request:\n branches: [main]\njobs:\n gate:\n if: always()\n",
1277 )]);
1278 let text = fault(&off_trunk, "ci", "gate").expect("a fault");
1279 assert!(text.contains("against master"), "{text}");
1280 }
1281
1282 #[test]
1283 fn an_unreadable_workflow_is_named_not_skipped() {
1284 let dir = tempfile::tempdir().expect("a tempdir");
1285 let workflows = dir.path().join(".github/workflows");
1286 std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1287 std::fs::write(
1288 workflows.join("ci.yml"),
1289 "on: [pull_request]\njobs:\n test:\n if: always()\n",
1290 )
1291 .expect("the workflow writes");
1292 let report = read_gate(
1293 Utf8Path::from_path(dir.path()).expect("utf-8"),
1294 "test",
1295 "master",
1296 );
1297 assert_eq!(report.reading, GateReading::Gated);
1298 assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1299 let text = faults(&report, "test", "master").expect("a fault");
1300 assert!(text.contains("[broken.yml] could not be read"), "{text}");
1301 assert!(!text.contains("stands for the gate alone"), "{text}");
1304 }
1305
1306 #[test]
1307 fn fault_texts_are_one_line_each() {
1308 let base = || GateReport {
1309 reading: GateReading::Gated,
1310 gate_condition: Some(Condition::Proven),
1311 gate_trigger: Trigger::default(),
1312 reporting: 1,
1313 unreadable: Vec::new(),
1314 };
1315 assert_eq!(faults(&base(), "test", "master"), None);
1316 let cases = [
1317 GateReport {
1318 reading: GateReading::NoRequestWorkflows,
1319 gate_condition: None,
1320 ..base()
1321 },
1322 GateReport {
1323 reading: GateReading::NoSuchJob {
1324 contexts: vec!["lint".to_owned()],
1325 },
1326 gate_condition: None,
1327 ..base()
1328 },
1329 GateReport {
1330 reading: GateReading::UnprovenGateName {
1331 job: "test".to_owned(),
1332 },
1333 gate_condition: None,
1334 ..base()
1335 },
1336 GateReport {
1337 reading: GateReading::OpaqueNeeds {
1338 workflow: "ci.yml".to_owned(),
1339 },
1340 gate_condition: Some(Condition::Absent),
1341 ..base()
1342 },
1343 GateReport {
1344 gate_condition: Some(Condition::Other("always() && x".to_owned())),
1345 ..base()
1346 },
1347 GateReport {
1348 gate_condition: Some(Condition::UnquotedTag("!cancelled()".to_owned())),
1349 ..base()
1350 },
1351 GateReport {
1352 reporting: 2,
1353 ..base()
1354 },
1355 GateReport {
1356 gate_trigger: Trigger {
1357 paths_filtered: true,
1358 misses_trunk: Some("branches: [main]".to_owned()),
1359 types_filtered: Some("types: [opened]".to_owned()),
1360 },
1361 ..base()
1362 },
1363 GateReport {
1364 unreadable: vec!["x.yml".to_owned()],
1365 ..base()
1366 },
1367 ];
1368 for case in &cases {
1369 let text = faults(case, "test", "master").expect("a fault");
1370 assert!(!text.contains('\n'), "{text}");
1371 assert!(
1372 text.starts_with(|c: char| c.is_lowercase() || c == '['),
1373 "{text}"
1374 );
1375 }
1376 }
1377}