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    use super::*;
725
726    fn report(text: &str, check: &str) -> GateReport {
727        let dir = tempfile::tempdir().expect("a tempdir");
728        let workflows = dir.path().join(".github/workflows");
729        std::fs::create_dir_all(&workflows).expect("the workflows dir");
730        std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
731        read_gate(
732            Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
733            check,
734            "master",
735        )
736    }
737
738    fn unfiltered() -> Trigger {
739        Trigger::default()
740    }
741
742    #[test]
743    fn the_trigger_is_read_in_every_on_form() {
744        assert_eq!(
745            request_trigger(
746                "on:\n  push:\n  pull_request:\n    branches: [master]\n",
747                "master"
748            ),
749            Some(unfiltered())
750        );
751        assert_eq!(
752            request_trigger(
753                "on:\n  push:\n  pull_request:\n    branches: [main]\n",
754                "master"
755            ),
756            Some(Trigger {
757                misses_trunk: Some("branches: [main]".to_owned()),
758                ..Trigger::default()
759            })
760        );
761        assert_eq!(
762            request_trigger(
763                "on:\n  pull_request:\n    branches-ignore:\n      - master\n    types: [opened]\n",
764                "master"
765            ),
766            Some(Trigger {
767                misses_trunk: Some("branches-ignore: [master]".to_owned()),
768                types_filtered: Some("types: [opened]".to_owned()),
769                ..Trigger::default()
770            })
771        );
772        assert_eq!(
773            request_trigger(
774                "on:\n  pull_request:\n    branches: ['**']\n    types: [opened, synchronize, reopened]\n",
775                "master"
776            ),
777            Some(unfiltered())
778        );
779        assert_eq!(
780            request_trigger(
781                "on:\n  pull_request:\n    branches: ['**', '!master']\n",
782                "master"
783            ),
784            Some(Trigger {
785                misses_trunk: Some("branches: [**, !master]".to_owned()),
786                ..Trigger::default()
787            })
788        );
789        assert_eq!(
790            request_trigger(
791                "on:\n  pull_request:\n    branches: ['!master', '**']\n",
792                "master"
793            ),
794            Some(Trigger {
795                misses_trunk: Some("branches: [!master, **]".to_owned()),
796                ..Trigger::default()
797            })
798        );
799        assert_eq!(
800            request_trigger(
801                "on:\n  pull_request:\n    branches-ignore: ['mast*']\n",
802                "master"
803            ),
804            Some(Trigger {
805                misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
806                ..Trigger::default()
807            })
808        );
809        assert_eq!(
810            request_trigger(
811                "on:\n  pull_request:\n    branches-ignore: [dependabot]\n",
812                "master"
813            ),
814            Some(unfiltered())
815        );
816        assert_eq!(
817            request_trigger(
818                "on:\n  pull_request:\n    branches-ignore: ['ma[as]ter']\n",
819                "master"
820            ),
821            Some(Trigger {
822                misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
823                ..Trigger::default()
824            })
825        );
826        assert_eq!(
827            request_trigger(
828                "on:\n  pull_request:\n    branches: [\"release/**\", 'a,b', master]\n",
829                "master"
830            ),
831            Some(unfiltered())
832        );
833        assert_eq!(
834            request_trigger(
835                "on:\n  pull_request:\n    branches: [\"topic\\\",master,tail\"]\n",
836                "master"
837            ),
838            Some(Trigger {
839                misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
840                ..Trigger::default()
841            })
842        );
843        assert_eq!(
844            request_trigger("on: [push, pull_request]\n", "master"),
845            Some(unfiltered())
846        );
847        assert_eq!(
848            request_trigger("on: pull_request_target\n", "master"),
849            Some(unfiltered())
850        );
851        assert_eq!(
852            request_trigger("on:\n  - push\n  - pull_request\n", "master"),
853            Some(unfiltered())
854        );
855        assert_eq!(
856            request_trigger("\"on\":\n  pull_request:\n", "master"),
857            Some(unfiltered())
858        );
859        assert_eq!(request_trigger("on: push\n", "master"), None);
860        assert_eq!(
861            request_trigger(
862                "on:\n  push:\n  workflow_dispatch:\njobs:\n  pull_request:\n",
863                "master"
864            ),
865            None
866        );
867        assert_eq!(
868            request_trigger(
869                "on:\n  pull_request:\n    paths:\n      - 'docs/**'\n  push:\n",
870                "master"
871            ),
872            Some(Trigger {
873                paths_filtered: true,
874                ..Trigger::default()
875            })
876        );
877        assert_eq!(
878            request_trigger(
879                "on:\n  push:\n    paths: [x]\n  pull_request:\n    branches: [master]\n",
880                "master"
881            ),
882            Some(unfiltered())
883        );
884    }
885
886    #[test]
887    fn jobs_read_names_needs_and_conditions_in_every_form() {
888        let text = "\
889jobs:
890  lint:
891    runs-on: ubuntu-latest
892  build:
893    name: \"Build it\" # the context
894    needs: lint
895  docs:
896    needs: [lint, build]
897  gate:
898    name: gate-${{ matrix.os }}
899    if: ${{ always() }}
900    needs:
901      - lint
902      - 'docs'
903    steps:
904      - uses: x@y
905        with:
906          needs: nothing
907  odd:
908    if: always() && needs.lint.result == 'success'
909    needs: ${{ fromJSON(x) }}
910  called:
911    uses: org/repo/.github/workflows/x.yml@main
912    name: called
913  named-first:
914    name: gate
915    uses: org/repo/.github/workflows/x.yml@main
916";
917        let found = jobs(text);
918        let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
919        assert_eq!(
920            ids,
921            [
922                "lint",
923                "build",
924                "docs",
925                "gate",
926                "odd",
927                "called",
928                "named-first"
929            ]
930        );
931        assert_eq!(found[0].needs, Needs::None);
932        assert_eq!(found[0].condition, Condition::Absent);
933        assert_eq!(found[1].context(), Some("Build it"));
934        assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
935        assert_eq!(
936            found[2].needs,
937            Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
938        );
939        assert_eq!(found[3].name, Name::Unproven);
940        assert_eq!(found[3].context(), None);
941        assert_eq!(found[3].condition, Condition::Proven);
942        assert_eq!(
943            found[3].needs,
944            Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
945        );
946        assert_eq!(
947            found[4].condition,
948            Condition::Other("always() && needs.lint.result == 'success'".to_owned())
949        );
950        assert_eq!(found[4].needs, Needs::Opaque);
951        assert!(found[5].reusable);
952        assert_eq!(found[5].context(), None);
953        assert!(found[6].reusable);
954        assert_eq!(found[6].context(), None);
955    }
956
957    #[test]
958    fn flow_lists_keep_quoted_scalars_whole() {
959        assert_eq!(list_items("[a, b]"), ["a", "b"]);
960        assert_eq!(list_items("a"), ["a"]);
961        assert_eq!(list_items("\"a\" # c"), ["a"]);
962        assert_eq!(
963            list_items("['ma[as]ter', \"x,y\", z]"),
964            ["ma[as]ter", "x,y", "z"]
965        );
966        assert_eq!(list_items("[]"), Vec::<&str>::new());
967        assert_eq!(
968            list_items("[\"topic\\\",master,tail\", x]"),
969            ["topic\\\",master,tail", "x"]
970        );
971    }
972
973    #[test]
974    fn a_nested_jobs_key_opens_no_region() {
975        let text = "\
976jobs:
977  call:
978    uses: org/repo/.github/workflows/x.yml@main
979    with:
980      jobs: 3
981  other:
982    strategy:
983      matrix:
984        jobs: [a, b]
985";
986        let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
987        assert_eq!(ids, ["call", "other"]);
988    }
989
990    #[test]
991    fn a_condition_is_proven_only_as_always_or_a_readable_not_cancelled() {
992        assert_eq!(condition("always()"), Condition::Proven);
993        assert_eq!(condition("${{ always() }}"), Condition::Proven);
994        assert_eq!(condition("'${{always()}}'"), Condition::Proven);
995        // The `!` survives YAML only inside the braces or inside quotes.
996        assert_eq!(condition("${{ !cancelled() }}"), Condition::Proven);
997        assert_eq!(condition("'!cancelled()'"), Condition::Proven);
998        assert_eq!(condition("\"!cancelled()\""), Condition::Proven);
999        // Unquoted, it is a YAML tag: the workflow does not parse at all.
1000        assert_eq!(
1001            condition("!cancelled()"),
1002            Condition::UnquotedTag("!cancelled()".to_owned())
1003        );
1004        assert_eq!(
1005            condition("${{ always() && false }}"),
1006            Condition::Other("always() && false".to_owned())
1007        );
1008        assert_eq!(
1009            condition("'!cancelled() && x'"),
1010            Condition::Other("!cancelled() && x".to_owned())
1011        );
1012        assert_eq!(
1013            condition("!always()"),
1014            Condition::UnquotedTag("!always()".to_owned())
1015        );
1016        assert_eq!(
1017            condition(""),
1018            Condition::Other("(a value carried on another line)".to_owned())
1019        );
1020    }
1021
1022    #[test]
1023    fn read_gate_judges_the_gates_shape() {
1024        let gated = report(
1025            "on: [pull_request]\njobs:\n  lint:\n  test:\n    if: always()\n    needs: [lint]\n",
1026            "test",
1027        );
1028        assert_eq!(gated.reading, GateReading::Gated);
1029        assert_eq!(gated.gate_condition, Some(Condition::Proven));
1030        assert_eq!(gated.gate_trigger, Trigger::default());
1031        assert_eq!(gated.reporting, 1);
1032        assert!(gated.unreadable.is_empty());
1033
1034        // A gate that needs one job of five is sound: which of the others
1035        // votes is the project's convention, and no file states it.
1036        let subset = report(
1037            "on: [pull_request]\njobs:\n  lint:\n  build:\n  docs:\n  pr-title:\n  test:\n    if: always()\n    needs: lint\n",
1038            "test",
1039        );
1040        assert_eq!(subset.reading, GateReading::Gated);
1041        assert_eq!(faults(&subset, "test", "master"), None);
1042
1043        let missing = report("on: [pull_request]\njobs:\n  lint:\n  unit:\n", "test");
1044        assert_eq!(
1045            missing.reading,
1046            GateReading::NoSuchJob {
1047                contexts: vec!["lint".to_owned(), "unit".to_owned()]
1048            }
1049        );
1050        assert_eq!(missing.gate_condition, None);
1051
1052        let dynamic = report(
1053            "on: [pull_request]\njobs:\n  lint:\n  test:\n    name: test-${{ matrix.os }}\n    needs: [lint]\n",
1054            "test",
1055        );
1056        assert_eq!(
1057            dynamic.reading,
1058            GateReading::UnprovenGateName {
1059                job: "test".to_owned()
1060            }
1061        );
1062
1063        let opaque = report(
1064            "on: [pull_request]\njobs:\n  lint:\n  test:\n    needs: *all\n",
1065            "test",
1066        );
1067        assert_eq!(
1068            opaque.reading,
1069            GateReading::OpaqueNeeds {
1070                workflow: "ci.yml".to_owned()
1071            }
1072        );
1073
1074        let filtered = report(
1075            "on:\n  pull_request:\n    paths: ['src/**']\njobs:\n  test:\n    if: always()\n",
1076            "test",
1077        );
1078        assert_eq!(filtered.reading, GateReading::Gated);
1079        assert!(filtered.gate_trigger.paths_filtered);
1080
1081        let off_trunk = report(
1082            "on:\n  pull_request:\n    branches: [main]\njobs:\n  test:\n    if: always()\n",
1083            "test",
1084        );
1085        assert_eq!(
1086            off_trunk.gate_trigger.misses_trunk,
1087            Some("branches: [main]".to_owned())
1088        );
1089
1090        let reusable = report(
1091            "on: [pull_request]\njobs:\n  test:\n    uses: org/repo/.github/workflows/x.yml@main\n    name: test\n",
1092            "test",
1093        );
1094        assert_eq!(
1095            reusable.reading,
1096            GateReading::UnprovenGateName {
1097                job: "test".to_owned()
1098            }
1099        );
1100
1101        let push_only = report("on: push\njobs:\n  lint:\n  test:\n", "test");
1102        assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1103
1104        // Two jobs reporting one context leave the protection unable to say
1105        // which one it is holding for.
1106        let duplicated = report(
1107            "on: [pull_request]\njobs:\n  test:\n    if: always()\n  other:\n    name: test\n",
1108            "test",
1109        );
1110        assert_eq!(duplicated.reporting, 2);
1111        let text = faults(&duplicated, "test", "master").expect("a fault");
1112        assert!(
1113            text.contains("no longer stands for the gate alone"),
1114            "{text}"
1115        );
1116
1117        let dir = tempfile::tempdir().expect("a tempdir");
1118        let empty = read_gate(
1119            Utf8Path::from_path(dir.path()).expect("utf-8"),
1120            "test",
1121            "master",
1122        );
1123        assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1124        assert!(empty.unreadable.is_empty());
1125    }
1126
1127    #[test]
1128    fn an_unreadable_workflow_is_named_not_skipped() {
1129        let dir = tempfile::tempdir().expect("a tempdir");
1130        let workflows = dir.path().join(".github/workflows");
1131        std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1132        std::fs::write(
1133            workflows.join("ci.yml"),
1134            "on: [pull_request]\njobs:\n  test:\n    if: always()\n",
1135        )
1136        .expect("the workflow writes");
1137        let report = read_gate(
1138            Utf8Path::from_path(dir.path()).expect("utf-8"),
1139            "test",
1140            "master",
1141        );
1142        assert_eq!(report.reading, GateReading::Gated);
1143        assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1144        let text = faults(&report, "test", "master").expect("a fault");
1145        assert!(text.contains("[broken.yml] could not be read"), "{text}");
1146        // The unreadable file leaves uniqueness unproven; the text must not
1147        // convert that into a claim of uniqueness.
1148        assert!(!text.contains("stands for the gate alone"), "{text}");
1149    }
1150
1151    #[test]
1152    fn fault_texts_are_one_line_each() {
1153        let base = || GateReport {
1154            reading: GateReading::Gated,
1155            gate_condition: Some(Condition::Proven),
1156            gate_trigger: Trigger::default(),
1157            reporting: 1,
1158            unreadable: Vec::new(),
1159        };
1160        assert_eq!(faults(&base(), "test", "master"), None);
1161        let cases = [
1162            GateReport {
1163                reading: GateReading::NoRequestWorkflows,
1164                gate_condition: None,
1165                ..base()
1166            },
1167            GateReport {
1168                reading: GateReading::NoSuchJob {
1169                    contexts: vec!["lint".to_owned()],
1170                },
1171                gate_condition: None,
1172                ..base()
1173            },
1174            GateReport {
1175                reading: GateReading::UnprovenGateName {
1176                    job: "test".to_owned(),
1177                },
1178                gate_condition: None,
1179                ..base()
1180            },
1181            GateReport {
1182                reading: GateReading::OpaqueNeeds {
1183                    workflow: "ci.yml".to_owned(),
1184                },
1185                gate_condition: Some(Condition::Absent),
1186                ..base()
1187            },
1188            GateReport {
1189                gate_condition: Some(Condition::Other("always() && x".to_owned())),
1190                ..base()
1191            },
1192            GateReport {
1193                gate_condition: Some(Condition::UnquotedTag("!cancelled()".to_owned())),
1194                ..base()
1195            },
1196            GateReport {
1197                reporting: 2,
1198                ..base()
1199            },
1200            GateReport {
1201                gate_trigger: Trigger {
1202                    paths_filtered: true,
1203                    misses_trunk: Some("branches: [main]".to_owned()),
1204                    types_filtered: Some("types: [opened]".to_owned()),
1205                },
1206                ..base()
1207            },
1208            GateReport {
1209                unreadable: vec!["x.yml".to_owned()],
1210                ..base()
1211            },
1212        ];
1213        for case in &cases {
1214            let text = faults(case, "test", "master").expect("a fault");
1215            assert!(!text.contains('\n'), "{text}");
1216            assert!(
1217                text.starts_with(|c: char| c.is_lowercase() || c == '['),
1218                "{text}"
1219            );
1220        }
1221    }
1222}