Skip to main content

release_kit/setup/
workflow_jobs.rs

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