Skip to main content

release_kit/setup/
workflow_jobs.rs

1//! What a target's own workflows say about the job `--required-check` names.
2//!
3//! The trunk protection requires exactly two status-check contexts: the
4//! named check and the title check. Every other job the project's workflow
5//! reports on a pull request gates nothing, and under the trunk style's
6//! standing arm nobody reads the check list before the merge. This reader
7//! finds those jobs so `rk setup check` can name them as a limitation on
8//! `protect-trunk`. It reads the workflow text line by line, in the same
9//! spirit as the landing invariants: where a value sits somewhere this
10//! reader does not follow, it says so rather than guessing.
11
12use camino::Utf8Path;
13
14use crate::landing::invariants::before_comment;
15use crate::setup::context::TRUNK_BRANCH;
16use crate::setup::observe::TITLE_CHECK;
17
18/// What the workflows say about the required check.
19#[derive(Debug, PartialEq, Eq)]
20pub enum GateReading {
21    /// No workflow runs on a pull request, so no job reports the check.
22    NoRequestWorkflows,
23    /// Every request-reporting context is the check, the title check, or a
24    /// job the check needs.
25    Gated,
26    /// No job reports the required check's context on a pull request.
27    NoSuchJob {
28        /// The contexts that do report, in file order.
29        contexts: Vec<String>,
30    },
31    /// A job carries the check's id, but names itself by an expression or
32    /// runs a reusable workflow, so the context it reports is not in the
33    /// file.
34    UnprovenGateName {
35        /// The job id.
36        job: String,
37    },
38    /// The check's `needs` value is one this reader does not follow.
39    OpaqueNeeds {
40        /// The workflow file that carries it.
41        workflow: String,
42    },
43    /// Contexts that report on a pull request and gate nothing.
44    Ungated {
45        /// Their names, in file order.
46        jobs: Vec<String>,
47    },
48}
49
50/// The gate job's `if` condition, as far as the reader proves it.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Condition {
53    /// No `if` key: the job is skipped when a needed job fails.
54    Absent,
55    /// A bare `always()`: the job runs whatever the needed jobs report.
56    Always,
57    /// Any other expression, carried verbatim: not proven to run on a
58    /// failed dependency.
59    Other(String),
60}
61
62/// The reading and what the reader judges beside it.
63#[derive(Debug, PartialEq, Eq)]
64pub struct GateReport {
65    /// What the workflows say.
66    pub reading: GateReading,
67    /// The gate job's condition, where a gate job was found.
68    pub gate_condition: Option<Condition>,
69    /// What the gate's workflow filters its pull-request trigger by.
70    pub gate_trigger: Trigger,
71    /// Workflow files that could not be read, so their jobs are unjudged.
72    pub unreadable: Vec<String>,
73}
74
75/// One job as the line reader sees it.
76#[derive(Debug, PartialEq, Eq)]
77struct Job {
78    id: String,
79    name: Name,
80    /// The job calls a reusable workflow, whose jobs report their own
81    /// contexts, named after both the caller and the callee.
82    reusable: bool,
83    needs: Needs,
84    condition: Condition,
85}
86
87/// How a job's status-check context is known.
88#[derive(Debug, PartialEq, Eq)]
89enum Name {
90    /// No `name` key: the context is the id.
91    Id,
92    /// A literal `name` value.
93    Fixed(String),
94    /// A name built from an expression: not in this file.
95    Unproven,
96}
97
98impl Job {
99    /// The status-check context the job reports, where the file states it.
100    fn context(&self) -> Option<&str> {
101        if self.reusable {
102            return None;
103        }
104        match &self.name {
105            Name::Id => Some(&self.id),
106            Name::Fixed(name) => Some(name),
107            Name::Unproven => None,
108        }
109    }
110
111    /// How the job is listed: its context, or its id marked as unresolved.
112    fn listing(&self) -> String {
113        self.context().map_or_else(
114            || format!("{} (a context this reader cannot resolve)", self.id),
115            str::to_owned,
116        )
117    }
118}
119
120/// A job's `needs` value.
121#[derive(Debug, PartialEq, Eq)]
122enum Needs {
123    /// The key is absent.
124    None,
125    /// The job ids named, in a scalar, a flow list, or a block list.
126    Listed(Vec<String>),
127    /// An expression, an anchor, or a folded scalar: not followed.
128    Opaque,
129}
130
131/// What a workflow's pull-request trigger filters by.
132///
133/// Every filter can keep the gate from reporting on a request the trunk
134/// protection covers, and a required context that never appears leaves
135/// the merge hanging.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct Trigger {
138    /// The trigger carries `paths` or `paths-ignore`.
139    pub paths_filtered: bool,
140    /// The trigger's branch filter leaves the trunk out, quoted.
141    pub misses_trunk: Option<String>,
142    /// The trigger's activity types leave out an opened, reopened, or
143    /// synchronized request, quoted.
144    pub types_filtered: Option<String>,
145}
146
147impl Trigger {
148    /// Read the filters one request event carries.
149    fn from_filters(filters: &[(String, Vec<String>)]) -> Self {
150        let mut trigger = Self::default();
151        for (key, items) in filters {
152            match key.as_str() {
153                "paths" | "paths-ignore" => trigger.paths_filtered = true,
154                // A negative pattern later in the list can take the trunk
155                // back out, so a list carrying one is not proven either way.
156                "branches" => {
157                    let negated = items.iter().any(|item| item.starts_with('!'));
158                    if negated || !items.iter().any(|item| covers_trunk(item)) {
159                        trigger.misses_trunk = Some(format!("branches: [{}]", items.join(", ")));
160                    }
161                }
162                // A glob here may match the trunk, and the reader does not
163                // run the forge's matcher, so only a literal other name is
164                // proven harmless.
165                "branches-ignore" => {
166                    if items.iter().any(|item| covers_trunk(item) || is_glob(item)) {
167                        trigger.misses_trunk =
168                            Some(format!("branches-ignore: [{}]", items.join(", ")));
169                    }
170                }
171                "types" => {
172                    let needed = ["opened", "synchronize", "reopened"];
173                    if !needed
174                        .iter()
175                        .all(|kind| items.iter().any(|item| item == kind))
176                    {
177                        trigger.types_filtered = Some(format!("types: [{}]", items.join(", ")));
178                    }
179                }
180                _ => {}
181            }
182        }
183        trigger
184    }
185
186    /// Fold a second request event's filters in: a filter on either event
187    /// is reported.
188    fn merge(&mut self, other: Self) {
189        self.paths_filtered |= other.paths_filtered;
190        if self.misses_trunk.is_none() {
191            self.misses_trunk = other.misses_trunk;
192        }
193        if self.types_filtered.is_none() {
194            self.types_filtered = other.types_filtered;
195        }
196    }
197}
198
199/// Whether a branch pattern names the trunk: its exact name, or a glob
200/// that matches every branch. Any other glob is not proven to.
201fn covers_trunk(pattern: &str) -> bool {
202    pattern == TRUNK_BRANCH || pattern == "*" || pattern == "**"
203}
204
205/// Whether a branch pattern carries a glob or negation character, so its
206/// matches are the forge's to decide, not this reader's.
207fn is_glob(pattern: &str) -> bool {
208    pattern.contains(['*', '?', '[', ']', '+', '!'])
209}
210
211/// One workflow file that runs on a pull request.
212struct Workflow {
213    name: String,
214    trigger: Trigger,
215    jobs: Vec<Job>,
216}
217
218/// Read every workflow under the target's `.github/workflows` and judge
219/// the named check against the jobs that report on a pull request.
220#[must_use]
221pub fn read_gate(target: &Utf8Path, required_check: &str) -> GateReport {
222    let (workflows, unreadable) = read_workflows(&target.join(".github/workflows"));
223    let mut report = GateReport {
224        reading: GateReading::NoRequestWorkflows,
225        gate_condition: None,
226        gate_trigger: Trigger::default(),
227        unreadable,
228    };
229    if workflows.iter().all(|workflow| workflow.jobs.is_empty()) {
230        return report;
231    }
232    judge(&mut report, &workflows, required_check);
233    report
234}
235
236/// Every request-running workflow under the directory, in name order, and
237/// every path that could not be read. A missing directory is neither: a
238/// target with no workflows reads as none, not as unreadable.
239fn read_workflows(dir: &Utf8Path) -> (Vec<Workflow>, Vec<String>) {
240    let mut unreadable: Vec<String> = Vec::new();
241    let mut workflows: Vec<Workflow> = Vec::new();
242    match std::fs::read_dir(dir) {
243        Ok(entries) => {
244            let mut names: Vec<String> = Vec::new();
245            for entry in entries {
246                match entry {
247                    Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
248                    Err(_) => unreadable.push(dir.to_string()),
249                }
250            }
251            names.sort();
252            for name in names {
253                let is_workflow = std::path::Path::new(&name)
254                    .extension()
255                    .is_some_and(|ext| ext == "yml" || ext == "yaml");
256                if !is_workflow {
257                    continue;
258                }
259                let Ok(text) = std::fs::read_to_string(dir.join(&name)) else {
260                    unreadable.push(name);
261                    continue;
262                };
263                if let Some(trigger) = request_trigger(&text) {
264                    workflows.push(Workflow {
265                        name,
266                        trigger,
267                        jobs: jobs(&text),
268                    });
269                }
270            }
271        }
272        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
273        Err(_) => unreadable.push(dir.to_string()),
274    }
275    (workflows, unreadable)
276}
277
278/// The gate judgment over workflows that declare at least one job.
279fn judge(report: &mut GateReport, workflows: &[Workflow], required_check: &str) {
280    let Some((workflow, gate)) = workflows.iter().find_map(|workflow| {
281        workflow
282            .jobs
283            .iter()
284            .find(|job| job.context() == Some(required_check))
285            .map(|job| (workflow, job))
286    }) else {
287        let unproven = workflows
288            .iter()
289            .flat_map(|workflow| &workflow.jobs)
290            .find(|job| job.id == required_check && job.context().is_none());
291        report.reading = unproven.map_or_else(
292            || GateReading::NoSuchJob {
293                contexts: workflows
294                    .iter()
295                    .flat_map(|workflow| workflow.jobs.iter().map(Job::listing))
296                    .collect(),
297            },
298            |job| GateReading::UnprovenGateName {
299                job: job.id.clone(),
300            },
301        );
302        return;
303    };
304    report.gate_condition = Some(gate.condition.clone());
305    report.gate_trigger = workflow.trigger.clone();
306    // A `needs` entry is a job id, and ids are per workflow file, so the
307    // gated jobs resolve inside the gate's own file.
308    let gated: Vec<&Job> = match &gate.needs {
309        Needs::Opaque => {
310            report.reading = GateReading::OpaqueNeeds {
311                workflow: workflow.name.clone(),
312            };
313            return;
314        }
315        Needs::None => Vec::new(),
316        Needs::Listed(ids) => ids
317            .iter()
318            .filter_map(|id| workflow.jobs.iter().find(|job| &job.id == id))
319            .collect(),
320    };
321    let mut ungated: Vec<String> = Vec::new();
322    for job in workflows.iter().flat_map(|workflow| &workflow.jobs) {
323        if std::ptr::eq(job, gate)
324            || job.context() == Some(TITLE_CHECK)
325            || gated.iter().any(|needed| std::ptr::eq(*needed, job))
326        {
327            continue;
328        }
329        let listing = job.listing();
330        if !ungated.contains(&listing) {
331            ungated.push(listing);
332        }
333    }
334    report.reading = if ungated.is_empty() {
335        GateReading::Gated
336    } else {
337        GateReading::Ungated { jobs: ungated }
338    };
339}
340
341/// The one-line limitation a report earns, or nothing where the check
342/// stands for every request-reporting job.
343#[must_use]
344pub fn limitation(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} cannot be satisfied"
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 cannot be satisfied; the request-reporting contexts 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 the required check {required_check} is not proven to exist"
357        )),
358        GateReading::OpaqueNeeds { workflow } => parts.push(format!(
359            "the needs value of {required_check} in {workflow} is one this reader does not follow; whether every request-reporting job is gated could not be read"
360        )),
361        GateReading::Ungated { jobs } => parts.push(format!(
362            "the required check {required_check} gates nothing from [{}]: those jobs report on a pull request but the gate does not need them, so a failure there does not hold the merge",
363            jobs.join(", ")
364        )),
365    }
366    match &report.gate_condition {
367        None | Some(Condition::Always) => {}
368        Some(Condition::Absent) => parts.push(format!(
369            "the job {required_check} runs without if: always(), so a needed job that fails skips it and the skip reports success"
370        )),
371        Some(Condition::Other(expression)) => parts.push(format!(
372            "the job {required_check} runs under the condition {expression}, which this reader cannot prove holds when a needed job fails; a bare always() is the proven form"
373        )),
374    }
375    if report.gate_trigger.paths_filtered {
376        parts.push(format!(
377            "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"
378        ));
379    }
380    if let Some(filter) = &report.gate_trigger.misses_trunk {
381        parts.push(format!(
382            "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"
383        ));
384    }
385    if let Some(filter) = &report.gate_trigger.types_filtered {
386        parts.push(format!(
387            "the pull_request trigger of the workflow carrying {required_check} reads {filter}, so an opened, reopened, or synchronized request outside those types never reports the check"
388        ));
389    }
390    if !report.unreadable.is_empty() {
391        parts.push(format!(
392            "[{}] could not be read, so the jobs there are not judged",
393            report.unreadable.join(", ")
394        ));
395    }
396    (!parts.is_empty()).then(|| parts.join("; "))
397}
398
399/// The workflow's pull-request trigger, in the block, the flow, the
400/// scalar, or the block-list form of `on`, where it has one, with the
401/// filters a block-form event carries under it.
402fn request_trigger(workflow: &str) -> Option<Trigger> {
403    let mut in_on = false;
404    let mut event_indent: Option<usize> = None;
405    let mut in_request_event = false;
406    let mut filter_indent: Option<usize> = None;
407    let mut filters: Vec<(String, Vec<String>)> = Vec::new();
408    let mut found: Option<Trigger> = None;
409    let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
410        if let Some(trigger) = found {
411            trigger.merge(Trigger::from_filters(filters));
412        }
413        filters.clear();
414    };
415    for line in workflow.lines() {
416        if is_blank(line) {
417            continue;
418        }
419        let depth = indent(line);
420        if depth == 0 {
421            if in_request_event {
422                close_event(&mut filters, &mut found);
423            }
424            in_on = false;
425            in_request_event = false;
426            event_indent = None;
427            let Some((key, value)) = key_value(line) else {
428                continue;
429            };
430            if key != "on" {
431                continue;
432            }
433            if value.is_empty() {
434                in_on = true;
435                continue;
436            }
437            if list_items(value).iter().any(|item| is_request_event(item)) {
438                found.get_or_insert_with(Trigger::default);
439            }
440            continue;
441        }
442        if !in_on {
443            continue;
444        }
445        let event_depth = *event_indent.get_or_insert(depth);
446        if depth == event_depth {
447            if in_request_event {
448                close_event(&mut filters, &mut found);
449            }
450            filter_indent = None;
451            let item = line.trim_start();
452            let item = item.strip_prefix("- ").map_or(item, str::trim_start);
453            let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
454            in_request_event = is_request_event(key);
455            if in_request_event {
456                found.get_or_insert_with(Trigger::default);
457            }
458            continue;
459        }
460        if !in_request_event || depth <= event_depth {
461            continue;
462        }
463        let filter_depth = *filter_indent.get_or_insert(depth);
464        if depth == filter_depth {
465            if let Some((key, value)) = key_value(line) {
466                let items = if value.is_empty() {
467                    Vec::new()
468                } else {
469                    list_items(value).into_iter().map(str::to_owned).collect()
470                };
471                filters.push((key.to_owned(), items));
472            }
473            continue;
474        }
475        // A block-list item under the last filter key.
476        if let Some(item) = line.trim_start().strip_prefix("- ") {
477            if let Some((_, items)) = filters.last_mut() {
478                items.push(unquote(before_comment(item).trim()).to_owned());
479            }
480        }
481    }
482    if in_request_event {
483        close_event(&mut filters, &mut found);
484    }
485    found
486}
487
488fn is_request_event(name: &str) -> bool {
489    matches!(name, "pull_request" | "pull_request_target")
490}
491
492/// The jobs the workflow declares under its top-level `jobs` key, with
493/// the properties the gate judgment reads. Steps and every deeper mapping
494/// are passed over, so a `jobs` key nested in a reusable-workflow call or
495/// a matrix opens no job.
496fn jobs(workflow: &str) -> Vec<Job> {
497    let mut found: Vec<Job> = Vec::new();
498    let mut in_jobs = false;
499    let mut job_indent: Option<usize> = None;
500    let mut property_indent: Option<usize> = None;
501    let mut reading_needs_list = false;
502    for line in workflow.lines() {
503        if is_blank(line) {
504            continue;
505        }
506        let depth = indent(line);
507        if depth == 0 {
508            in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
509            job_indent = None;
510            property_indent = None;
511            reading_needs_list = false;
512            continue;
513        }
514        if !in_jobs {
515            continue;
516        }
517        let job_depth = *job_indent.get_or_insert(depth);
518        if depth == job_depth {
519            reading_needs_list = false;
520            property_indent = None;
521            if let Some((id, _)) = key_value(line) {
522                found.push(Job {
523                    id: id.to_owned(),
524                    name: Name::Id,
525                    reusable: false,
526                    needs: Needs::None,
527                    condition: Condition::Absent,
528                });
529            }
530            continue;
531        }
532        if depth < job_depth {
533            continue;
534        }
535        let Some(job) = found.last_mut() else {
536            continue;
537        };
538        let property_depth = *property_indent.get_or_insert(depth);
539        if reading_needs_list && depth > property_depth {
540            if let Some(item) = line.trim_start().strip_prefix("- ") {
541                if let Needs::Listed(ids) = &mut job.needs {
542                    ids.push(unquote(before_comment(item).trim()).to_owned());
543                }
544                continue;
545            }
546        }
547        reading_needs_list = false;
548        if depth != property_depth {
549            continue;
550        }
551        let Some((key, value)) = key_value(line) else {
552            continue;
553        };
554        match key {
555            "name" => {
556                let value = unquote(before_comment(value).trim());
557                // A name built from an expression resolves per run, so
558                // the context it reports is not in the file.
559                if value.contains("${{") || value.is_empty() {
560                    job.name = Name::Unproven;
561                } else {
562                    job.name = Name::Fixed(value.to_owned());
563                }
564            }
565            // A reusable-workflow call reports the called jobs' contexts,
566            // named after both the caller and the callee, whatever name
567            // the caller sets and in whatever key order.
568            "uses" => job.reusable = true,
569            "if" => job.condition = condition(value),
570            "needs" => {
571                let value = before_comment(value).trim();
572                if value.is_empty() {
573                    job.needs = Needs::Listed(Vec::new());
574                    reading_needs_list = true;
575                } else if value.starts_with(['|', '>', '*', '&', '$']) {
576                    job.needs = Needs::Opaque;
577                } else {
578                    job.needs =
579                        Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
580                }
581            }
582            _ => {}
583        }
584    }
585    found
586}
587
588/// A job's `if` value: a bare `always()`, with or without the expression
589/// braces, is the one form proven to run on a failed dependency. Anything
590/// else is carried verbatim, because `always() && x` skips when `x` is
591/// false and a skipped job reports success.
592fn condition(value: &str) -> Condition {
593    let value = unquote(before_comment(value).trim());
594    let inner = value
595        .strip_prefix("${{")
596        .and_then(|rest| rest.strip_suffix("}}"))
597        .map_or(value, str::trim);
598    if inner == "always()" {
599        Condition::Always
600    } else if inner.is_empty() {
601        Condition::Other("(a value carried on another line)".to_owned())
602    } else {
603        Condition::Other(inner.to_owned())
604    }
605}
606
607/// A scalar or a flow list, as its items: `a`, `[a, b]`, or `"a"`. The
608/// outer brackets alone delimit the list, and a comma inside a quoted
609/// scalar separates nothing, so a bracketed glob such as `'ma[as]ter'`
610/// stays one item and reaches the judgment whole.
611fn list_items(value: &str) -> Vec<&str> {
612    let value = before_comment(value).trim();
613    let inner = value
614        .strip_prefix('[')
615        .and_then(|rest| rest.strip_suffix(']'))
616        .unwrap_or(value);
617    let mut items = Vec::new();
618    let mut quote: Option<char> = None;
619    let mut escaped = false;
620    let mut start = 0;
621    for (index, character) in inner.char_indices() {
622        if let Some(open) = quote {
623            // A double-quoted scalar escapes with a backslash, so the
624            // quote after one is content, not the close.
625            if escaped {
626                escaped = false;
627            } else if open == QUOTES[0] && character == '\\' {
628                escaped = true;
629            } else if character == open {
630                quote = None;
631            }
632        } else if QUOTES.contains(&character) {
633            quote = Some(character);
634        } else if character == ',' {
635            items.push(&inner[start..index]);
636            start = index + 1;
637        }
638    }
639    items.push(&inner[start..]);
640    items
641        .into_iter()
642        .map(|item| unquote(item.trim()))
643        .filter(|item| !item.is_empty())
644        .collect()
645}
646
647/// A `key: value` line split at its first mapping colon, the key bare or
648/// quoted as YAML permits for an implicit key.
649fn key_value(line: &str) -> Option<(&str, &str)> {
650    let line = line.trim();
651    let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
652        let quote = line.chars().next()?;
653        let end = quoted.find(quote)?;
654        (&quoted[..end], quoted[end + 1..].trim_start())
655    } else {
656        let end = line.find(':')?;
657        (&line[..end], &line[end..])
658    };
659    let value = rest.strip_prefix(':')?;
660    if !(value.is_empty() || value.starts_with([' ', '\t'])) {
661        return None;
662    }
663    let key = key.trim();
664    if key.is_empty() || key.contains([' ', '\t']) {
665        return None;
666    }
667    Some((key, value.trim()))
668}
669
670/// The two quote characters a YAML scalar is written with, named by code
671/// point because the artifact-body scan reads a lone quote in these
672/// sources as a literal opening.
673const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
674
675fn unquote(value: &str) -> &str {
676    value
677        .strip_prefix(QUOTES[0])
678        .and_then(|rest| rest.strip_suffix(QUOTES[0]))
679        .or_else(|| {
680            value
681                .strip_prefix('\'')
682                .and_then(|rest| rest.strip_suffix('\''))
683        })
684        .unwrap_or(value)
685}
686
687fn indent(line: &str) -> usize {
688    line.len() - line.trim_start_matches(' ').len()
689}
690
691fn is_blank(line: &str) -> bool {
692    let trimmed = line.trim();
693    trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
694}
695
696#[cfg(test)]
697mod tests {
698    #![allow(clippy::expect_used)]
699
700    use super::*;
701
702    fn report(text: &str, check: &str) -> GateReport {
703        let dir = tempfile::tempdir().expect("a tempdir");
704        let workflows = dir.path().join(".github/workflows");
705        std::fs::create_dir_all(&workflows).expect("the workflows dir");
706        std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
707        read_gate(
708            Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
709            check,
710        )
711    }
712
713    fn unfiltered() -> Trigger {
714        Trigger::default()
715    }
716
717    #[test]
718    fn the_trigger_is_read_in_every_on_form() {
719        assert_eq!(
720            request_trigger("on:\n  push:\n  pull_request:\n    branches: [master]\n"),
721            Some(unfiltered())
722        );
723        assert_eq!(
724            request_trigger("on:\n  push:\n  pull_request:\n    branches: [main]\n"),
725            Some(Trigger {
726                misses_trunk: Some("branches: [main]".to_owned()),
727                ..Trigger::default()
728            })
729        );
730        assert_eq!(
731            request_trigger(
732                "on:\n  pull_request:\n    branches-ignore:\n      - master\n    types: [opened]\n"
733            ),
734            Some(Trigger {
735                misses_trunk: Some("branches-ignore: [master]".to_owned()),
736                types_filtered: Some("types: [opened]".to_owned()),
737                ..Trigger::default()
738            })
739        );
740        assert_eq!(
741            request_trigger(
742                "on:\n  pull_request:\n    branches: ['**']\n    types: [opened, synchronize, reopened]\n"
743            ),
744            Some(unfiltered())
745        );
746        assert_eq!(
747            request_trigger("on:\n  pull_request:\n    branches: ['**', '!master']\n"),
748            Some(Trigger {
749                misses_trunk: Some("branches: [**, !master]".to_owned()),
750                ..Trigger::default()
751            })
752        );
753        assert_eq!(
754            request_trigger("on:\n  pull_request:\n    branches: ['!master', '**']\n"),
755            Some(Trigger {
756                misses_trunk: Some("branches: [!master, **]".to_owned()),
757                ..Trigger::default()
758            })
759        );
760        assert_eq!(
761            request_trigger("on:\n  pull_request:\n    branches-ignore: ['mast*']\n"),
762            Some(Trigger {
763                misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
764                ..Trigger::default()
765            })
766        );
767        assert_eq!(
768            request_trigger("on:\n  pull_request:\n    branches-ignore: [dependabot]\n"),
769            Some(unfiltered())
770        );
771        assert_eq!(
772            request_trigger("on:\n  pull_request:\n    branches-ignore: ['ma[as]ter']\n"),
773            Some(Trigger {
774                misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
775                ..Trigger::default()
776            })
777        );
778        assert_eq!(
779            request_trigger(
780                "on:\n  pull_request:\n    branches: [\"release/**\", 'a,b', master]\n"
781            ),
782            Some(unfiltered())
783        );
784        assert_eq!(
785            request_trigger("on:\n  pull_request:\n    branches: [\"topic\\\",master,tail\"]\n"),
786            Some(Trigger {
787                misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
788                ..Trigger::default()
789            })
790        );
791        assert_eq!(
792            request_trigger("on: [push, pull_request]\n"),
793            Some(unfiltered())
794        );
795        assert_eq!(
796            request_trigger("on: pull_request_target\n"),
797            Some(unfiltered())
798        );
799        assert_eq!(
800            request_trigger("on:\n  - push\n  - pull_request\n"),
801            Some(unfiltered())
802        );
803        assert_eq!(
804            request_trigger("\"on\":\n  pull_request:\n"),
805            Some(unfiltered())
806        );
807        assert_eq!(request_trigger("on: push\n"), None);
808        assert_eq!(
809            request_trigger("on:\n  push:\n  workflow_dispatch:\njobs:\n  pull_request:\n"),
810            None
811        );
812        assert_eq!(
813            request_trigger("on:\n  pull_request:\n    paths:\n      - 'docs/**'\n  push:\n"),
814            Some(Trigger {
815                paths_filtered: true,
816                ..Trigger::default()
817            })
818        );
819        assert_eq!(
820            request_trigger(
821                "on:\n  push:\n    paths: [x]\n  pull_request:\n    branches: [master]\n"
822            ),
823            Some(unfiltered())
824        );
825    }
826
827    #[test]
828    fn jobs_read_names_needs_and_conditions_in_every_form() {
829        let text = "\
830jobs:
831  lint:
832    runs-on: ubuntu-latest
833  build:
834    name: \"Build it\" # the context
835    needs: lint
836  docs:
837    needs: [lint, build]
838  gate:
839    name: gate-${{ matrix.os }}
840    if: ${{ always() }}
841    needs:
842      - lint
843      - 'docs'
844    steps:
845      - uses: x@y
846        with:
847          needs: nothing
848  odd:
849    if: always() && needs.lint.result == 'success'
850    needs: ${{ fromJSON(x) }}
851  called:
852    uses: org/repo/.github/workflows/x.yml@main
853    name: called
854  named-first:
855    name: gate
856    uses: org/repo/.github/workflows/x.yml@main
857";
858        let found = jobs(text);
859        let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
860        assert_eq!(
861            ids,
862            [
863                "lint",
864                "build",
865                "docs",
866                "gate",
867                "odd",
868                "called",
869                "named-first"
870            ]
871        );
872        assert_eq!(found[0].needs, Needs::None);
873        assert_eq!(found[0].condition, Condition::Absent);
874        assert_eq!(found[1].context(), Some("Build it"));
875        assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
876        assert_eq!(
877            found[2].needs,
878            Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
879        );
880        assert_eq!(found[3].name, Name::Unproven);
881        assert_eq!(found[3].context(), None);
882        assert_eq!(found[3].condition, Condition::Always);
883        assert_eq!(
884            found[3].needs,
885            Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
886        );
887        assert_eq!(
888            found[4].condition,
889            Condition::Other("always() && needs.lint.result == 'success'".to_owned())
890        );
891        assert_eq!(found[4].needs, Needs::Opaque);
892        assert!(found[5].reusable);
893        assert_eq!(found[5].context(), None);
894        assert!(found[6].reusable);
895        assert_eq!(found[6].context(), None);
896    }
897
898    #[test]
899    fn flow_lists_keep_quoted_scalars_whole() {
900        assert_eq!(list_items("[a, b]"), ["a", "b"]);
901        assert_eq!(list_items("a"), ["a"]);
902        assert_eq!(list_items("\"a\" # c"), ["a"]);
903        assert_eq!(
904            list_items("['ma[as]ter', \"x,y\", z]"),
905            ["ma[as]ter", "x,y", "z"]
906        );
907        assert_eq!(list_items("[]"), Vec::<&str>::new());
908        assert_eq!(
909            list_items("[\"topic\\\",master,tail\", x]"),
910            ["topic\\\",master,tail", "x"]
911        );
912    }
913
914    #[test]
915    fn a_nested_jobs_key_opens_no_region() {
916        let text = "\
917jobs:
918  call:
919    uses: org/repo/.github/workflows/x.yml@main
920    with:
921      jobs: 3
922  other:
923    strategy:
924      matrix:
925        jobs: [a, b]
926";
927        let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
928        assert_eq!(ids, ["call", "other"]);
929    }
930
931    #[test]
932    fn a_condition_is_proven_only_as_a_bare_always() {
933        assert_eq!(condition("always()"), Condition::Always);
934        assert_eq!(condition("${{ always() }}"), Condition::Always);
935        assert_eq!(condition("'${{always()}}'"), Condition::Always);
936        assert_eq!(
937            condition("${{ always() && false }}"),
938            Condition::Other("always() && false".to_owned())
939        );
940        assert_eq!(
941            condition("!always()"),
942            Condition::Other("!always()".to_owned())
943        );
944        assert_eq!(
945            condition(""),
946            Condition::Other("(a value carried on another line)".to_owned())
947        );
948    }
949
950    #[test]
951    fn read_gate_partitions_the_contexts() {
952        let gated = report(
953            "on: [pull_request]\njobs:\n  lint:\n  test:\n    if: always()\n    needs: [lint]\n",
954            "test",
955        );
956        assert_eq!(gated.reading, GateReading::Gated);
957        assert_eq!(gated.gate_condition, Some(Condition::Always));
958        assert_eq!(gated.gate_trigger, Trigger::default());
959        assert!(gated.unreadable.is_empty());
960
961        let ungated = report(
962            "on: [pull_request]\njobs:\n  lint:\n  build:\n  docs:\n  pr-title:\n  test:\n    needs: lint\n",
963            "test",
964        );
965        assert_eq!(
966            ungated.reading,
967            GateReading::Ungated {
968                jobs: vec!["build".to_owned(), "docs".to_owned()]
969            }
970        );
971        assert_eq!(ungated.gate_condition, Some(Condition::Absent));
972
973        let missing = report("on: [pull_request]\njobs:\n  lint:\n  unit:\n", "test");
974        assert_eq!(
975            missing.reading,
976            GateReading::NoSuchJob {
977                contexts: vec!["lint".to_owned(), "unit".to_owned()]
978            }
979        );
980        assert_eq!(missing.gate_condition, None);
981
982        let dynamic = report(
983            "on: [pull_request]\njobs:\n  lint:\n  test:\n    name: test-${{ matrix.os }}\n    needs: [lint]\n",
984            "test",
985        );
986        assert_eq!(
987            dynamic.reading,
988            GateReading::UnprovenGateName {
989                job: "test".to_owned()
990            }
991        );
992
993        let opaque = report(
994            "on: [pull_request]\njobs:\n  lint:\n  test:\n    needs: *all\n",
995            "test",
996        );
997        assert_eq!(
998            opaque.reading,
999            GateReading::OpaqueNeeds {
1000                workflow: "ci.yml".to_owned()
1001            }
1002        );
1003
1004        let filtered = report(
1005            "on:\n  pull_request:\n    paths: ['src/**']\njobs:\n  test:\n    if: always()\n",
1006            "test",
1007        );
1008        assert_eq!(filtered.reading, GateReading::Gated);
1009        assert!(filtered.gate_trigger.paths_filtered);
1010
1011        let off_trunk = report(
1012            "on:\n  pull_request:\n    branches: [main]\njobs:\n  test:\n    if: always()\n",
1013            "test",
1014        );
1015        assert_eq!(
1016            off_trunk.gate_trigger.misses_trunk,
1017            Some("branches: [main]".to_owned())
1018        );
1019
1020        let reusable = report(
1021            "on: [pull_request]\njobs:\n  test:\n    uses: org/repo/.github/workflows/x.yml@main\n    name: test\n",
1022            "test",
1023        );
1024        assert_eq!(
1025            reusable.reading,
1026            GateReading::UnprovenGateName {
1027                job: "test".to_owned()
1028            }
1029        );
1030
1031        let push_only = report("on: push\njobs:\n  lint:\n  test:\n", "test");
1032        assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1033
1034        let dir = tempfile::tempdir().expect("a tempdir");
1035        let empty = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1036        assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1037        assert!(empty.unreadable.is_empty());
1038    }
1039
1040    #[test]
1041    fn an_unreadable_workflow_is_named_not_skipped() {
1042        let dir = tempfile::tempdir().expect("a tempdir");
1043        let workflows = dir.path().join(".github/workflows");
1044        std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1045        std::fs::write(
1046            workflows.join("ci.yml"),
1047            "on: [pull_request]\njobs:\n  test:\n    if: always()\n",
1048        )
1049        .expect("the workflow writes");
1050        let report = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1051        assert_eq!(report.reading, GateReading::Gated);
1052        assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1053        let text = limitation(&report, "test").expect("a limitation");
1054        assert!(text.contains("[broken.yml] could not be read"), "{text}");
1055    }
1056
1057    #[test]
1058    fn limitation_texts_are_one_line_each() {
1059        let base = || GateReport {
1060            reading: GateReading::Gated,
1061            gate_condition: Some(Condition::Always),
1062            gate_trigger: Trigger::default(),
1063            unreadable: Vec::new(),
1064        };
1065        assert_eq!(limitation(&base(), "test"), None);
1066        let cases = [
1067            GateReport {
1068                reading: GateReading::NoRequestWorkflows,
1069                gate_condition: None,
1070                ..base()
1071            },
1072            GateReport {
1073                reading: GateReading::NoSuchJob {
1074                    contexts: vec!["lint".to_owned()],
1075                },
1076                gate_condition: None,
1077                ..base()
1078            },
1079            GateReport {
1080                reading: GateReading::UnprovenGateName {
1081                    job: "test".to_owned(),
1082                },
1083                gate_condition: None,
1084                ..base()
1085            },
1086            GateReport {
1087                reading: GateReading::OpaqueNeeds {
1088                    workflow: "ci.yml".to_owned(),
1089                },
1090                gate_condition: Some(Condition::Absent),
1091                ..base()
1092            },
1093            GateReport {
1094                reading: GateReading::Ungated {
1095                    jobs: vec!["a".to_owned(), "b".to_owned()],
1096                },
1097                gate_condition: Some(Condition::Other("always() && x".to_owned())),
1098                ..base()
1099            },
1100            GateReport {
1101                gate_trigger: Trigger {
1102                    paths_filtered: true,
1103                    misses_trunk: Some("branches: [main]".to_owned()),
1104                    types_filtered: Some("types: [opened]".to_owned()),
1105                },
1106                ..base()
1107            },
1108            GateReport {
1109                unreadable: vec!["x.yml".to_owned()],
1110                ..base()
1111            },
1112        ];
1113        for case in &cases {
1114            let text = limitation(case, "test").expect("a limitation");
1115            assert!(!text.contains('\n'), "{text}");
1116            assert!(
1117                text.starts_with(|c: char| c.is_lowercase() || c == '['),
1118                "{text}"
1119            );
1120        }
1121    }
1122}