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