1use std::path::Path;
32
33use crate::check::Outcome;
34use crate::hooks::common::{fail, hl, ok, repo_root};
35use crate::manifest::{normalize_command, tree_gates, TreeGate, MANIFEST};
36
37pub const WORKFLOW_DIRS: &[&str] = &[".github/workflows", ".forgejo/workflows"];
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Problem {
43 pub file: String,
44 pub line: usize,
45 pub what: String,
46}
47
48impl std::fmt::Display for Problem {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 if self.line == 0 {
51 write!(f, "{}: {}", self.file, self.what)
52 } else {
53 write!(f, "{}:{}: {}", self.file, self.line, self.what)
54 }
55 }
56}
57
58#[derive(Debug, Default)]
60struct Step {
61 line: usize,
62 keys: Vec<(String, String, usize)>,
65 text: String,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum Inherited {
73 ShellBash,
75 Other,
77}
78
79#[derive(Debug, Default)]
80struct Job {
81 inherits: Vec<(usize, Inherited)>,
83 steps: Vec<Step>,
84}
85
86#[derive(Debug, Default)]
87struct Workflow {
88 inherits: Vec<(usize, Inherited)>,
90 jobs: Vec<Job>,
91 stray: Vec<usize>,
93}
94
95fn indent_of(line: &str) -> usize {
96 line.len() - line.trim_start_matches(' ').len()
97}
98
99fn key_of(s: &str) -> Option<(&str, &str)> {
101 let (k, v) = s.split_once(':')?;
102 let ok = !k.is_empty()
103 && k.chars()
104 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
105 (ok && (v.is_empty() || v.starts_with(' '))).then(|| (k, v.trim()))
106}
107
108fn mentions_tree(line: &str) -> bool {
109 line.contains("'tree-") || line.contains("\"tree-")
110}
111
112fn classify(lines: &[&str], idx: usize) -> Inherited {
116 let key_line = lines[idx];
117 if key_of(key_line.trim()).map(|(k, _)| k) != Some("defaults") {
118 return Inherited::Other;
119 }
120 let level = indent_of(key_line);
121 let body: Vec<&str> = lines[idx + 1..]
122 .iter()
123 .map(|l| (indent_of(l), l.trim()))
124 .filter(|(_, t)| !t.is_empty() && !t.starts_with('#'))
125 .take_while(|(ind, _)| *ind > level)
126 .map(|(_, t)| t)
127 .collect();
128 let bash = ["shell: bash", "shell: 'bash'", "shell: \"bash\""];
129 match body.as_slice() {
130 ["run:", shell] if bash.contains(shell) => Inherited::ShellBash,
131 _ => Inherited::Other,
132 }
133}
134
135fn is_simple(command: &str) -> bool {
138 !command.contains(['|', ';', '&', '>', '<', '`', '(', ')']) && !command.contains("$(")
139}
140
141fn read_workflow(text: &str) -> Workflow {
142 let all: Vec<&str> = text.lines().collect();
143 let mut wf = Workflow::default();
144 let mut in_jobs = false;
145 let mut job_indent: Option<usize> = None;
146 let mut job_key_indent: Option<usize> = None;
147 let mut steps_indent: Option<usize> = None;
148 let mut dash_indent: Option<usize> = None;
149 for (i, raw) in text.lines().enumerate() {
150 let lineno = i + 1;
151 let trimmed = raw.trim();
152 if trimmed.is_empty() || trimmed.starts_with('#') {
153 continue;
154 }
155 let ind = indent_of(raw);
156 if ind == 0 {
157 in_jobs = false;
158 job_indent = None;
159 job_key_indent = None;
160 steps_indent = None;
161 dash_indent = None;
162 if let Some((k, _)) = key_of(trimmed) {
163 if k == "jobs" {
164 in_jobs = true;
165 } else if k == "env" || k == "defaults" {
166 wf.inherits.push((lineno, classify(&all, i)));
167 }
168 }
169 if mentions_tree(raw) {
170 wf.stray.push(lineno);
171 }
172 continue;
173 }
174 if !in_jobs {
175 if mentions_tree(raw) {
176 wf.stray.push(lineno);
177 }
178 continue;
179 }
180 let ji = *job_indent.get_or_insert(ind);
181 if ind <= ji {
182 job_key_indent = None;
184 steps_indent = None;
185 dash_indent = None;
186 wf.jobs.push(Job::default());
187 if mentions_tree(raw) {
188 wf.stray.push(lineno);
189 }
190 continue;
191 }
192 let Some(job) = wf.jobs.last_mut() else {
193 if mentions_tree(raw) {
194 wf.stray.push(lineno);
195 }
196 continue;
197 };
198 let jki = *job_key_indent.get_or_insert(ind);
199 if ind <= jki {
200 steps_indent = None;
202 dash_indent = None;
203 if let Some((k, _)) = key_of(trimmed) {
204 if k == "steps" {
205 steps_indent = Some(ind);
206 } else if k == "env" || k == "defaults" {
207 job.inherits.push((lineno, classify(&all, i)));
208 }
209 }
210 if mentions_tree(raw) {
211 wf.stray.push(lineno);
212 }
213 continue;
214 }
215 if steps_indent.is_none() {
216 if mentions_tree(raw) {
217 wf.stray.push(lineno);
218 }
219 continue;
220 }
221 if let Some(item) = trimmed
222 .strip_prefix("- ")
223 .or((trimmed == "-").then_some(""))
224 {
225 let di = *dash_indent.get_or_insert(ind);
226 if ind == di {
227 let mut step = Step {
228 line: lineno,
229 ..Step::default()
230 };
231 step.text.push_str(raw);
232 step.text.push('\n');
233 if let Some((k, v)) = key_of(item) {
234 step.keys.push((k.to_string(), v.to_string(), lineno));
235 }
236 job.steps.push(step);
237 continue;
238 }
239 }
240 let Some(step) = job.steps.last_mut() else {
241 if mentions_tree(raw) {
242 wf.stray.push(lineno);
243 }
244 continue;
245 };
246 step.text.push_str(raw);
247 step.text.push('\n');
248 if Some(ind) == dash_indent.map(|d| d + 2) {
250 if let Some((k, v)) = key_of(trimmed) {
251 step.keys.push((k.to_string(), v.to_string(), lineno));
252 }
253 }
254 }
255 wf
256}
257
258fn referenced(text: &str) -> Vec<String> {
260 let mut out: Vec<String> = Vec::new();
261 for quote in ['\'', '"'] {
262 let needle = format!("{quote}tree-");
263 let mut rest = text;
264 while let Some(i) = rest.find(&needle) {
265 let after = &rest[i + needle.len()..];
266 let end = after.find(quote).unwrap_or(after.len());
267 let name = &after[..end];
268 if !name.is_empty() && !out.iter().any(|n| n == name) {
269 out.push(name.to_string());
270 }
271 rest = &after[end..];
272 }
273 }
274 out
275}
276
277fn scalar(raw: &str) -> Result<String, &'static str> {
279 let v = raw.trim();
280 if v.is_empty() || v.starts_with('|') || v.starts_with('>') {
281 return Err("a block scalar — write the command on one line");
282 }
283 if v.starts_with('&') || v.starts_with('*') {
284 return Err("an anchor or alias");
285 }
286 let value = if let Some(inner) = v.strip_prefix('"') {
287 let Some(inner) = inner.strip_suffix('"') else {
288 return Err("an unterminated quoted scalar");
289 };
290 if inner.contains(['\\', '"']) {
291 return Err("a quoted scalar with escapes");
292 }
293 inner.to_string()
294 } else if let Some(inner) = v.strip_prefix('\'') {
295 let Some(inner) = inner.strip_suffix('\'') else {
296 return Err("an unterminated quoted scalar");
297 };
298 if inner.contains('\'') {
299 return Err("a quoted scalar with escapes");
300 }
301 inner.to_string()
302 } else {
303 if v.contains(" #") || v.contains(": ") || v.starts_with(['{', '[', '!', '%', '@', '`']) {
304 return Err("a plain scalar YAML may read differently — quote it");
305 }
306 v.to_string()
307 };
308 if value.contains("${{") {
309 return Err("`${{ }}` interpolation");
310 }
311 Ok(value)
312}
313
314pub fn check_texts(gates: &[TreeGate], workflows: &[(String, String)]) -> Vec<Problem> {
316 let mut problems = Vec::new();
317 let mut seen: Vec<&str> = Vec::new();
318 for (file, text) in workflows {
319 let wf = read_workflow(text);
320 for line in &wf.stray {
321 problems.push(Problem {
322 file: file.clone(),
323 line: *line,
324 what: "a tree gate referenced outside a step — only a step may be skipped on one"
325 .into(),
326 });
327 }
328 for job in &wf.jobs {
329 for step in &job.steps {
330 let names = referenced(&step.text);
331 if names.is_empty() {
332 continue;
333 }
334 let at = |what: String| Problem {
335 file: file.clone(),
336 line: step.line,
337 what,
338 };
339 if names.len() > 1 {
340 problems.push(at(format!(
341 "one step is gated on several tree gates ({}) — a gate proves one command",
342 names.join(", ")
343 )));
344 continue;
345 }
346 let name = &names[0];
347 let Some(gate) = gates.iter().find(|g| &g.name == name) else {
348 problems.push(at(format!("`tree-{name}` is not declared in {MANIFEST}")));
349 continue;
350 };
351 seen.push(&gate.name);
352 let inherited: Vec<(usize, Inherited)> =
353 wf.inherits.iter().chain(&job.inherits).copied().collect();
354 if let Some((l, _)) = inherited.iter().find(|(_, k)| *k == Inherited::Other) {
355 problems.push(Problem {
356 file: file.clone(),
357 line: *l,
358 what: format!(
359 "`tree-{name}` is skipped in a job that inherits `env:` or \
360 `defaults:` — they change what `run:` does; move them onto \
361 the steps that need them"
362 ),
363 });
364 continue;
365 }
366 if let Some((l, _)) = inherited.first() {
367 if !is_simple(&gate.normalized()) {
368 problems.push(Problem {
369 file: file.clone(),
370 line: *l,
371 what: format!(
372 "`tree-{name}` inherits `shell: bash`, which is accepted only \
373 for a simple command — this one has a pipe, list, \
374 redirection or substitution"
375 ),
376 });
377 continue;
378 }
379 }
380 let mut run = None;
381 let mut bad = None;
382 for (k, v, l) in &step.keys {
383 match k.as_str() {
384 "run" => run = Some((v, *l)),
385 "env" | "shell" => {
386 bad = Some(format!("a step-level `{k}:` changes what `run:` does"))
387 }
388 "working-directory" => match (scalar(v), &gate.cwd) {
389 (Ok(dir), Some(cwd))
390 if dir.trim_end_matches('/').trim_start_matches("./")
391 == cwd.as_str() => {}
392 _ => {
393 bad = Some(
394 "`working-directory:` differs from the gate's `cwd=`".into(),
395 )
396 }
397 },
398 _ => {}
399 }
400 }
401 if bad.is_none() && gate.cwd.is_some() {
402 let has_wd = step.keys.iter().any(|(k, _, _)| k == "working-directory");
403 if !has_wd {
404 bad = Some(format!(
405 "the gate runs in `{}` (cwd=) but the step has no \
406 `working-directory:`",
407 gate.cwd.as_deref().unwrap_or("")
408 ));
409 }
410 }
411 if let Some(why) = bad {
412 problems.push(at(format!("`tree-{name}`: {why}")));
413 continue;
414 }
415 let Some((raw, line)) = run else {
416 problems.push(at(format!("`tree-{name}` gates a step with no `run:`")));
417 continue;
418 };
419 match scalar(raw) {
420 Err(why) => problems.push(Problem {
421 file: file.clone(),
422 line,
423 what: format!("`tree-{name}`: `run:` is {why}"),
424 }),
425 Ok(value) => {
426 let got = normalize_command(&value);
427 let want = gate.normalized();
428 if got != want {
429 problems.push(Problem {
430 file: file.clone(),
431 line,
432 what: format!(
433 "`tree-{name}` runs `{got}` here but `{want}` in {MANIFEST}:{}",
434 gate.lineno
435 ),
436 });
437 }
438 }
439 }
440 }
441 }
442 }
443 for gate in gates {
444 if !seen.contains(&gate.name.as_str()) {
445 problems.push(Problem {
446 file: MANIFEST.into(),
447 line: gate.lineno,
448 what: format!(
449 "`tree-{}` is declared but no workflow step is skipped on it",
450 gate.name
451 ),
452 });
453 }
454 }
455 problems
456}
457
458pub fn check_repo(root: &Path) -> Vec<Problem> {
460 let Ok(manifest) = std::fs::read_to_string(root.join(MANIFEST)) else {
461 return Vec::new();
462 };
463 let gates = tree_gates(&manifest);
464 if gates.is_empty() {
465 return Vec::new();
466 }
467 let mut workflows = Vec::new();
468 for dir in WORKFLOW_DIRS {
469 let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
470 continue;
471 };
472 let mut paths: Vec<_> = entries
473 .filter_map(Result::ok)
474 .map(|e| e.path())
475 .filter(|p| matches!(p.extension().and_then(|e| e.to_str()), Some("yml" | "yaml")))
476 .collect();
477 paths.sort();
478 for p in paths {
479 if let Ok(text) = std::fs::read_to_string(&p) {
480 let rel = p.strip_prefix(root).unwrap_or(&p).display().to_string();
481 workflows.push((rel, text));
482 }
483 }
484 }
485 check_texts(&gates, &workflows)
486}
487
488pub fn run(settings: &crate::config::Settings) -> Outcome {
490 let root = repo_root();
491 let root = Path::new(&root);
492 let declares = std::fs::read_to_string(root.join(MANIFEST))
493 .map(|t| !tree_gates(&t).is_empty())
494 .unwrap_or(false);
495 if !declares {
496 return Outcome::Inert;
497 }
498 let problems = check_repo(root);
499 if problems.is_empty() {
500 ok(settings, "tree gates match their CI steps");
501 return Outcome::Passed;
502 }
503 for p in &problems {
504 eprintln!(" {}", crate::ui::sanitize(&p.to_string()));
505 }
506 fail(&format!(
507 "{} tree-gate parity problem(s) — fix the workflow or the {} line; run {} to re-check",
508 problems.len(),
509 MANIFEST,
510 hl("amont tree-parity")
511 ));
512 Outcome::Failed
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn gates(text: &str) -> Vec<TreeGate> {
520 let g = tree_gates(text);
521 assert!(!g.is_empty(), "no gate parsed from {text:?}");
522 g
523 }
524
525 fn check(manifest: &str, workflow: &str) -> Vec<Problem> {
526 check_texts(
527 &gates(manifest),
528 &[(
529 ".forgejo/workflows/ci.yaml".to_string(),
530 workflow.to_string(),
531 )],
532 )
533 }
534
535 const ESLINT: &str = "tree eslint eslint * attest npm run lint -- {cache}";
536
537 fn wf(step_run: &str) -> String {
538 format!(
539 "name: ci\non:\n push:\njobs:\n checks:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - id: attest\n uses: fredericrous/attest@v1\n - name: Lint\n if: ${{{{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'tree-eslint') }}}}\n {step_run}\n"
540 )
541 }
542
543 #[test]
544 fn parity_matching_run_passes() {
545 assert_eq!(check(ESLINT, &wf("run: npm run lint")), vec![]);
546 }
547
548 #[test]
549 fn parity_quoted_run_passes() {
550 assert_eq!(check(ESLINT, &wf("run: 'npm run lint'")), vec![]);
551 }
552
553 #[test]
554 fn parity_drifted_run_fails() {
555 let p = check(ESLINT, &wf("run: npm run lint -- --max-warnings 0"));
556 assert_eq!(p.len(), 1, "{p:?}");
557 assert!(p[0]
558 .what
559 .contains("runs `npm run lint -- --max-warnings 0` here"));
560 }
561
562 #[test]
563 fn parity_block_scalar_rejected() {
564 let p = check(ESLINT, &wf("run: |\n npm run lint"));
565 assert_eq!(p.len(), 1, "{p:?}");
566 assert!(p[0].what.contains("block scalar"));
567 }
568
569 #[test]
570 fn parity_anchor_rejected() {
571 let p = check(ESLINT, &wf("run: *lint"));
572 assert!(p[0].what.contains("anchor"), "{p:?}");
573 }
574
575 #[test]
576 fn parity_interpolation_rejected() {
577 let p = check(ESLINT, &wf("run: npm run ${{ matrix.task }}"));
578 assert!(p[0].what.contains("interpolation"), "{p:?}");
579 }
580
581 #[test]
582 fn parity_step_env_rejected() {
583 let p = check(
584 ESLINT,
585 &wf("env:\n CI: '1'\n run: npm run lint"),
586 );
587 assert!(p[0].what.contains("step-level `env:`"), "{p:?}");
588 }
589
590 #[test]
591 fn parity_step_shell_rejected() {
592 let p = check(ESLINT, &wf("shell: bash\n run: npm run lint"));
593 assert!(p[0].what.contains("step-level `shell:`"), "{p:?}");
594 }
595
596 #[test]
597 fn parity_undeclared_working_directory_rejected() {
598 let p = check(
599 ESLINT,
600 &wf("working-directory: web\n run: npm run lint"),
601 );
602 assert!(p[0].what.contains("working-directory"), "{p:?}");
603 }
604
605 #[test]
606 fn parity_declared_cwd_passes() {
607 let p = check(
608 "tree eslint eslint * attest cwd=web npm run lint",
609 &wf("working-directory: web\n run: npm run lint"),
610 );
611 assert_eq!(p, vec![]);
612 }
613
614 #[test]
615 fn parity_cwd_without_working_directory_rejected() {
616 let p = check(
617 "tree eslint eslint * attest cwd=web npm run lint",
618 &wf("run: npm run lint"),
619 );
620 assert!(p[0].what.contains("no `working-directory:`"), "{p:?}");
621 }
622
623 #[test]
624 fn parity_job_env_rejected() {
625 let w = wf("run: npm run lint").replace(
626 " runs-on: ubuntu-latest\n",
627 " runs-on: ubuntu-latest\n env:\n NODE_ENV: test\n",
628 );
629 let p = check(ESLINT, &w);
630 assert!(p[0].what.contains("inherits"), "{p:?}");
631 }
632
633 #[test]
634 fn parity_job_defaults_rejected() {
635 let w = wf("run: npm run lint").replace(
636 " runs-on: ubuntu-latest\n",
637 " runs-on: ubuntu-latest\n defaults:\n run:\n working-directory: web\n",
638 );
639 let p = check(ESLINT, &w);
640 assert!(p[0].what.contains("inherits"), "{p:?}");
641 }
642
643 #[test]
644 fn parity_workflow_shell_bash_accepted_for_a_simple_command() {
645 let w = wf("run: npm run lint")
646 .replace("jobs:\n", "defaults:\n run:\n shell: bash\njobs:\n");
647 assert_eq!(check(ESLINT, &w), vec![]);
648 }
649
650 #[test]
651 fn parity_job_shell_bash_accepted_for_a_simple_command() {
652 let w = wf("run: npm run lint").replace(
653 " runs-on: ubuntu-latest\n",
654 " runs-on: ubuntu-latest\n defaults:\n run:\n shell: bash\n",
655 );
656 assert_eq!(check(ESLINT, &w), vec![]);
657 }
658
659 #[test]
660 fn parity_shell_bash_rejected_for_a_pipeline() {
661 let w = wf("run: npm run lint | tee lint.log")
662 .replace("jobs:\n", "defaults:\n run:\n shell: bash\njobs:\n");
663 let p = check(
664 "tree eslint eslint * attest npm run lint | tee lint.log",
665 &w,
666 );
667 assert!(p[0].what.contains("simple command"), "{p:?}");
668 }
669
670 #[test]
671 fn parity_other_shell_rejected() {
672 let w =
673 wf("run: npm run lint").replace("jobs:\n", "defaults:\n run:\n shell: sh\njobs:\n");
674 let p = check(ESLINT, &w);
675 assert!(p[0].what.contains("inherits `env:` or"), "{p:?}");
676 }
677
678 #[test]
679 fn parity_shell_bash_plus_working_directory_rejected() {
680 let w = wf("run: npm run lint").replace(
681 "jobs:\n",
682 "defaults:\n run:\n shell: bash\n working-directory: web\njobs:\n",
683 );
684 let p = check(ESLINT, &w);
685 assert!(p[0].what.contains("inherits `env:` or"), "{p:?}");
686 }
687
688 #[test]
689 fn parity_workflow_env_still_rejected_beside_shell_bash() {
690 let w = wf("run: npm run lint").replace(
691 "jobs:\n",
692 "defaults:\n run:\n shell: bash\nenv:\n CI: '1'\njobs:\n",
693 );
694 let p = check(ESLINT, &w);
695 assert!(p[0].what.contains("inherits `env:` or"), "{p:?}");
696 }
697
698 #[test]
699 fn parity_undeclared_gate_rejected() {
700 let p = check(
701 "tree prettier prettier * attest npx prettier --check .",
702 &wf("run: npm run lint"),
703 );
704 assert!(
705 p.iter()
706 .any(|p| p.what.contains("`tree-eslint` is not declared")),
707 "{p:?}"
708 );
709 assert!(
710 p.iter().any(|p| p.what.contains("no workflow step")),
711 "{p:?}"
712 );
713 }
714
715 #[test]
716 fn parity_declared_gate_without_step_rejected() {
717 let p = check(
718 ESLINT,
719 "name: ci\njobs:\n a:\n steps:\n - run: true\n",
720 );
721 assert_eq!(p.len(), 1, "{p:?}");
722 assert!(p[0].what.contains("no workflow step is skipped on it"));
723 }
724
725 #[test]
726 fn parity_job_level_reference_rejected() {
727 let w = "name: ci\njobs:\n lint:\n if: ${{ !contains(fromJSON(needs.a.outputs.gates), 'tree-eslint') }}\n steps:\n - run: npm run lint\n";
728 let p = check(ESLINT, w);
729 assert!(p.iter().any(|p| p.what.contains("outside a step")), "{p:?}");
730 }
731
732 #[test]
733 fn parity_two_gates_on_one_step_rejected() {
734 let w = wf("run: npm run lint").replace(
735 "'tree-eslint') }}",
736 "'tree-eslint') && !contains(fromJSON(steps.attest.outputs.gates), 'tree-prettier') }}",
737 );
738 let p = check(
739 "tree eslint eslint * attest npm run lint\ntree prettier prettier * attest npx prettier --check .",
740 &w,
741 );
742 assert!(
743 p.iter().any(|p| p.what.contains("several tree gates")),
744 "{p:?}"
745 );
746 }
747}