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