1use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15
16pub const MAX_GLUE_LINES: usize = 12;
19
20const LOGIC_KEYWORDS: [(&str, &str); 5] = [
22 ("for", "for loop"),
23 ("while", "while loop"),
24 ("until", "until loop"),
25 ("select", "select loop"),
26 ("case", "case dispatch"),
27];
28
29const MUNGING_COMMANDS: [&str; 2] = ["awk", "sed"];
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Finding {
35 pub file: PathBuf,
36 pub line: usize,
38 pub step: String,
40 pub kind: &'static str,
42 pub reasons: Vec<String>,
44}
45
46pub fn flag_reasons(body: &str) -> Vec<String> {
48 let mut reasons: Vec<String> = Vec::new();
49 for (keyword, name) in LOGIC_KEYWORDS {
50 if body.lines().any(|line| starts_command(line, keyword)) {
51 reasons.push(name.to_string());
52 }
53 }
54 for command in MUNGING_COMMANDS {
55 if body.lines().any(|line| runs_command(line, command)) {
56 reasons.push(command.to_string());
57 }
58 }
59 let count = significant_lines(body);
60 if count > MAX_GLUE_LINES {
61 reasons.push(format!("{count} lines (> {MAX_GLUE_LINES})"));
62 }
63 reasons
64}
65
66fn significant_lines(body: &str) -> usize {
69 body.lines()
70 .map(str::trim)
71 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with("set -"))
72 .count()
73}
74
75fn starts_command(line: &str, keyword: &str) -> bool {
78 line.split_whitespace().next() == Some(keyword)
79}
80
81fn runs_command(line: &str, command: &str) -> bool {
84 command_segments(line).any(|segment| segment.split_whitespace().next() == Some(command))
85}
86
87fn command_segments(line: &str) -> impl Iterator<Item = &str> {
91 line.split(['|', ';', '&', '(', ')', '`', '{', '}'])
92}
93
94pub fn find_violations(yaml_text: &str, file: impl AsRef<Path>) -> Vec<Finding> {
102 let file = file.as_ref();
103 let root = match marked_yaml::parse_yaml(0, yaml_text) {
104 Ok(marked_yaml::types::Node::Mapping(root)) => root,
105 _ => return Vec::new(),
106 };
107
108 let mut findings = Vec::new();
109 for step in steps(&root) {
110 let label = step_label(step);
111 for (kind, body, line) in bodies(step) {
112 let reasons = flag_reasons(&body);
113 if !reasons.is_empty() {
114 findings.push(Finding {
115 file: file.to_path_buf(),
116 line,
117 step: label.clone(),
118 kind,
119 reasons,
120 });
121 }
122 }
123 }
124 findings
125}
126
127fn steps(
130 root: &marked_yaml::types::MarkedMappingNode,
131) -> Vec<&marked_yaml::types::MarkedMappingNode> {
132 let mut out = Vec::new();
133 if let Some(jobs) = root.get_mapping("jobs") {
134 for (_, job) in jobs.iter() {
135 if let Some(job) = job.as_mapping() {
136 push_steps(job, &mut out);
137 }
138 }
139 }
140 if let Some(runs) = root.get_mapping("runs") {
141 push_steps(runs, &mut out);
142 }
143 out
144}
145
146fn push_steps<'a>(
148 owner: &'a marked_yaml::types::MarkedMappingNode,
149 out: &mut Vec<&'a marked_yaml::types::MarkedMappingNode>,
150) {
151 let Some(steps) = owner.get_sequence("steps") else {
152 return;
153 };
154 out.extend(steps.iter().filter_map(|step| step.as_mapping()));
155}
156
157fn step_label(step: &marked_yaml::types::MarkedMappingNode) -> String {
159 step.get_scalar("name")
160 .or_else(|| step.get_scalar("uses"))
161 .map(|node| node.as_str().to_string())
162 .unwrap_or_else(|| "<unnamed step>".to_string())
163}
164
165fn bodies(step: &marked_yaml::types::MarkedMappingNode) -> Vec<(&'static str, String, usize)> {
167 let mut out = Vec::new();
168 if let Some(run) = step.get_scalar("run") {
169 out.push(("run", run.as_str().to_string(), line_of(run.span())));
170 }
171 out.extend(github_script_body(step));
172 out
173}
174
175fn github_script_body(
178 step: &marked_yaml::types::MarkedMappingNode,
179) -> Option<(&'static str, String, usize)> {
180 let uses = step.get_scalar("uses")?;
181 if !uses.as_str().starts_with("actions/github-script") {
182 return None;
183 }
184 let script = step.get_mapping("with")?.get_scalar("script")?;
185 Some((
186 "github-script",
187 script.as_str().to_string(),
188 line_of(script.span()),
189 ))
190}
191
192fn line_of(span: &marked_yaml::Span) -> usize {
194 span.start().map(|marker| marker.line()).unwrap_or(0)
195}
196
197pub fn scan(path: impl AsRef<Path>) -> Result<Vec<Finding>> {
203 let path = path.as_ref();
204 let mut files = Vec::new();
205 if path.exists() {
206 collect_ci_files(path, &mut files)?;
207 }
208 files.sort();
209
210 let mut findings = Vec::new();
211 for file in files {
212 let text = std::fs::read_to_string(&file)
213 .with_context(|| format!("reading workflow `{}`", file.display()))?;
214 findings.extend(find_violations(&text, &file));
215 }
216 Ok(findings)
217}
218
219pub fn in_scope(repo: &Path, path: &Path, base: Option<&str>) -> Result<bool> {
222 let Some(base) = base else {
223 return Ok(true);
224 };
225 let changed = changed_under(repo, path, base)?;
226 let named_file = repo.join(path).is_file();
227 Ok(changed
228 .iter()
229 .any(|file| named_file || is_ci_file(Path::new(file))))
230}
231
232const DIFF_ARGS: [&str; 8] = [
234 "-c",
235 "core.quotepath=off",
236 "--literal-pathspecs",
237 "diff",
238 "--name-only",
239 "--no-ext-diff",
240 "--no-renames",
241 "--relative",
242];
243
244fn changed_under(repo: &Path, path: &Path, base: &str) -> Result<Vec<String>> {
246 let range = format!("{base}...HEAD");
247 let output = std::process::Command::new("git")
248 .current_dir(repo)
249 .args(DIFF_ARGS)
250 .args([range.as_str(), "--"])
251 .arg(path)
252 .output()
253 .with_context(|| format!("running `git diff` in `{}`", repo.display()))?;
254 if !output.status.success() {
255 let stderr = String::from_utf8_lossy(&output.stderr);
256 anyhow::bail!("`git diff {range}` failed: {}", stderr.trim());
257 }
258 Ok(diff_paths(&String::from_utf8_lossy(&output.stdout)))
259}
260
261fn diff_paths(stdout: &str) -> Vec<String> {
263 stdout
264 .lines()
265 .filter(|line| !line.is_empty())
266 .map(|line| crate::git_path::unquote_c_path(line).replace('\\', "/"))
267 .collect()
268}
269
270fn collect_ci_files(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
274 if path.is_file() {
275 out.push(path.to_path_buf());
276 return Ok(());
277 }
278 let entries = std::fs::read_dir(path)
279 .with_context(|| format!("reading directory `{}`", path.display()))?;
280 for entry in entries {
281 let child = crate::walk::dir_entry(entry, path)?.path();
282 if child.is_dir() {
283 collect_ci_files(&child, out)?;
284 } else if is_ci_file(&child) {
285 out.push(child);
286 }
287 }
288 Ok(())
289}
290
291fn is_ci_file(path: &Path) -> bool {
297 if !matches!(
298 path.extension().and_then(|e| e.to_str()),
299 Some("yml" | "yaml")
300 ) {
301 return false;
302 }
303 let name = path.file_name().and_then(|n| n.to_str());
304 if matches!(name, Some("action.yml" | "action.yaml")) {
305 return true;
306 }
307 path.parent()
308 .and_then(Path::file_name)
309 .and_then(|n| n.to_str())
310 == Some("workflows")
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn changed_under_reports_a_spawn_failure() {
319 let nowhere = Path::new("/nonexistent-tc-workflow-lint");
320 let err = changed_under(nowhere, Path::new(".github"), "main").unwrap_err();
321 assert!(format!("{err:#}").contains("running `git diff`"), "{err:#}");
322 }
323
324 #[test]
325 fn diff_paths_undoes_c_quoting_and_drops_blank_lines() {
326 assert_eq!(
327 diff_paths("\".github/workflows/a\\\"b.yml\"\n\n.github/action.yml\n"),
328 vec![".github/workflows/a\"b.yml", ".github/action.yml"]
329 );
330 }
331
332 #[test]
333 fn straight_line_glue_is_not_logic() {
334 assert_eq!(
335 flag_reasons(
336 "pkg=@x/linux\nmkdir -p \"$pkg/bin\"\ninstall -m 755 bin/x \"$pkg/bin/x\""
337 ),
338 Vec::<String>::new()
339 );
340 }
341
342 #[test]
343 fn a_lone_guard_around_an_exit_is_glue() {
344 let body = "set -euo pipefail\n\
345 private=$(gh api repos/x --jq .private)\n\
346 if [ \"$private\" = \"true\" ]; then\n\
347 echo \"::error::private\"\n\
348 exit 1\n\
349 fi\n\
350 echo ok\n";
351 assert_eq!(flag_reasons(body), Vec::<String>::new());
352 }
353
354 #[test]
355 fn each_loop_and_dispatch_keyword_is_named() {
356 assert_eq!(
357 flag_reasons("for name in $NAMES; do\n npm publish \"$name\"\ndone"),
358 vec!["for loop"]
359 );
360 assert_eq!(
361 flag_reasons("while read -r l; do\n echo $l\ndone"),
362 vec!["while loop"]
363 );
364 assert_eq!(
365 flag_reasons("until ok; do\n sleep 1\ndone"),
366 vec!["until loop"]
367 );
368 assert_eq!(
369 flag_reasons("select x in a b; do\n echo $x\ndone"),
370 vec!["select loop"]
371 );
372 assert_eq!(
373 flag_reasons("case \"$1\" in\n a) echo a ;;\nesac"),
374 vec!["case dispatch"]
375 );
376 }
377
378 #[test]
379 fn a_keyword_is_only_a_marker_at_a_command_position() {
380 assert_eq!(
382 flag_reasons("before_hook\nformat_output\ncasements=3"),
383 Vec::<String>::new()
384 );
385 assert_eq!(flag_reasons("echo for while case"), Vec::<String>::new());
387 }
388
389 #[test]
390 fn text_munging_counts_wherever_the_command_runs() {
391 assert_eq!(flag_reasons("awk -F/ '{print $2}' f"), vec!["awk"]);
392 assert_eq!(flag_reasons("echo x | awk -F/ '{print $2}'"), vec!["awk"]);
393 assert_eq!(flag_reasons("cat f | sed 's/a/b/'"), vec!["sed"]);
394 assert_eq!(flag_reasons("v=$(sed -n 1p f)"), vec!["sed"]);
395 assert_eq!(flag_reasons("a; sed -i s/x/y/ f"), vec!["sed"]);
396 assert_eq!(flag_reasons("ok && awk 'END{print NR}' f"), vec!["awk"]);
397 }
398
399 #[test]
400 fn a_munging_command_named_as_an_argument_is_not_running() {
401 assert_eq!(
402 flag_reasons("echo 'use awk for this'"),
403 Vec::<String>::new()
404 );
405 assert_eq!(flag_reasons("apt-get install -y sed"), Vec::<String>::new());
406 }
407
408 #[test]
409 fn a_long_straight_line_body_is_a_script_by_length() {
410 let body = (0..MAX_GLUE_LINES + 3)
411 .map(|i| format!("cmd{i}"))
412 .collect::<Vec<_>>()
413 .join("\n");
414 assert_eq!(flag_reasons(&body), vec!["15 lines (> 12)"]);
415 }
416
417 #[test]
418 fn the_length_count_ignores_bookkeeping() {
419 let body = (0..MAX_GLUE_LINES)
421 .map(|i| format!("cmd{i}"))
422 .collect::<Vec<_>>()
423 .join("\n");
424 assert_eq!(flag_reasons(&body), Vec::<String>::new());
425 assert_eq!(
426 flag_reasons("set -euo pipefail\n# a note\n\necho hi"),
427 Vec::<String>::new()
428 );
429 }
430
431 #[test]
432 fn every_reason_a_body_earns_is_reported() {
433 let body = "for f in *; do\n case $f in\n a) sed -i s/x/y/ $f ;;\n esac\ndone";
434 assert_eq!(flag_reasons(body), vec!["for loop", "case dispatch", "sed"]);
435 }
436
437 const LOOP_WORKFLOW: &str = "jobs:\n publish:\n steps:\n - name: Publish stubs\n run: |\n for name in $NAMES; do\n npm publish \"$name\"\n done\n";
438
439 #[test]
440 fn a_run_step_reports_its_file_name_and_line() {
441 let findings = find_violations(LOOP_WORKFLOW, "w.yml");
442 assert_eq!(findings.len(), 1);
443 assert_eq!(findings[0].step, "Publish stubs");
444 assert_eq!(findings[0].kind, "run");
445 assert_eq!(findings[0].reasons, vec!["for loop"]);
446 assert_eq!(findings[0].file, PathBuf::from("w.yml"));
447 assert_eq!(findings[0].line, 6, "the body starts on line 6");
448 }
449
450 #[test]
451 fn a_github_script_body_is_judged_too() {
452 let yaml = "jobs:\n label:\n steps:\n - uses: actions/github-script@v7\n with:\n script: |\n for (const i of items) {\n core.info(i)\n }\n";
453 let findings = find_violations(yaml, "w.yml");
454 assert_eq!(findings.len(), 1);
455 assert_eq!(findings[0].kind, "github-script");
456 assert_eq!(findings[0].step, "actions/github-script@v7");
457 assert_eq!(findings[0].reasons, vec!["for loop"]);
458 }
459
460 #[test]
461 fn another_actions_script_input_is_not_a_github_script_body() {
462 let yaml = "jobs:\n j:\n steps:\n - uses: some/other@v1\n with:\n script: |\n for x in 1 2; do echo $x; done\n";
463 assert_eq!(find_violations(yaml, "w.yml"), Vec::new());
464 }
465
466 #[test]
467 fn a_composite_action_is_scanned_through_runs_steps() {
468 let yaml = "runs:\n using: composite\n steps:\n - name: Munge\n shell: bash\n run: cat f | sed 's/a/b/'\n";
469 let findings = find_violations(yaml, "action.yml");
470 assert_eq!(findings.len(), 1);
471 assert_eq!(findings[0].step, "Munge");
472 assert_eq!(findings[0].reasons, vec!["sed"]);
473 }
474
475 #[test]
476 fn a_clean_workflow_yields_nothing() {
477 let yaml = "jobs:\n build:\n steps:\n - uses: actions/checkout@v4\n - run: pnpm install --frozen-lockfile\n - name: Test\n run: pnpm test\n";
478 assert_eq!(find_violations(yaml, "w.yml"), Vec::new());
479 }
480
481 #[test]
482 fn a_step_without_a_name_falls_back_to_its_uses_then_a_placeholder() {
483 let named_by_uses = "jobs:\n j:\n steps:\n - uses: actions/setup-node@v4\n run: for x in 1; do echo $x; done\n";
484 assert_eq!(
485 find_violations(named_by_uses, "w.yml")[0].step,
486 "actions/setup-node@v4"
487 );
488
489 let unnamed = "jobs:\n j:\n steps:\n - run: for x in 1; do echo $x; done\n";
490 assert_eq!(find_violations(unnamed, "w.yml")[0].step, "<unnamed step>");
491 }
492
493 #[test]
494 fn unparseable_yaml_is_actionlints_to_report_not_ours() {
495 assert_eq!(find_violations("jobs: [unclosed\n", "w.yml"), Vec::new());
496 }
497
498 #[test]
499 fn a_document_with_no_steps_anywhere_yields_nothing() {
500 assert_eq!(find_violations("name: CI\non: push\n", "w.yml"), Vec::new());
501 assert_eq!(find_violations("- a\n- b\n", "w.yml"), Vec::new());
502 assert_eq!(find_violations("", "w.yml"), Vec::new());
503 assert_eq!(
505 find_violations("jobs:\n a:\n runs-on: ubuntu-latest\n", "w.yml"),
506 Vec::new()
507 );
508 assert_eq!(
509 find_violations("runs:\n using: node20\n main: index.js\n", "action.yml"),
510 Vec::new()
511 );
512 }
513
514 #[test]
515 fn a_job_that_is_not_a_mapping_is_skipped() {
516 assert_eq!(find_violations("jobs:\n a: 3\n", "w.yml"), Vec::new());
518 }
519
520 #[test]
521 fn a_github_script_step_carrying_no_script_has_no_body() {
522 let yaml = "jobs:\n j:\n steps:\n - uses: actions/github-script@v7\n";
523 assert_eq!(find_violations(yaml, "w.yml"), Vec::new());
524 }
525
526 #[test]
527 fn discovery_follows_githubs_own_rules() {
528 assert!(is_ci_file(Path::new(".github/workflows/ci.yml")));
529 assert!(is_ci_file(Path::new(".github/workflows/ci.yaml")));
530 assert!(is_ci_file(Path::new(".github/actions/detect/action.yml")));
531 assert!(is_ci_file(Path::new("tools/thing/action.yaml")));
532
533 assert!(!is_ci_file(Path::new(
536 ".github/selftest/clean/pnpm-lock.yaml"
537 )));
538 assert!(!is_ci_file(Path::new(
539 ".github/workflows/nested/deep/ci.yml"
540 )));
541 assert!(!is_ci_file(Path::new(".github/workflows/notes.md")));
542 }
543
544 #[test]
545 fn an_unreadable_directory_names_itself() {
546 let err = collect_ci_files(
547 Path::new("tc-workflow-lint-no-such-directory"),
548 &mut Vec::new(),
549 )
550 .unwrap_err();
551 assert!(
552 format!("{err:#}").contains("reading directory"),
553 "got: {err:#}"
554 );
555 }
556
557 #[test]
558 fn every_job_in_a_workflow_is_scanned() {
559 let yaml = "jobs:\n a:\n steps:\n - run: for x in 1; do :; done\n b:\n steps:\n - run: cat f | sed s/a/b/\n";
560 let findings = find_violations(yaml, "w.yml");
561 assert_eq!(findings.len(), 2);
562 }
563}