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.
402///
403/// The landing invariant reads it too, to ask whether a generated
404/// workflow reports on a request at all: one reader owns the forms `on`
405/// takes, so a form one of them learns is a form both know.
406pub(crate) fn request_trigger(workflow: &str) -> Option<Trigger> {
407    let mut in_on = false;
408    let mut event_indent: Option<usize> = None;
409    let mut in_request_event = false;
410    let mut filter_indent: Option<usize> = None;
411    let mut filters: Vec<(String, Vec<String>)> = Vec::new();
412    let mut found: Option<Trigger> = None;
413    let close_event = |filters: &mut Vec<(String, Vec<String>)>, found: &mut Option<Trigger>| {
414        if let Some(trigger) = found {
415            trigger.merge(Trigger::from_filters(filters));
416        }
417        filters.clear();
418    };
419    for line in workflow.lines() {
420        if is_blank(line) {
421            continue;
422        }
423        let depth = indent(line);
424        if depth == 0 {
425            if in_request_event {
426                close_event(&mut filters, &mut found);
427            }
428            in_on = false;
429            in_request_event = false;
430            event_indent = None;
431            let Some((key, value)) = key_value(line) else {
432                continue;
433            };
434            if key != "on" {
435                continue;
436            }
437            if value.is_empty() {
438                in_on = true;
439                continue;
440            }
441            if list_items(value).iter().any(|item| is_request_event(item)) {
442                found.get_or_insert_with(Trigger::default);
443            }
444            continue;
445        }
446        if !in_on {
447            continue;
448        }
449        let event_depth = *event_indent.get_or_insert(depth);
450        if depth == event_depth {
451            if in_request_event {
452                close_event(&mut filters, &mut found);
453            }
454            filter_indent = None;
455            let item = line.trim_start();
456            let item = item.strip_prefix("- ").map_or(item, str::trim_start);
457            let key = key_value(item).map_or_else(|| before_comment(item).trim(), |(key, _)| key);
458            in_request_event = is_request_event(key);
459            if in_request_event {
460                found.get_or_insert_with(Trigger::default);
461            }
462            continue;
463        }
464        if !in_request_event || depth <= event_depth {
465            continue;
466        }
467        let filter_depth = *filter_indent.get_or_insert(depth);
468        if depth == filter_depth {
469            if let Some((key, value)) = key_value(line) {
470                let items = if value.is_empty() {
471                    Vec::new()
472                } else {
473                    list_items(value).into_iter().map(str::to_owned).collect()
474                };
475                filters.push((key.to_owned(), items));
476            }
477            continue;
478        }
479        // A block-list item under the last filter key.
480        if let Some(item) = line.trim_start().strip_prefix("- ") {
481            if let Some((_, items)) = filters.last_mut() {
482                items.push(unquote(before_comment(item).trim()).to_owned());
483            }
484        }
485    }
486    if in_request_event {
487        close_event(&mut filters, &mut found);
488    }
489    found
490}
491
492fn is_request_event(name: &str) -> bool {
493    matches!(name, "pull_request" | "pull_request_target")
494}
495
496/// The jobs the workflow declares under its top-level `jobs` key, with
497/// the properties the gate judgment reads. Steps and every deeper mapping
498/// are passed over, so a `jobs` key nested in a reusable-workflow call or
499/// a matrix opens no job.
500fn jobs(workflow: &str) -> Vec<Job> {
501    let mut found: Vec<Job> = Vec::new();
502    let mut in_jobs = false;
503    let mut job_indent: Option<usize> = None;
504    let mut property_indent: Option<usize> = None;
505    let mut reading_needs_list = false;
506    for line in workflow.lines() {
507        if is_blank(line) {
508            continue;
509        }
510        let depth = indent(line);
511        if depth == 0 {
512            in_jobs = key_value(line).is_some_and(|(key, value)| key == "jobs" && value.is_empty());
513            job_indent = None;
514            property_indent = None;
515            reading_needs_list = false;
516            continue;
517        }
518        if !in_jobs {
519            continue;
520        }
521        let job_depth = *job_indent.get_or_insert(depth);
522        if depth == job_depth {
523            reading_needs_list = false;
524            property_indent = None;
525            if let Some((id, _)) = key_value(line) {
526                found.push(Job {
527                    id: id.to_owned(),
528                    name: Name::Id,
529                    reusable: false,
530                    needs: Needs::None,
531                    condition: Condition::Absent,
532                });
533            }
534            continue;
535        }
536        if depth < job_depth {
537            continue;
538        }
539        let Some(job) = found.last_mut() else {
540            continue;
541        };
542        let property_depth = *property_indent.get_or_insert(depth);
543        if reading_needs_list && depth > property_depth {
544            if let Some(item) = line.trim_start().strip_prefix("- ") {
545                if let Needs::Listed(ids) = &mut job.needs {
546                    ids.push(unquote(before_comment(item).trim()).to_owned());
547                }
548                continue;
549            }
550        }
551        reading_needs_list = false;
552        if depth != property_depth {
553            continue;
554        }
555        let Some((key, value)) = key_value(line) else {
556            continue;
557        };
558        match key {
559            "name" => {
560                let value = unquote(before_comment(value).trim());
561                // A name built from an expression resolves per run, so
562                // the context it reports is not in the file.
563                if value.contains("${{") || value.is_empty() {
564                    job.name = Name::Unproven;
565                } else {
566                    job.name = Name::Fixed(value.to_owned());
567                }
568            }
569            // A reusable-workflow call reports the called jobs' contexts,
570            // named after both the caller and the callee, whatever name
571            // the caller sets and in whatever key order.
572            "uses" => job.reusable = true,
573            "if" => job.condition = condition(value),
574            "needs" => {
575                let value = before_comment(value).trim();
576                if value.is_empty() {
577                    job.needs = Needs::Listed(Vec::new());
578                    reading_needs_list = true;
579                } else if value.starts_with(['|', '>', '*', '&', '$']) {
580                    job.needs = Needs::Opaque;
581                } else {
582                    job.needs =
583                        Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
584                }
585            }
586            _ => {}
587        }
588    }
589    found
590}
591
592/// A job's `if` value: a bare `always()`, with or without the expression
593/// braces, is the one form proven to run on a failed dependency. Anything
594/// else is carried verbatim, because `always() && x` skips when `x` is
595/// false and a skipped job reports success.
596fn condition(value: &str) -> Condition {
597    let value = unquote(before_comment(value).trim());
598    let inner = value
599        .strip_prefix("${{")
600        .and_then(|rest| rest.strip_suffix("}}"))
601        .map_or(value, str::trim);
602    if inner == "always()" {
603        Condition::Always
604    } else if inner.is_empty() {
605        Condition::Other("(a value carried on another line)".to_owned())
606    } else {
607        Condition::Other(inner.to_owned())
608    }
609}
610
611/// A scalar or a flow list, as its items: `a`, `[a, b]`, or `"a"`. The
612/// outer brackets alone delimit the list, and a comma inside a quoted
613/// scalar separates nothing, so a bracketed glob such as `'ma[as]ter'`
614/// stays one item and reaches the judgment whole.
615fn list_items(value: &str) -> Vec<&str> {
616    let value = before_comment(value).trim();
617    let inner = value
618        .strip_prefix('[')
619        .and_then(|rest| rest.strip_suffix(']'))
620        .unwrap_or(value);
621    let mut items = Vec::new();
622    let mut quote: Option<char> = None;
623    let mut escaped = false;
624    let mut start = 0;
625    for (index, character) in inner.char_indices() {
626        if let Some(open) = quote {
627            // A double-quoted scalar escapes with a backslash, so the
628            // quote after one is content, not the close.
629            if escaped {
630                escaped = false;
631            } else if open == QUOTES[0] && character == '\\' {
632                escaped = true;
633            } else if character == open {
634                quote = None;
635            }
636        } else if QUOTES.contains(&character) {
637            quote = Some(character);
638        } else if character == ',' {
639            items.push(&inner[start..index]);
640            start = index + 1;
641        }
642    }
643    items.push(&inner[start..]);
644    items
645        .into_iter()
646        .map(|item| unquote(item.trim()))
647        .filter(|item| !item.is_empty())
648        .collect()
649}
650
651/// A `key: value` line split at its first mapping colon, the key bare or
652/// quoted as YAML permits for an implicit key.
653fn key_value(line: &str) -> Option<(&str, &str)> {
654    let line = line.trim();
655    let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
656        let quote = line.chars().next()?;
657        let end = quoted.find(quote)?;
658        (&quoted[..end], quoted[end + 1..].trim_start())
659    } else {
660        let end = line.find(':')?;
661        (&line[..end], &line[end..])
662    };
663    let value = rest.strip_prefix(':')?;
664    if !(value.is_empty() || value.starts_with([' ', '\t'])) {
665        return None;
666    }
667    let key = key.trim();
668    if key.is_empty() || key.contains([' ', '\t']) {
669        return None;
670    }
671    Some((key, value.trim()))
672}
673
674/// The two quote characters a YAML scalar is written with, named by code
675/// point because the artifact-body scan reads a lone quote in these
676/// sources as a literal opening.
677const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
678
679fn unquote(value: &str) -> &str {
680    value
681        .strip_prefix(QUOTES[0])
682        .and_then(|rest| rest.strip_suffix(QUOTES[0]))
683        .or_else(|| {
684            value
685                .strip_prefix('\'')
686                .and_then(|rest| rest.strip_suffix('\''))
687        })
688        .unwrap_or(value)
689}
690
691fn indent(line: &str) -> usize {
692    line.len() - line.trim_start_matches(' ').len()
693}
694
695fn is_blank(line: &str) -> bool {
696    let trimmed = line.trim();
697    trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
698}
699
700#[cfg(test)]
701mod tests {
702    #![allow(clippy::expect_used)]
703
704    use super::*;
705
706    fn report(text: &str, check: &str) -> GateReport {
707        let dir = tempfile::tempdir().expect("a tempdir");
708        let workflows = dir.path().join(".github/workflows");
709        std::fs::create_dir_all(&workflows).expect("the workflows dir");
710        std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
711        read_gate(
712            Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
713            check,
714        )
715    }
716
717    fn unfiltered() -> Trigger {
718        Trigger::default()
719    }
720
721    #[test]
722    fn the_trigger_is_read_in_every_on_form() {
723        assert_eq!(
724            request_trigger("on:\n  push:\n  pull_request:\n    branches: [master]\n"),
725            Some(unfiltered())
726        );
727        assert_eq!(
728            request_trigger("on:\n  push:\n  pull_request:\n    branches: [main]\n"),
729            Some(Trigger {
730                misses_trunk: Some("branches: [main]".to_owned()),
731                ..Trigger::default()
732            })
733        );
734        assert_eq!(
735            request_trigger(
736                "on:\n  pull_request:\n    branches-ignore:\n      - master\n    types: [opened]\n"
737            ),
738            Some(Trigger {
739                misses_trunk: Some("branches-ignore: [master]".to_owned()),
740                types_filtered: Some("types: [opened]".to_owned()),
741                ..Trigger::default()
742            })
743        );
744        assert_eq!(
745            request_trigger(
746                "on:\n  pull_request:\n    branches: ['**']\n    types: [opened, synchronize, reopened]\n"
747            ),
748            Some(unfiltered())
749        );
750        assert_eq!(
751            request_trigger("on:\n  pull_request:\n    branches: ['**', '!master']\n"),
752            Some(Trigger {
753                misses_trunk: Some("branches: [**, !master]".to_owned()),
754                ..Trigger::default()
755            })
756        );
757        assert_eq!(
758            request_trigger("on:\n  pull_request:\n    branches: ['!master', '**']\n"),
759            Some(Trigger {
760                misses_trunk: Some("branches: [!master, **]".to_owned()),
761                ..Trigger::default()
762            })
763        );
764        assert_eq!(
765            request_trigger("on:\n  pull_request:\n    branches-ignore: ['mast*']\n"),
766            Some(Trigger {
767                misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
768                ..Trigger::default()
769            })
770        );
771        assert_eq!(
772            request_trigger("on:\n  pull_request:\n    branches-ignore: [dependabot]\n"),
773            Some(unfiltered())
774        );
775        assert_eq!(
776            request_trigger("on:\n  pull_request:\n    branches-ignore: ['ma[as]ter']\n"),
777            Some(Trigger {
778                misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
779                ..Trigger::default()
780            })
781        );
782        assert_eq!(
783            request_trigger(
784                "on:\n  pull_request:\n    branches: [\"release/**\", 'a,b', master]\n"
785            ),
786            Some(unfiltered())
787        );
788        assert_eq!(
789            request_trigger("on:\n  pull_request:\n    branches: [\"topic\\\",master,tail\"]\n"),
790            Some(Trigger {
791                misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
792                ..Trigger::default()
793            })
794        );
795        assert_eq!(
796            request_trigger("on: [push, pull_request]\n"),
797            Some(unfiltered())
798        );
799        assert_eq!(
800            request_trigger("on: pull_request_target\n"),
801            Some(unfiltered())
802        );
803        assert_eq!(
804            request_trigger("on:\n  - push\n  - pull_request\n"),
805            Some(unfiltered())
806        );
807        assert_eq!(
808            request_trigger("\"on\":\n  pull_request:\n"),
809            Some(unfiltered())
810        );
811        assert_eq!(request_trigger("on: push\n"), None);
812        assert_eq!(
813            request_trigger("on:\n  push:\n  workflow_dispatch:\njobs:\n  pull_request:\n"),
814            None
815        );
816        assert_eq!(
817            request_trigger("on:\n  pull_request:\n    paths:\n      - 'docs/**'\n  push:\n"),
818            Some(Trigger {
819                paths_filtered: true,
820                ..Trigger::default()
821            })
822        );
823        assert_eq!(
824            request_trigger(
825                "on:\n  push:\n    paths: [x]\n  pull_request:\n    branches: [master]\n"
826            ),
827            Some(unfiltered())
828        );
829    }
830
831    #[test]
832    fn jobs_read_names_needs_and_conditions_in_every_form() {
833        let text = "\
834jobs:
835  lint:
836    runs-on: ubuntu-latest
837  build:
838    name: \"Build it\" # the context
839    needs: lint
840  docs:
841    needs: [lint, build]
842  gate:
843    name: gate-${{ matrix.os }}
844    if: ${{ always() }}
845    needs:
846      - lint
847      - 'docs'
848    steps:
849      - uses: x@y
850        with:
851          needs: nothing
852  odd:
853    if: always() && needs.lint.result == 'success'
854    needs: ${{ fromJSON(x) }}
855  called:
856    uses: org/repo/.github/workflows/x.yml@main
857    name: called
858  named-first:
859    name: gate
860    uses: org/repo/.github/workflows/x.yml@main
861";
862        let found = jobs(text);
863        let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
864        assert_eq!(
865            ids,
866            [
867                "lint",
868                "build",
869                "docs",
870                "gate",
871                "odd",
872                "called",
873                "named-first"
874            ]
875        );
876        assert_eq!(found[0].needs, Needs::None);
877        assert_eq!(found[0].condition, Condition::Absent);
878        assert_eq!(found[1].context(), Some("Build it"));
879        assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
880        assert_eq!(
881            found[2].needs,
882            Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
883        );
884        assert_eq!(found[3].name, Name::Unproven);
885        assert_eq!(found[3].context(), None);
886        assert_eq!(found[3].condition, Condition::Always);
887        assert_eq!(
888            found[3].needs,
889            Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
890        );
891        assert_eq!(
892            found[4].condition,
893            Condition::Other("always() && needs.lint.result == 'success'".to_owned())
894        );
895        assert_eq!(found[4].needs, Needs::Opaque);
896        assert!(found[5].reusable);
897        assert_eq!(found[5].context(), None);
898        assert!(found[6].reusable);
899        assert_eq!(found[6].context(), None);
900    }
901
902    #[test]
903    fn flow_lists_keep_quoted_scalars_whole() {
904        assert_eq!(list_items("[a, b]"), ["a", "b"]);
905        assert_eq!(list_items("a"), ["a"]);
906        assert_eq!(list_items("\"a\" # c"), ["a"]);
907        assert_eq!(
908            list_items("['ma[as]ter', \"x,y\", z]"),
909            ["ma[as]ter", "x,y", "z"]
910        );
911        assert_eq!(list_items("[]"), Vec::<&str>::new());
912        assert_eq!(
913            list_items("[\"topic\\\",master,tail\", x]"),
914            ["topic\\\",master,tail", "x"]
915        );
916    }
917
918    #[test]
919    fn a_nested_jobs_key_opens_no_region() {
920        let text = "\
921jobs:
922  call:
923    uses: org/repo/.github/workflows/x.yml@main
924    with:
925      jobs: 3
926  other:
927    strategy:
928      matrix:
929        jobs: [a, b]
930";
931        let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
932        assert_eq!(ids, ["call", "other"]);
933    }
934
935    #[test]
936    fn a_condition_is_proven_only_as_a_bare_always() {
937        assert_eq!(condition("always()"), Condition::Always);
938        assert_eq!(condition("${{ always() }}"), Condition::Always);
939        assert_eq!(condition("'${{always()}}'"), Condition::Always);
940        assert_eq!(
941            condition("${{ always() && false }}"),
942            Condition::Other("always() && false".to_owned())
943        );
944        assert_eq!(
945            condition("!always()"),
946            Condition::Other("!always()".to_owned())
947        );
948        assert_eq!(
949            condition(""),
950            Condition::Other("(a value carried on another line)".to_owned())
951        );
952    }
953
954    #[test]
955    fn read_gate_partitions_the_contexts() {
956        let gated = report(
957            "on: [pull_request]\njobs:\n  lint:\n  test:\n    if: always()\n    needs: [lint]\n",
958            "test",
959        );
960        assert_eq!(gated.reading, GateReading::Gated);
961        assert_eq!(gated.gate_condition, Some(Condition::Always));
962        assert_eq!(gated.gate_trigger, Trigger::default());
963        assert!(gated.unreadable.is_empty());
964
965        let ungated = report(
966            "on: [pull_request]\njobs:\n  lint:\n  build:\n  docs:\n  pr-title:\n  test:\n    needs: lint\n",
967            "test",
968        );
969        assert_eq!(
970            ungated.reading,
971            GateReading::Ungated {
972                jobs: vec!["build".to_owned(), "docs".to_owned()]
973            }
974        );
975        assert_eq!(ungated.gate_condition, Some(Condition::Absent));
976
977        let missing = report("on: [pull_request]\njobs:\n  lint:\n  unit:\n", "test");
978        assert_eq!(
979            missing.reading,
980            GateReading::NoSuchJob {
981                contexts: vec!["lint".to_owned(), "unit".to_owned()]
982            }
983        );
984        assert_eq!(missing.gate_condition, None);
985
986        let dynamic = report(
987            "on: [pull_request]\njobs:\n  lint:\n  test:\n    name: test-${{ matrix.os }}\n    needs: [lint]\n",
988            "test",
989        );
990        assert_eq!(
991            dynamic.reading,
992            GateReading::UnprovenGateName {
993                job: "test".to_owned()
994            }
995        );
996
997        let opaque = report(
998            "on: [pull_request]\njobs:\n  lint:\n  test:\n    needs: *all\n",
999            "test",
1000        );
1001        assert_eq!(
1002            opaque.reading,
1003            GateReading::OpaqueNeeds {
1004                workflow: "ci.yml".to_owned()
1005            }
1006        );
1007
1008        let filtered = report(
1009            "on:\n  pull_request:\n    paths: ['src/**']\njobs:\n  test:\n    if: always()\n",
1010            "test",
1011        );
1012        assert_eq!(filtered.reading, GateReading::Gated);
1013        assert!(filtered.gate_trigger.paths_filtered);
1014
1015        let off_trunk = report(
1016            "on:\n  pull_request:\n    branches: [main]\njobs:\n  test:\n    if: always()\n",
1017            "test",
1018        );
1019        assert_eq!(
1020            off_trunk.gate_trigger.misses_trunk,
1021            Some("branches: [main]".to_owned())
1022        );
1023
1024        let reusable = report(
1025            "on: [pull_request]\njobs:\n  test:\n    uses: org/repo/.github/workflows/x.yml@main\n    name: test\n",
1026            "test",
1027        );
1028        assert_eq!(
1029            reusable.reading,
1030            GateReading::UnprovenGateName {
1031                job: "test".to_owned()
1032            }
1033        );
1034
1035        let push_only = report("on: push\njobs:\n  lint:\n  test:\n", "test");
1036        assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1037
1038        let dir = tempfile::tempdir().expect("a tempdir");
1039        let empty = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1040        assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1041        assert!(empty.unreadable.is_empty());
1042    }
1043
1044    #[test]
1045    fn an_unreadable_workflow_is_named_not_skipped() {
1046        let dir = tempfile::tempdir().expect("a tempdir");
1047        let workflows = dir.path().join(".github/workflows");
1048        std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1049        std::fs::write(
1050            workflows.join("ci.yml"),
1051            "on: [pull_request]\njobs:\n  test:\n    if: always()\n",
1052        )
1053        .expect("the workflow writes");
1054        let report = read_gate(Utf8Path::from_path(dir.path()).expect("utf-8"), "test");
1055        assert_eq!(report.reading, GateReading::Gated);
1056        assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1057        let text = limitation(&report, "test").expect("a limitation");
1058        assert!(text.contains("[broken.yml] could not be read"), "{text}");
1059    }
1060
1061    #[test]
1062    fn limitation_texts_are_one_line_each() {
1063        let base = || GateReport {
1064            reading: GateReading::Gated,
1065            gate_condition: Some(Condition::Always),
1066            gate_trigger: Trigger::default(),
1067            unreadable: Vec::new(),
1068        };
1069        assert_eq!(limitation(&base(), "test"), None);
1070        let cases = [
1071            GateReport {
1072                reading: GateReading::NoRequestWorkflows,
1073                gate_condition: None,
1074                ..base()
1075            },
1076            GateReport {
1077                reading: GateReading::NoSuchJob {
1078                    contexts: vec!["lint".to_owned()],
1079                },
1080                gate_condition: None,
1081                ..base()
1082            },
1083            GateReport {
1084                reading: GateReading::UnprovenGateName {
1085                    job: "test".to_owned(),
1086                },
1087                gate_condition: None,
1088                ..base()
1089            },
1090            GateReport {
1091                reading: GateReading::OpaqueNeeds {
1092                    workflow: "ci.yml".to_owned(),
1093                },
1094                gate_condition: Some(Condition::Absent),
1095                ..base()
1096            },
1097            GateReport {
1098                reading: GateReading::Ungated {
1099                    jobs: vec!["a".to_owned(), "b".to_owned()],
1100                },
1101                gate_condition: Some(Condition::Other("always() && x".to_owned())),
1102                ..base()
1103            },
1104            GateReport {
1105                gate_trigger: Trigger {
1106                    paths_filtered: true,
1107                    misses_trunk: Some("branches: [main]".to_owned()),
1108                    types_filtered: Some("types: [opened]".to_owned()),
1109                },
1110                ..base()
1111            },
1112            GateReport {
1113                unreadable: vec!["x.yml".to_owned()],
1114                ..base()
1115            },
1116        ];
1117        for case in &cases {
1118            let text = limitation(case, "test").expect("a limitation");
1119            assert!(!text.contains('\n'), "{text}");
1120            assert!(
1121                text.starts_with(|c: char| c.is_lowercase() || c == '['),
1122                "{text}"
1123            );
1124        }
1125    }
1126}