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            && let Some((_, items)) = filters.last_mut()
489        {
490            items.push(unquote(before_comment(item).trim()).to_owned());
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
551            && depth > property_depth
552            && let Some(item) = line.trim_start().strip_prefix("- ")
553        {
554            if let Needs::Listed(ids) = &mut job.needs {
555                ids.push(unquote(before_comment(item).trim()).to_owned());
556            }
557            continue;
558        }
559        reading_needs_list = false;
560        if depth != property_depth {
561            continue;
562        }
563        let Some((key, value)) = key_value(line) else {
564            continue;
565        };
566        match key {
567            "name" => {
568                let value = unquote(before_comment(value).trim());
569                // A name built from an expression resolves per run, so
570                // the context it reports is not in the file.
571                if value.contains("${{") || value.is_empty() {
572                    job.name = Name::Unproven;
573                } else {
574                    job.name = Name::Fixed(value.to_owned());
575                }
576            }
577            // A reusable-workflow call reports the called jobs' contexts,
578            // named after both the caller and the callee, whatever name
579            // the caller sets and in whatever key order.
580            "uses" => job.reusable = true,
581            "if" => job.condition = condition(value),
582            "needs" => {
583                let value = before_comment(value).trim();
584                if value.is_empty() {
585                    job.needs = Needs::Listed(Vec::new());
586                    reading_needs_list = true;
587                } else if value.starts_with(['|', '>', '*', '&', '$']) {
588                    job.needs = Needs::Opaque;
589                } else {
590                    job.needs =
591                        Needs::Listed(list_items(value).into_iter().map(str::to_owned).collect());
592                }
593            }
594            _ => {}
595        }
596    }
597    found
598}
599
600/// A job's `if` value: `always()` and `!cancelled()` are the two
601/// expressions proven to run on a failed dependency. Anything else is
602/// carried verbatim, because `always() && x` skips when `x` is false and a
603/// skipped job reports success.
604///
605/// `!cancelled()` is here because `rust-lang/cargo` uses it deliberately,
606/// so that a manual cancel does not turn the gate red. It runs on a failed
607/// dependency exactly as `always()` does, which is the property the gate
608/// rests on.
609///
610/// The `!` needs the expression braces or quotes to survive YAML: an
611/// unquoted scalar opening with `!` is a tag, not text, so `if:
612/// !cancelled()` does not parse and the forge never runs the workflow. The
613/// raw scalar is therefore read before it is unquoted, and the bare form is
614/// its own fault rather than a pass.
615fn condition(value: &str) -> Condition {
616    let raw = before_comment(value).trim();
617    let value = unquote(raw);
618    let inner = value
619        .strip_prefix("${{")
620        .and_then(|rest| rest.strip_suffix("}}"))
621        .map_or(value, str::trim);
622    if raw.starts_with('!') {
623        return Condition::UnquotedTag(raw.to_owned());
624    }
625    if inner == "always()" || inner == "!cancelled()" {
626        Condition::Proven
627    } else if inner.is_empty() {
628        Condition::Other("(a value carried on another line)".to_owned())
629    } else {
630        Condition::Other(inner.to_owned())
631    }
632}
633
634/// A scalar or a flow list, as its items: `a`, `[a, b]`, or `"a"`. The
635/// outer brackets alone delimit the list, and a comma inside a quoted
636/// scalar separates nothing, so a bracketed glob such as `'ma[as]ter'`
637/// stays one item and reaches the judgment whole.
638fn list_items(value: &str) -> Vec<&str> {
639    let value = before_comment(value).trim();
640    let inner = value
641        .strip_prefix('[')
642        .and_then(|rest| rest.strip_suffix(']'))
643        .unwrap_or(value);
644    let mut items = Vec::new();
645    let mut quote: Option<char> = None;
646    let mut escaped = false;
647    let mut start = 0;
648    for (index, character) in inner.char_indices() {
649        if let Some(open) = quote {
650            // A double-quoted scalar escapes with a backslash, so the
651            // quote after one is content, not the close.
652            if escaped {
653                escaped = false;
654            } else if open == QUOTES[0] && character == '\\' {
655                escaped = true;
656            } else if character == open {
657                quote = None;
658            }
659        } else if QUOTES.contains(&character) {
660            quote = Some(character);
661        } else if character == ',' {
662            items.push(&inner[start..index]);
663            start = index + 1;
664        }
665    }
666    items.push(&inner[start..]);
667    items
668        .into_iter()
669        .map(|item| unquote(item.trim()))
670        .filter(|item| !item.is_empty())
671        .collect()
672}
673
674/// A `key: value` line split at its first mapping colon, the key bare or
675/// quoted as YAML permits for an implicit key.
676fn key_value(line: &str) -> Option<(&str, &str)> {
677    let line = line.trim();
678    let (key, rest) = if let Some(quoted) = line.strip_prefix(QUOTES) {
679        let quote = line.chars().next()?;
680        let end = quoted.find(quote)?;
681        (&quoted[..end], quoted[end + 1..].trim_start())
682    } else {
683        let end = line.find(':')?;
684        (&line[..end], &line[end..])
685    };
686    let value = rest.strip_prefix(':')?;
687    if !(value.is_empty() || value.starts_with([' ', '\t'])) {
688        return None;
689    }
690    let key = key.trim();
691    if key.is_empty() || key.contains([' ', '\t']) {
692        return None;
693    }
694    Some((key, value.trim()))
695}
696
697/// The two quote characters a YAML scalar is written with, named by code
698/// point because the artifact-body scan reads a lone quote in these
699/// sources as a literal opening.
700const QUOTES: [char; 2] = ['\u{22}', '\u{27}'];
701
702fn unquote(value: &str) -> &str {
703    value
704        .strip_prefix(QUOTES[0])
705        .and_then(|rest| rest.strip_suffix(QUOTES[0]))
706        .or_else(|| {
707            value
708                .strip_prefix('\'')
709                .and_then(|rest| rest.strip_suffix('\''))
710        })
711        .unwrap_or(value)
712}
713
714fn indent(line: &str) -> usize {
715    line.len() - line.trim_start_matches(' ').len()
716}
717
718fn is_blank(line: &str) -> bool {
719    let trimmed = line.trim();
720    trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    fn report(text: &str, check: &str) -> GateReport {
728        let dir = tempfile::tempdir().expect("a tempdir");
729        let workflows = dir.path().join(".github/workflows");
730        std::fs::create_dir_all(&workflows).expect("the workflows dir");
731        std::fs::write(workflows.join("ci.yml"), text).expect("the workflow writes");
732        read_gate(
733            Utf8Path::from_path(dir.path()).expect("utf-8 tempdir"),
734            check,
735            "master",
736        )
737    }
738
739    fn unfiltered() -> Trigger {
740        Trigger::default()
741    }
742
743    #[test]
744    fn the_trigger_is_read_in_every_on_form() {
745        assert_eq!(
746            request_trigger(
747                "on:\n  push:\n  pull_request:\n    branches: [master]\n",
748                "master"
749            ),
750            Some(unfiltered())
751        );
752        assert_eq!(
753            request_trigger(
754                "on:\n  push:\n  pull_request:\n    branches: [main]\n",
755                "master"
756            ),
757            Some(Trigger {
758                misses_trunk: Some("branches: [main]".to_owned()),
759                ..Trigger::default()
760            })
761        );
762        assert_eq!(
763            request_trigger(
764                "on:\n  pull_request:\n    branches-ignore:\n      - master\n    types: [opened]\n",
765                "master"
766            ),
767            Some(Trigger {
768                misses_trunk: Some("branches-ignore: [master]".to_owned()),
769                types_filtered: Some("types: [opened]".to_owned()),
770                ..Trigger::default()
771            })
772        );
773        assert_eq!(
774            request_trigger(
775                "on:\n  pull_request:\n    branches: ['**']\n    types: [opened, synchronize, reopened]\n",
776                "master"
777            ),
778            Some(unfiltered())
779        );
780        assert_eq!(
781            request_trigger(
782                "on:\n  pull_request:\n    branches: ['**', '!master']\n",
783                "master"
784            ),
785            Some(Trigger {
786                misses_trunk: Some("branches: [**, !master]".to_owned()),
787                ..Trigger::default()
788            })
789        );
790        assert_eq!(
791            request_trigger(
792                "on:\n  pull_request:\n    branches: ['!master', '**']\n",
793                "master"
794            ),
795            Some(Trigger {
796                misses_trunk: Some("branches: [!master, **]".to_owned()),
797                ..Trigger::default()
798            })
799        );
800        assert_eq!(
801            request_trigger(
802                "on:\n  pull_request:\n    branches-ignore: ['mast*']\n",
803                "master"
804            ),
805            Some(Trigger {
806                misses_trunk: Some("branches-ignore: [mast*]".to_owned()),
807                ..Trigger::default()
808            })
809        );
810        assert_eq!(
811            request_trigger(
812                "on:\n  pull_request:\n    branches-ignore: [dependabot]\n",
813                "master"
814            ),
815            Some(unfiltered())
816        );
817        assert_eq!(
818            request_trigger(
819                "on:\n  pull_request:\n    branches-ignore: ['ma[as]ter']\n",
820                "master"
821            ),
822            Some(Trigger {
823                misses_trunk: Some("branches-ignore: [ma[as]ter]".to_owned()),
824                ..Trigger::default()
825            })
826        );
827        assert_eq!(
828            request_trigger(
829                "on:\n  pull_request:\n    branches: [\"release/**\", 'a,b', master]\n",
830                "master"
831            ),
832            Some(unfiltered())
833        );
834        assert_eq!(
835            request_trigger(
836                "on:\n  pull_request:\n    branches: [\"topic\\\",master,tail\"]\n",
837                "master"
838            ),
839            Some(Trigger {
840                misses_trunk: Some("branches: [topic\\\",master,tail]".to_owned()),
841                ..Trigger::default()
842            })
843        );
844        assert_eq!(
845            request_trigger("on: [push, pull_request]\n", "master"),
846            Some(unfiltered())
847        );
848        assert_eq!(
849            request_trigger("on: pull_request_target\n", "master"),
850            Some(unfiltered())
851        );
852        assert_eq!(
853            request_trigger("on:\n  - push\n  - pull_request\n", "master"),
854            Some(unfiltered())
855        );
856        assert_eq!(
857            request_trigger("\"on\":\n  pull_request:\n", "master"),
858            Some(unfiltered())
859        );
860        assert_eq!(request_trigger("on: push\n", "master"), None);
861        assert_eq!(
862            request_trigger(
863                "on:\n  push:\n  workflow_dispatch:\njobs:\n  pull_request:\n",
864                "master"
865            ),
866            None
867        );
868        assert_eq!(
869            request_trigger(
870                "on:\n  pull_request:\n    paths:\n      - 'docs/**'\n  push:\n",
871                "master"
872            ),
873            Some(Trigger {
874                paths_filtered: true,
875                ..Trigger::default()
876            })
877        );
878        assert_eq!(
879            request_trigger(
880                "on:\n  push:\n    paths: [x]\n  pull_request:\n    branches: [master]\n",
881                "master"
882            ),
883            Some(unfiltered())
884        );
885    }
886
887    #[test]
888    fn jobs_read_names_needs_and_conditions_in_every_form() {
889        let text = "\
890jobs:
891  lint:
892    runs-on: ubuntu-latest
893  build:
894    name: \"Build it\" # the context
895    needs: lint
896  docs:
897    needs: [lint, build]
898  gate:
899    name: gate-${{ matrix.os }}
900    if: ${{ always() }}
901    needs:
902      - lint
903      - 'docs'
904    steps:
905      - uses: x@y
906        with:
907          needs: nothing
908  odd:
909    if: always() && needs.lint.result == 'success'
910    needs: ${{ fromJSON(x) }}
911  called:
912    uses: org/repo/.github/workflows/x.yml@main
913    name: called
914  named-first:
915    name: gate
916    uses: org/repo/.github/workflows/x.yml@main
917";
918        let found = jobs(text);
919        let ids: Vec<&str> = found.iter().map(|job| job.id.as_str()).collect();
920        assert_eq!(
921            ids,
922            [
923                "lint",
924                "build",
925                "docs",
926                "gate",
927                "odd",
928                "called",
929                "named-first"
930            ]
931        );
932        assert_eq!(found[0].needs, Needs::None);
933        assert_eq!(found[0].condition, Condition::Absent);
934        assert_eq!(found[1].context(), Some("Build it"));
935        assert_eq!(found[1].needs, Needs::Listed(vec!["lint".to_owned()]));
936        assert_eq!(
937            found[2].needs,
938            Needs::Listed(vec!["lint".to_owned(), "build".to_owned()])
939        );
940        assert_eq!(found[3].name, Name::Unproven);
941        assert_eq!(found[3].context(), None);
942        assert_eq!(found[3].condition, Condition::Proven);
943        assert_eq!(
944            found[3].needs,
945            Needs::Listed(vec!["lint".to_owned(), "docs".to_owned()])
946        );
947        assert_eq!(
948            found[4].condition,
949            Condition::Other("always() && needs.lint.result == 'success'".to_owned())
950        );
951        assert_eq!(found[4].needs, Needs::Opaque);
952        assert!(found[5].reusable);
953        assert_eq!(found[5].context(), None);
954        assert!(found[6].reusable);
955        assert_eq!(found[6].context(), None);
956    }
957
958    #[test]
959    fn flow_lists_keep_quoted_scalars_whole() {
960        assert_eq!(list_items("[a, b]"), ["a", "b"]);
961        assert_eq!(list_items("a"), ["a"]);
962        assert_eq!(list_items("\"a\" # c"), ["a"]);
963        assert_eq!(
964            list_items("['ma[as]ter', \"x,y\", z]"),
965            ["ma[as]ter", "x,y", "z"]
966        );
967        assert_eq!(list_items("[]"), Vec::<&str>::new());
968        assert_eq!(
969            list_items("[\"topic\\\",master,tail\", x]"),
970            ["topic\\\",master,tail", "x"]
971        );
972    }
973
974    #[test]
975    fn a_nested_jobs_key_opens_no_region() {
976        let text = "\
977jobs:
978  call:
979    uses: org/repo/.github/workflows/x.yml@main
980    with:
981      jobs: 3
982  other:
983    strategy:
984      matrix:
985        jobs: [a, b]
986";
987        let ids: Vec<String> = jobs(text).into_iter().map(|job| job.id).collect();
988        assert_eq!(ids, ["call", "other"]);
989    }
990
991    #[test]
992    fn a_condition_is_proven_only_as_always_or_a_readable_not_cancelled() {
993        assert_eq!(condition("always()"), Condition::Proven);
994        assert_eq!(condition("${{ always() }}"), Condition::Proven);
995        assert_eq!(condition("'${{always()}}'"), Condition::Proven);
996        // The `!` survives YAML only inside the braces or inside quotes.
997        assert_eq!(condition("${{ !cancelled() }}"), Condition::Proven);
998        assert_eq!(condition("'!cancelled()'"), Condition::Proven);
999        assert_eq!(condition("\"!cancelled()\""), Condition::Proven);
1000        // Unquoted, it is a YAML tag: the workflow does not parse at all.
1001        assert_eq!(
1002            condition("!cancelled()"),
1003            Condition::UnquotedTag("!cancelled()".to_owned())
1004        );
1005        assert_eq!(
1006            condition("${{ always() && false }}"),
1007            Condition::Other("always() && false".to_owned())
1008        );
1009        assert_eq!(
1010            condition("'!cancelled() && x'"),
1011            Condition::Other("!cancelled() && x".to_owned())
1012        );
1013        assert_eq!(
1014            condition("!always()"),
1015            Condition::UnquotedTag("!always()".to_owned())
1016        );
1017        assert_eq!(
1018            condition(""),
1019            Condition::Other("(a value carried on another line)".to_owned())
1020        );
1021    }
1022
1023    #[test]
1024    fn read_gate_judges_the_gates_shape() {
1025        let gated = report(
1026            "on: [pull_request]\njobs:\n  lint:\n  test:\n    if: always()\n    needs: [lint]\n",
1027            "test",
1028        );
1029        assert_eq!(gated.reading, GateReading::Gated);
1030        assert_eq!(gated.gate_condition, Some(Condition::Proven));
1031        assert_eq!(gated.gate_trigger, Trigger::default());
1032        assert_eq!(gated.reporting, 1);
1033        assert!(gated.unreadable.is_empty());
1034
1035        // A gate that needs one job of five is sound: which of the others
1036        // votes is the project's convention, and no file states it.
1037        let subset = report(
1038            "on: [pull_request]\njobs:\n  lint:\n  build:\n  docs:\n  pr-title:\n  test:\n    if: always()\n    needs: lint\n",
1039            "test",
1040        );
1041        assert_eq!(subset.reading, GateReading::Gated);
1042        assert_eq!(faults(&subset, "test", "master"), None);
1043
1044        let missing = report("on: [pull_request]\njobs:\n  lint:\n  unit:\n", "test");
1045        assert_eq!(
1046            missing.reading,
1047            GateReading::NoSuchJob {
1048                contexts: vec!["lint".to_owned(), "unit".to_owned()]
1049            }
1050        );
1051        assert_eq!(missing.gate_condition, None);
1052
1053        let dynamic = report(
1054            "on: [pull_request]\njobs:\n  lint:\n  test:\n    name: test-${{ matrix.os }}\n    needs: [lint]\n",
1055            "test",
1056        );
1057        assert_eq!(
1058            dynamic.reading,
1059            GateReading::UnprovenGateName {
1060                job: "test".to_owned()
1061            }
1062        );
1063
1064        let opaque = report(
1065            "on: [pull_request]\njobs:\n  lint:\n  test:\n    needs: *all\n",
1066            "test",
1067        );
1068        assert_eq!(
1069            opaque.reading,
1070            GateReading::OpaqueNeeds {
1071                workflow: "ci.yml".to_owned()
1072            }
1073        );
1074
1075        let filtered = report(
1076            "on:\n  pull_request:\n    paths: ['src/**']\njobs:\n  test:\n    if: always()\n",
1077            "test",
1078        );
1079        assert_eq!(filtered.reading, GateReading::Gated);
1080        assert!(filtered.gate_trigger.paths_filtered);
1081
1082        let off_trunk = report(
1083            "on:\n  pull_request:\n    branches: [main]\njobs:\n  test:\n    if: always()\n",
1084            "test",
1085        );
1086        assert_eq!(
1087            off_trunk.gate_trigger.misses_trunk,
1088            Some("branches: [main]".to_owned())
1089        );
1090
1091        let reusable = report(
1092            "on: [pull_request]\njobs:\n  test:\n    uses: org/repo/.github/workflows/x.yml@main\n    name: test\n",
1093            "test",
1094        );
1095        assert_eq!(
1096            reusable.reading,
1097            GateReading::UnprovenGateName {
1098                job: "test".to_owned()
1099            }
1100        );
1101
1102        let push_only = report("on: push\njobs:\n  lint:\n  test:\n", "test");
1103        assert_eq!(push_only.reading, GateReading::NoRequestWorkflows);
1104
1105        // Two jobs reporting one context leave the protection unable to say
1106        // which one it is holding for.
1107        let duplicated = report(
1108            "on: [pull_request]\njobs:\n  test:\n    if: always()\n  other:\n    name: test\n",
1109            "test",
1110        );
1111        assert_eq!(duplicated.reporting, 2);
1112        let text = faults(&duplicated, "test", "master").expect("a fault");
1113        assert!(
1114            text.contains("no longer stands for the gate alone"),
1115            "{text}"
1116        );
1117
1118        let dir = tempfile::tempdir().expect("a tempdir");
1119        let empty = read_gate(
1120            Utf8Path::from_path(dir.path()).expect("utf-8"),
1121            "test",
1122            "master",
1123        );
1124        assert_eq!(empty.reading, GateReading::NoRequestWorkflows);
1125        assert!(empty.unreadable.is_empty());
1126    }
1127
1128    #[test]
1129    fn an_unreadable_workflow_is_named_not_skipped() {
1130        let dir = tempfile::tempdir().expect("a tempdir");
1131        let workflows = dir.path().join(".github/workflows");
1132        std::fs::create_dir_all(workflows.join("broken.yml")).expect("a directory named as a file");
1133        std::fs::write(
1134            workflows.join("ci.yml"),
1135            "on: [pull_request]\njobs:\n  test:\n    if: always()\n",
1136        )
1137        .expect("the workflow writes");
1138        let report = read_gate(
1139            Utf8Path::from_path(dir.path()).expect("utf-8"),
1140            "test",
1141            "master",
1142        );
1143        assert_eq!(report.reading, GateReading::Gated);
1144        assert_eq!(report.unreadable, vec!["broken.yml".to_owned()]);
1145        let text = faults(&report, "test", "master").expect("a fault");
1146        assert!(text.contains("[broken.yml] could not be read"), "{text}");
1147        // The unreadable file leaves uniqueness unproven; the text must not
1148        // convert that into a claim of uniqueness.
1149        assert!(!text.contains("stands for the gate alone"), "{text}");
1150    }
1151
1152    #[test]
1153    fn fault_texts_are_one_line_each() {
1154        let base = || GateReport {
1155            reading: GateReading::Gated,
1156            gate_condition: Some(Condition::Proven),
1157            gate_trigger: Trigger::default(),
1158            reporting: 1,
1159            unreadable: Vec::new(),
1160        };
1161        assert_eq!(faults(&base(), "test", "master"), None);
1162        let cases = [
1163            GateReport {
1164                reading: GateReading::NoRequestWorkflows,
1165                gate_condition: None,
1166                ..base()
1167            },
1168            GateReport {
1169                reading: GateReading::NoSuchJob {
1170                    contexts: vec!["lint".to_owned()],
1171                },
1172                gate_condition: None,
1173                ..base()
1174            },
1175            GateReport {
1176                reading: GateReading::UnprovenGateName {
1177                    job: "test".to_owned(),
1178                },
1179                gate_condition: None,
1180                ..base()
1181            },
1182            GateReport {
1183                reading: GateReading::OpaqueNeeds {
1184                    workflow: "ci.yml".to_owned(),
1185                },
1186                gate_condition: Some(Condition::Absent),
1187                ..base()
1188            },
1189            GateReport {
1190                gate_condition: Some(Condition::Other("always() && x".to_owned())),
1191                ..base()
1192            },
1193            GateReport {
1194                gate_condition: Some(Condition::UnquotedTag("!cancelled()".to_owned())),
1195                ..base()
1196            },
1197            GateReport {
1198                reporting: 2,
1199                ..base()
1200            },
1201            GateReport {
1202                gate_trigger: Trigger {
1203                    paths_filtered: true,
1204                    misses_trunk: Some("branches: [main]".to_owned()),
1205                    types_filtered: Some("types: [opened]".to_owned()),
1206                },
1207                ..base()
1208            },
1209            GateReport {
1210                unreadable: vec!["x.yml".to_owned()],
1211                ..base()
1212            },
1213        ];
1214        for case in &cases {
1215            let text = faults(case, "test", "master").expect("a fault");
1216            assert!(!text.contains('\n'), "{text}");
1217            assert!(
1218                text.starts_with(|c: char| c.is_lowercase() || c == '['),
1219                "{text}"
1220            );
1221        }
1222    }
1223}