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