1use std::ffi::OsStr;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use crate::money;
6use crate::usage::UsageRow;
7use crate::workflow::{
8 load_action, locate_action, repository_root, scan_yaml, ActionStep, Job, LocalAction, Matrix,
9 RemoteWorkflow, ReusableCall, ReusableSource, RunsOn, ScanError, Workflow,
10};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
14pub enum FindingKind {
15 LintOnRunner,
17 MissingTimeout,
19 PullRequestConcurrency,
21 MatrixJobs,
23 DuplicateKey,
25 InvoiceMismatch,
27 CalleeNotFound,
29 ReusableCycle,
31 RemoteWorkflow,
33 InvalidUses,
35 ActionNotFound,
37 ActionCycle,
39 RerunWaste,
41 OverlapSavings,
44}
45
46impl FindingKind {
47 pub fn code(self) -> &'static str {
49 match self {
50 Self::LintOnRunner => "F1",
51 Self::MissingTimeout => "F2",
52 Self::PullRequestConcurrency => "F3",
53 Self::MatrixJobs => "F4",
54 Self::DuplicateKey => "F8",
55 Self::InvoiceMismatch => "F9",
56 Self::CalleeNotFound => "F6",
57 Self::ReusableCycle => "F7",
58 Self::RemoteWorkflow => "F10",
59 Self::InvalidUses => "F11",
60 Self::ActionNotFound => "F12",
61 Self::ActionCycle => "F13",
62 Self::RerunWaste => "F5",
63 Self::OverlapSavings => "F3$",
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum SavingsMethod {
71 LinuxRate,
73 StuckRun,
75 Overlap,
77 Rounding,
79 Rerun,
81 #[default]
83 Recorded,
84}
85
86impl SavingsMethod {
87 pub fn as_str(self) -> &'static str {
89 match self {
90 Self::LinuxRate => "linux_rate",
91 Self::StuckRun => "stuck_run",
92 Self::Overlap => "overlap",
93 Self::Rounding => "rounding",
94 Self::Rerun => "rerun",
95 Self::Recorded => "recorded",
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Finding {
103 pub path: std::path::PathBuf,
104 pub line: usize,
105 pub kind: FindingKind,
106 pub message: String,
107 pub repository: Option<String>,
109 pub workflow_path: Option<String>,
116 pub savings: money::Micros,
118 pub method: SavingsMethod,
120 pub(crate) anchor: Option<usize>,
122}
123
124impl Finding {
125 pub(crate) fn record(
127 path: impl Into<PathBuf>,
128 line: usize,
129 kind: FindingKind,
130 message: impl Into<String>,
131 ) -> Self {
132 Self {
133 path: path.into(),
134 line,
135 kind,
136 message: message.into(),
137 repository: None,
138 workflow_path: None,
139 savings: money::Micros::ZERO,
140 method: SavingsMethod::Recorded,
141 anchor: None,
142 }
143 }
144
145 pub(crate) fn placed(
146 path: impl Into<PathBuf>,
147 line: usize,
148 kind: FindingKind,
149 message: impl Into<String>,
150 repository: Option<String>,
151 workflow_path: Option<String>,
152 ) -> Self {
153 let mut finding = Self::record(path, line, kind, message);
154 finding.repository = repository;
155 finding.workflow_path = workflow_path;
156 finding
157 }
158}
159
160const LINT_TOOLS: [&str; 5] = ["lint", "fmt", "clippy", "eslint", "prettier"];
161
162pub fn findings(workflows: &[Workflow]) -> Result<Vec<Finding>, ScanError> {
178 findings_with_cache(workflows, None)
179}
180
181pub fn findings_with_cache(
186 workflows: &[Workflow],
187 runs: Option<&Path>,
188) -> Result<Vec<Finding>, ScanError> {
189 let mut found = Vec::new();
190 for workflow in workflows {
191 emit_workflow_findings(&mut found, workflow, None)?;
192 let mut stack = vec![CallFrame {
193 path: workflow.path.clone(),
194 label: display_name(&workflow.path).to_string(),
195 }];
196 let expand = Expand {
197 workflows,
198 root: workflow,
199 caller_key: usage_key(&workflow.path),
200 runs,
201 repo: None,
202 };
203 expand_calls(&mut found, workflow, &mut stack, &expand)?;
204 }
205 dedupe_findings(&mut found);
206 found.sort_by(cmp_finding);
207 Ok(found)
208}
209
210fn emit_workflow_findings(
211 found: &mut Vec<Finding>,
212 workflow: &Workflow,
213 caller_key: Option<&str>,
214) -> Result<(), ScanError> {
215 let start = found.len();
216 if pulls_without_cancel(workflow) {
217 if let Some(line) = workflow.on_line {
218 push(
219 found,
220 &workflow.path,
221 line,
222 FindingKind::PullRequestConcurrency,
223 pr_cancel_message(workflow),
224 );
225 }
226 }
227 for dup in &workflow.duplicate_keys {
228 push(
229 found,
230 &workflow.path,
231 dup.second,
232 FindingKind::DuplicateKey,
233 &duplicate_message(&dup.key, dup.first, dup.second),
234 );
235 }
236 for job in &workflow.jobs {
237 if let Some(invalid) = &job.invalid_uses {
238 push_at(
239 found,
240 &workflow.path,
241 invalid.line,
242 FindingKind::InvalidUses,
243 &invalid_uses_message(&job.name, &invalid.detail),
244 Some(usage_key(&workflow.path)),
245 );
246 }
247 if job.uses.is_none() && job.invalid_uses.is_none() {
248 note_job(found, &workflow.path, job);
249 note_local_actions(found, workflow, job)?;
250 }
251 }
252 if let Some(key) = caller_key {
253 for item in &mut found[start..] {
254 item.workflow_path = Some(key.to_string());
255 }
256 }
257 Ok(())
258}
259
260fn duplicate_message(key: &str, first: usize, second: usize) -> String {
261 format!("duplicate key {key} (line {first} and {second})")
262}
263
264struct CallFrame {
265 path: PathBuf,
266 label: String,
267}
268
269struct Expand<'a> {
270 workflows: &'a [Workflow],
271 root: &'a Workflow,
272 caller_key: String,
273 runs: Option<&'a Path>,
274 repo: Option<RemoteWorkflow>,
276}
277
278impl Expand<'_> {
279 fn with_repo(&self, repo: RemoteWorkflow) -> Expand<'_> {
280 Expand {
281 workflows: self.workflows,
282 root: self.root,
283 caller_key: self.caller_key.clone(),
284 runs: self.runs,
285 repo: Some(repo),
286 }
287 }
288}
289
290#[derive(Clone, Copy, PartialEq, Eq)]
292enum CacheMiss {
293 ShowJob,
295 NotFound,
297}
298
299fn expand_calls(
304 found: &mut Vec<Finding>,
305 current: &Workflow,
306 stack: &mut Vec<CallFrame>,
307 exp: &Expand<'_>,
308) -> Result<(), ScanError> {
309 for job in ¤t.jobs {
310 let Some(call) = &job.uses else {
311 continue;
312 };
313 if let Some(matrix) = &job.matrix {
314 if let Some(message) = matrix_message(matrix) {
315 push_at(
316 found,
317 ¤t.path,
318 matrix.line,
319 FindingKind::MatrixJobs,
320 &message,
321 Some(exp.caller_key.clone()),
322 );
323 }
324 }
325 let site = CallSite { current, job, call };
326 match &call.source {
327 ReusableSource::Local => expand_local(found, site, stack, exp)?,
328 ReusableSource::Remote(remote) => {
329 enter_cached(found, site, stack, exp, remote, CacheMiss::ShowJob)?;
330 }
331 }
332 }
333 Ok(())
334}
335
336#[derive(Clone, Copy)]
337struct CallSite<'a> {
338 current: &'a Workflow,
339 job: &'a Job,
340 call: &'a ReusableCall,
341}
342
343fn expand_local(
344 found: &mut Vec<Finding>,
345 site: CallSite<'_>,
346 stack: &mut Vec<CallFrame>,
347 exp: &Expand<'_>,
348) -> Result<(), ScanError> {
349 let CallSite { current, job, call } = site;
350 if let Some(repo) = &exp.repo {
351 let remote = RemoteWorkflow {
352 owner: repo.owner.clone(),
353 repo: repo.repo.clone(),
354 workflow_path: format!(".github/workflows/{}", call.file_name),
355 reference: repo.reference.clone(),
356 };
357 return enter_cached(
358 found,
359 CallSite { current, job, call },
360 stack,
361 exp,
362 &remote,
363 CacheMiss::NotFound,
364 );
365 }
366 match find_callee(exp.workflows, &call.file_name) {
367 None => push_at(
368 found,
369 ¤t.path,
370 call.line,
371 FindingKind::CalleeNotFound,
372 &missing_callee_message(&job.name, &call.spec),
373 Some(exp.caller_key.clone()),
374 ),
375 Some(callee) => {
376 if stack.iter().any(|frame| frame.path == callee.path) {
377 push_at(
378 found,
379 ¤t.path,
380 call.line,
381 FindingKind::ReusableCycle,
382 &cycle_message(stack, display_name(&callee.path)),
383 Some(exp.caller_key.clone()),
384 );
385 return Ok(());
386 }
387 emit_workflow_findings(found, callee, Some(&exp.caller_key))?;
388 stack.push(CallFrame {
389 path: callee.path.clone(),
390 label: display_name(&callee.path).to_string(),
391 });
392 expand_calls(found, callee, stack, exp)?;
393 stack.pop();
394 }
395 }
396 Ok(())
397}
398
399fn enter_cached(
400 found: &mut Vec<Finding>,
401 site: CallSite<'_>,
402 stack: &mut Vec<CallFrame>,
403 exp: &Expand<'_>,
404 remote: &RemoteWorkflow,
405 miss: CacheMiss,
406) -> Result<(), ScanError> {
407 let CallSite { current, job, call } = site;
408 let Some(runs) = exp.runs else {
409 note_cache_miss(found, site, exp, miss);
410 return Ok(());
411 };
412 let Some(path) = find_cached_file(runs, &remote.cache_relative())? else {
413 note_cache_miss(found, site, exp, miss);
414 return Ok(());
415 };
416 if stack.iter().any(|frame| frame.path == path) {
417 push_at(
418 found,
419 ¤t.path,
420 call.line,
421 FindingKind::ReusableCycle,
422 &cycle_message(stack, &call.spec),
423 Some(exp.caller_key.clone()),
424 );
425 return Ok(());
426 }
427 let callee = load_cached(&path)?;
428 let before = found.len();
429 emit_workflow_findings(found, &callee, Some(&exp.caller_key))?;
430 stack.push(CallFrame {
431 path: callee.path.clone(),
432 label: call.spec.clone(),
433 });
434 let nested = exp.with_repo(remote.clone());
435 expand_calls(found, &callee, stack, &nested)?;
436 stack.pop();
437 if miss == CacheMiss::ShowJob && found.len() == before {
438 show_remote_job(found, CallSite { current, job, call }, exp);
439 }
440 Ok(())
441}
442
443fn note_cache_miss(
444 found: &mut Vec<Finding>,
445 site: CallSite<'_>,
446 exp: &Expand<'_>,
447 miss: CacheMiss,
448) {
449 match miss {
450 CacheMiss::ShowJob => show_remote_job(found, site, exp),
451 CacheMiss::NotFound => push_at(
452 found,
453 &site.current.path,
454 site.call.line,
455 FindingKind::CalleeNotFound,
456 &missing_callee_message(&site.job.name, &site.call.spec),
457 Some(exp.caller_key.clone()),
458 ),
459 }
460}
461
462fn show_remote_job(found: &mut Vec<Finding>, site: CallSite<'_>, exp: &Expand<'_>) {
463 push_at(
464 found,
465 &site.current.path,
466 site.call.line,
467 FindingKind::RemoteWorkflow,
468 &remote_job_message(&site.job.name, &site.call.spec),
469 Some(exp.caller_key.clone()),
470 );
471}
472
473fn remote_job_message(job: &str, spec: &str) -> String {
474 format!("job {job} uses {spec}")
475}
476
477fn invalid_uses_message(job: &str, detail: &str) -> String {
478 format!("invalid reusable workflow uses: job {job} uses {detail}")
479}
480
481fn find_cached_file(runs: &Path, relative: &Path) -> Result<Option<PathBuf>, ScanError> {
482 if !runs.is_dir() {
483 return Ok(None);
484 }
485 let exact = runs.join(relative);
486 if exact.is_file() {
487 return Ok(Some(exact));
488 }
489 let Some(parts) = relative_parts(relative) else {
490 return Ok(None);
491 };
492 let mut current = runs.to_path_buf();
493 for (index, part) in parts.iter().enumerate() {
494 let want_file = index + 1 == parts.len();
495 match child_ignore_ascii_case(¤t, part, want_file)? {
496 Some(next) => current = next,
497 None => return Ok(None),
498 }
499 }
500 if current.is_file() {
501 Ok(Some(current))
502 } else {
503 Ok(None)
504 }
505}
506
507fn relative_parts(relative: &Path) -> Option<Vec<std::ffi::OsString>> {
508 let mut parts = Vec::new();
509 for component in relative.components() {
510 match component {
511 Component::Normal(part) => parts.push(part.to_os_string()),
512 Component::CurDir => {}
513 Component::Prefix(_) | Component::RootDir | Component::ParentDir => return None,
514 }
515 }
516 Some(parts)
517}
518
519fn child_ignore_ascii_case(
520 dir: &Path,
521 name: &OsStr,
522 want_file: bool,
523) -> Result<Option<PathBuf>, ScanError> {
524 let entries = fs::read_dir(dir).map_err(|_| ScanError::directory(dir))?;
525 let mut exact = None;
526 let mut folded = None;
527 for entry in entries {
528 let entry = entry.map_err(|_| ScanError::directory(dir))?;
529 let entry_name = entry.file_name();
530 if !entry_name.eq_ignore_ascii_case(name) {
531 continue;
532 }
533 let path = entry.path();
534 let matches_kind = if want_file {
535 path.is_file()
536 } else {
537 path.is_dir()
538 };
539 if !matches_kind {
540 continue;
541 }
542 if entry_name == name {
543 exact = Some(path);
544 } else if folded.is_none() {
545 folded = Some(path);
546 }
547 }
548 Ok(exact.or(folded))
549}
550
551fn load_cached(path: &Path) -> Result<Workflow, ScanError> {
552 let text = fs::read_to_string(path).map_err(|_| ScanError::file(path))?;
553 scan_yaml(path, &text)
554}
555
556fn missing_callee_message(job: &str, spec: &str) -> String {
557 format!("callee not found: job {job} uses {spec}")
558}
559
560fn cycle_message(stack: &[CallFrame], next: &str) -> String {
561 let mut names: Vec<&str> = stack.iter().map(|frame| frame.label.as_str()).collect();
562 names.push(next);
563 let chain = names.join(" -> ");
564 format!("reusable workflow cycle: {chain}")
565}
566
567fn display_name(path: &Path) -> &str {
568 file_name(path).unwrap_or("workflow")
569}
570
571fn usage_key(path: &Path) -> String {
572 format!(".github/workflows/{}", display_name(path))
573}
574
575fn file_name(path: &Path) -> Option<&str> {
576 path.file_name().and_then(|name| name.to_str())
577}
578
579fn find_callee<'a>(workflows: &'a [Workflow], file_name_wanted: &str) -> Option<&'a Workflow> {
580 if let Some(exact) = workflows
581 .iter()
582 .find(|workflow| file_name(&workflow.path) == Some(file_name_wanted))
583 {
584 return Some(exact);
585 }
586 workflows.iter().find(|workflow| {
587 file_name(&workflow.path).is_some_and(|name| name.eq_ignore_ascii_case(file_name_wanted))
588 })
589}
590
591fn dedupe_findings(found: &mut Vec<Finding>) {
592 let mut kept: Vec<Finding> = Vec::new();
593 for item in found.drain(..) {
594 if !kept.iter().any(|other| same_recorded(other, &item)) {
595 kept.push(item);
596 }
597 }
598 *found = kept;
599}
600
601fn same_recorded(left: &Finding, right: &Finding) -> bool {
602 left.path == right.path
603 && left.line == right.line
604 && left.kind == right.kind
605 && left.message == right.message
606 && left.repository == right.repository
607 && left.workflow_path == right.workflow_path
608}
609
610pub fn invoice_findings(path: &Path, rows: &[UsageRow]) -> Vec<Finding> {
619 let mut found = Vec::new();
620 for row in rows {
621 let Some(message) = mismatch_message(row) else {
622 continue;
623 };
624 found.push(Finding::placed(
625 path,
626 usize::try_from(row.line).unwrap_or(usize::MAX),
627 FindingKind::InvoiceMismatch,
628 message,
629 Some(row.repository.clone()),
630 Some(row.workflow_path.clone()),
631 ));
632 }
633 found
634}
635
636pub fn combine(yaml: Option<Vec<Finding>>, invoice: Vec<Finding>) -> Option<Vec<Finding>> {
641 let mut all = match yaml {
642 Some(items) => items,
643 None if invoice.is_empty() => return None,
644 None => Vec::new(),
645 };
646 all.extend(invoice);
647 all.sort_by(cmp_finding);
648 Some(all)
649}
650
651pub fn append(base: Option<Vec<Finding>>, extra: Vec<Finding>) -> Option<Vec<Finding>> {
655 if extra.is_empty() {
656 return base;
657 }
658 let mut all = base.unwrap_or_default();
659 all.extend(extra);
660 all.sort_by(cmp_finding);
661 Some(all)
662}
663
664fn cmp_finding(left: &Finding, right: &Finding) -> std::cmp::Ordering {
665 (&left.path, left.line, left.kind, left.message.as_str()).cmp(&(
666 &right.path,
667 right.line,
668 right.kind,
669 right.message.as_str(),
670 ))
671}
672
673const GROSS_TOLERANCE_MICROS: i128 = 1;
675
676fn mismatch_message(row: &UsageRow) -> Option<String> {
677 let mut parts = Vec::new();
678 let expected_gross = money::rounded_product(row.quantity, row.applied_cost_per_quantity);
679 if (i128::from(row.gross.0) - expected_gross).abs() > GROSS_TOLERANCE_MICROS {
680 parts.push(format!(
681 "gross {} != quantity * applied_cost_per_quantity {}",
682 row.gross.to_decimal_string(),
683 money::format_micros(expected_gross),
684 ));
685 }
686 let expected_net = i128::from(row.gross.0) - i128::from(row.discount.0);
687 if expected_net != i128::from(row.net.0) {
688 parts.push(format!(
689 "net {} != gross - discount {}",
690 row.net.to_decimal_string(),
691 money::format_micros(expected_net),
692 ));
693 }
694 if parts.is_empty() {
695 return None;
696 }
697 Some(format!(
698 "invoice row mismatch: sku {}, date {}, {}",
699 debug_cell(&row.sku),
700 debug_cell(&row.date),
701 parts.join(", ")
702 ))
703}
704
705fn debug_cell(text: &str) -> String {
707 format!("{text:?}")
708}
709
710fn pulls_without_cancel(workflow: &Workflow) -> bool {
712 let pulls = workflow.has_pull_request || workflow.has_pull_request_target;
713 pulls && !workflow_cancels(workflow)
714}
715
716fn workflow_cancels(workflow: &Workflow) -> bool {
717 workflow.cancel_in_progress || workflow.jobs.iter().any(|job| job.cancel_in_progress)
718}
719
720fn pr_cancel_message(workflow: &Workflow) -> &'static str {
721 if workflow.has_pull_request {
722 "pull_request without concurrency cancel-in-progress"
723 } else {
724 "pull_request_target without concurrency cancel-in-progress"
725 }
726}
727
728fn note_job(found: &mut Vec<Finding>, path: &Path, job: &Job) {
729 if let Some(runs_on) = &job.runs_on {
730 note_runner(found, path, job, runs_on);
731 }
732 if !job.has_timeout_minutes {
733 push(
734 found,
735 path,
736 job.line,
737 FindingKind::MissingTimeout,
738 "default timeout is 360 minutes",
739 );
740 }
741 if let Some(matrix) = &job.matrix {
742 if let Some(message) = matrix_message(matrix) {
743 push(found, path, matrix.line, FindingKind::MatrixJobs, &message);
744 }
745 }
746}
747
748struct ActionFrame {
749 relative: String,
750 spec: String,
751}
752
753fn note_local_actions(
758 found: &mut Vec<Finding>,
759 workflow: &Workflow,
760 job: &Job,
761) -> Result<(), ScanError> {
762 let mut stack = Vec::new();
763 for step in &job.steps {
764 if let Some(action) = &step.local_action {
765 load_local_action(found, workflow, job, &workflow.path, action, &mut stack)?;
766 }
767 }
768 Ok(())
769}
770
771fn load_local_action(
772 found: &mut Vec<Finding>,
773 workflow: &Workflow,
774 job: &Job,
775 from: &Path,
776 action: &LocalAction,
777 stack: &mut Vec<ActionFrame>,
778) -> Result<(), ScanError> {
779 if stack.iter().any(|frame| frame.relative == action.relative) {
780 push_action(
781 found,
782 workflow,
783 from,
784 action.line,
785 FindingKind::ActionCycle,
786 &action_cycle_message(stack, &action.spec),
787 );
788 return Ok(());
789 }
790 let Some(root) = repository_root(&workflow.path) else {
791 push_missing_action(found, workflow, job, from, action);
792 return Ok(());
793 };
794 let Some(path) = locate_action(&root, &action.relative)? else {
795 push_missing_action(found, workflow, job, from, action);
796 return Ok(());
797 };
798 let parsed = load_action(&path)?;
799 if !parsed.using.trim().eq_ignore_ascii_case("composite") {
800 return Ok(());
801 }
802 stack.push(ActionFrame {
803 relative: action.relative.clone(),
804 spec: action.spec.clone(),
805 });
806 for step in &parsed.steps {
807 if let Some(line) = action_lint_line(step) {
808 note_action_runner(found, workflow, job, &path, line);
809 }
810 if let Some(nested) = &step.local_action {
811 load_local_action(found, workflow, job, &path, nested, stack)?;
812 }
813 }
814 stack.pop();
815 Ok(())
816}
817
818fn action_lint_line(step: &ActionStep) -> Option<usize> {
819 if step.run_line.is_some() && mentions_lint_tool(&step.run_text) {
820 return step.run_line;
821 }
822 if step.name_line.is_some() && mentions_lint_tool(&step.name) {
823 return step.name_line;
824 }
825 None
826}
827
828fn note_action_runner(
829 found: &mut Vec<Finding>,
830 workflow: &Workflow,
831 job: &Job,
832 action_path: &Path,
833 line: usize,
834) {
835 let Some(runs_on) = &job.runs_on else {
836 return;
837 };
838 if self_hosted(&runs_on.labels) {
839 return;
840 }
841 let anchor = runs_on.line;
842 let start = found.len();
843 let (macos, windows) = label_set_os(&runs_on.labels);
844 if macos {
845 push_action(
846 found,
847 workflow,
848 action_path,
849 line,
850 FindingKind::LintOnRunner,
851 "lint on macOS",
852 );
853 }
854 if windows {
855 push_action(
856 found,
857 workflow,
858 action_path,
859 line,
860 FindingKind::LintOnRunner,
861 "lint on Windows",
862 );
863 }
864 if let Some(matrix) = &job.matrix {
865 for hit in matrix_runner_hits(runs_on, matrix) {
866 if hit.macos {
867 push_action(
868 found,
869 workflow,
870 action_path,
871 line,
872 FindingKind::LintOnRunner,
873 &axis_lint_message("macOS", runs_on.line, &hit.axis, hit.line),
874 );
875 }
876 if hit.windows {
877 push_action(
878 found,
879 workflow,
880 action_path,
881 line,
882 FindingKind::LintOnRunner,
883 &axis_lint_message("Windows", runs_on.line, &hit.axis, hit.line),
884 );
885 }
886 }
887 }
888 for item in &mut found[start..] {
889 if item.kind == FindingKind::LintOnRunner {
890 item.anchor = Some(anchor);
891 }
892 }
893}
894
895fn push_missing_action(
896 found: &mut Vec<Finding>,
897 workflow: &Workflow,
898 job: &Job,
899 from: &Path,
900 action: &LocalAction,
901) {
902 push_action(
903 found,
904 workflow,
905 from,
906 action.line,
907 FindingKind::ActionNotFound,
908 &missing_action_message(&job.name, &action.spec),
909 );
910}
911
912fn push_action(
913 found: &mut Vec<Finding>,
914 workflow: &Workflow,
915 path: &Path,
916 line: usize,
917 kind: FindingKind,
918 message: &str,
919) {
920 push_at(
921 found,
922 path,
923 line,
924 kind,
925 message,
926 Some(usage_key(&workflow.path)),
927 );
928}
929
930fn missing_action_message(job: &str, spec: &str) -> String {
931 format!("action not found: job {job} uses {spec}")
932}
933
934fn action_cycle_message(stack: &[ActionFrame], next: &str) -> String {
935 let mut names: Vec<&str> = stack.iter().map(|frame| frame.spec.as_str()).collect();
936 names.push(next);
937 format!("composite action cycle: {}", names.join(" -> "))
938}
939
940fn matrix_message(matrix: &Matrix) -> Option<String> {
943 if let Some(hi) = matrix.jobs_hi {
944 if hi < 6 {
945 return None;
946 }
947 let noun = if hi == 1 { "job" } else { "jobs" };
948 return Some(format!(
949 "matrix runs between {} and {hi} {noun}; each job is billed in whole minutes",
950 matrix.jobs
951 ));
952 }
953 let wide = matrix.jobs >= 6 || (matrix.at_least && matrix.jobs > 0);
954 if !wide {
955 return None;
956 }
957 let noun = if matrix.jobs == 1 { "job" } else { "jobs" };
958 let message = if matrix.at_least {
959 format!(
960 "matrix runs at least {} {noun}; each job is billed in whole minutes",
961 matrix.jobs
962 )
963 } else {
964 format!(
965 "matrix runs {} {noun}; each job is billed in whole minutes",
966 matrix.jobs
967 )
968 };
969 Some(message)
970}
971
972fn job_is_lint(job: &Job) -> bool {
973 mentions_lint_tool(&job.name)
974 || job.display_name.as_deref().is_some_and(mentions_lint_tool)
975 || mentions_lint_tool(&job.steps_text)
976}
977
978fn mentions_lint_tool(text: &str) -> bool {
979 text.split(|character: char| !character.is_ascii_alphanumeric())
980 .any(|token| {
981 LINT_TOOLS
982 .iter()
983 .any(|tool| token.eq_ignore_ascii_case(tool))
984 })
985}
986
987fn note_runner(found: &mut Vec<Finding>, path: &Path, job: &Job, runs_on: &RunsOn) {
988 if !job_is_lint(job) || self_hosted(&runs_on.labels) {
989 return;
990 }
991 let (macos, windows) = label_set_os(&runs_on.labels);
992 if macos {
993 push(
994 found,
995 path,
996 runs_on.line,
997 FindingKind::LintOnRunner,
998 "lint on macOS",
999 );
1000 }
1001 if windows {
1002 push(
1003 found,
1004 path,
1005 runs_on.line,
1006 FindingKind::LintOnRunner,
1007 "lint on Windows",
1008 );
1009 }
1010 let Some(matrix) = &job.matrix else {
1011 return;
1012 };
1013 for hit in matrix_runner_hits(runs_on, matrix) {
1014 if hit.macos {
1015 push(
1016 found,
1017 path,
1018 runs_on.line,
1019 FindingKind::LintOnRunner,
1020 &axis_lint_message("macOS", runs_on.line, &hit.axis, hit.line),
1021 );
1022 }
1023 if hit.windows {
1024 push(
1025 found,
1026 path,
1027 runs_on.line,
1028 FindingKind::LintOnRunner,
1029 &axis_lint_message("Windows", runs_on.line, &hit.axis, hit.line),
1030 );
1031 }
1032 }
1033}
1034
1035fn axis_lint_message(os: &str, runs_on_line: usize, axis: &str, axis_line: usize) -> String {
1036 format!("lint on {os} (runs-on line {runs_on_line}, axis {axis} line {axis_line})")
1037}
1038
1039struct AxisHit {
1040 macos: bool,
1041 windows: bool,
1042 axis: String,
1043 line: usize,
1044}
1045
1046fn matrix_runner_hits(runs_on: &RunsOn, matrix: &crate::workflow::Matrix) -> Vec<AxisHit> {
1047 let mut hits = Vec::new();
1048 for name in runs_on_matrix_names(runs_on) {
1049 let Some(axis) = matrix.axes.iter().find(|axis| axis.name == name) else {
1050 continue;
1051 };
1052 let mut macos = false;
1053 let mut windows = false;
1054 for labels in &axis.values {
1055 let (axis_macos, axis_windows) = label_set_os(labels);
1056 macos |= axis_macos;
1057 windows |= axis_windows;
1058 }
1059 if macos || windows {
1060 hits.push(AxisHit {
1061 macos,
1062 windows,
1063 axis: axis.name.clone(),
1064 line: axis.line,
1065 });
1066 }
1067 }
1068 hits
1069}
1070
1071fn runs_on_matrix_names(runs_on: &RunsOn) -> Vec<String> {
1072 let mut names = matrix_axis_names(&runs_on.value);
1073 for label in &runs_on.labels {
1074 for name in matrix_axis_names(label) {
1075 push_unique(&mut names, name);
1076 }
1077 }
1078 names
1079}
1080
1081fn push_unique(names: &mut Vec<String>, name: String) {
1082 if !names.iter().any(|item| item == &name) {
1083 names.push(name);
1084 }
1085}
1086
1087fn matrix_axis_names(text: &str) -> Vec<String> {
1089 let chars: Vec<char> = text.chars().collect();
1090 let mut names = Vec::new();
1091 let mut index = 0;
1092 while index < chars.len() {
1093 if !starts_with_matrix(&chars, index) {
1094 index += 1;
1095 continue;
1096 }
1097 let boundary = index == 0 || !is_name_char(chars[index - 1]);
1098 let after = index + MATRIX.len();
1099 if !boundary {
1100 index += 1;
1101 continue;
1102 }
1103 if let Some(end) = dotted_axis_end(&chars, after) {
1104 push_unique(&mut names, chars[after + 1..end].iter().collect());
1105 index = end;
1106 continue;
1107 }
1108 if let Some((name, end)) = bracket_axis(&chars, after) {
1109 push_unique(&mut names, name);
1110 index = end;
1111 continue;
1112 }
1113 index += 1;
1114 }
1115 names
1116}
1117
1118const MATRIX: &[char] = &['m', 'a', 't', 'r', 'i', 'x'];
1119
1120fn starts_with_matrix(chars: &[char], index: usize) -> bool {
1121 index + MATRIX.len() <= chars.len() && chars[index..index + MATRIX.len()] == MATRIX[..]
1122}
1123
1124fn is_name_char(character: char) -> bool {
1125 character.is_ascii_alphanumeric() || character == '_'
1126}
1127
1128fn dotted_axis_end(chars: &[char], after_matrix: usize) -> Option<usize> {
1129 if after_matrix >= chars.len() || chars[after_matrix] != '.' {
1130 return None;
1131 }
1132 let start = after_matrix + 1;
1133 if start >= chars.len() || !(chars[start].is_ascii_alphabetic() || chars[start] == '_') {
1134 return None;
1135 }
1136 let mut end = start + 1;
1137 while end < chars.len() && (is_name_char(chars[end]) || chars[end] == '-') {
1138 end += 1;
1139 }
1140 while end > start && chars[end - 1] == '-' {
1141 end -= 1;
1142 }
1143 if end == start {
1144 None
1145 } else {
1146 Some(end)
1147 }
1148}
1149
1150fn bracket_axis(chars: &[char], after_matrix: usize) -> Option<(String, usize)> {
1151 let mut index = skip_ws(chars, after_matrix);
1152 if index >= chars.len() || chars[index] != '[' {
1153 return None;
1154 }
1155 index = skip_ws(chars, index + 1);
1156 if index >= chars.len() {
1157 return None;
1158 }
1159 let quote = chars[index];
1160 if quote != '\'' && quote != '"' {
1161 return None;
1162 }
1163 index += 1;
1164 let start = index;
1165 while index < chars.len() && chars[index] != quote {
1166 index += 1;
1167 }
1168 if index >= chars.len() {
1169 return None;
1170 }
1171 let name: String = chars[start..index].iter().collect();
1172 index = skip_ws(chars, index + 1);
1173 if name.is_empty() || index >= chars.len() || chars[index] != ']' {
1174 return None;
1175 }
1176 Some((name, index + 1))
1177}
1178
1179fn skip_ws(chars: &[char], mut index: usize) -> usize {
1180 while index < chars.len() && chars[index].is_whitespace() {
1181 index += 1;
1182 }
1183 index
1184}
1185
1186fn self_hosted(labels: &[String]) -> bool {
1187 labels
1188 .iter()
1189 .any(|label| label.eq_ignore_ascii_case("self-hosted"))
1190}
1191
1192fn label_set_os(labels: &[String]) -> (bool, bool) {
1194 if self_hosted(labels) {
1195 return (false, false);
1196 }
1197 let mut macos = false;
1198 let mut windows = false;
1199 for label in labels {
1200 let label = label.to_ascii_lowercase();
1201 if label == "macos" || label.starts_with("macos-") {
1202 macos = true;
1203 } else if label == "windows" || label.starts_with("windows-") {
1204 windows = true;
1205 }
1206 }
1207 (macos, windows)
1208}
1209
1210fn push(found: &mut Vec<Finding>, path: &Path, line: usize, kind: FindingKind, message: &str) {
1211 push_at(found, path, line, kind, message, None);
1212}
1213
1214fn push_at(
1215 found: &mut Vec<Finding>,
1216 path: &Path,
1217 line: usize,
1218 kind: FindingKind,
1219 message: &str,
1220 workflow_path: Option<String>,
1221) {
1222 found.push(Finding::placed(
1223 path,
1224 line,
1225 kind,
1226 message,
1227 None,
1228 workflow_path,
1229 ));
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234 use super::*;
1235 use crate::money::Micros;
1236 use crate::rank::rank;
1237 use crate::report::{render, render_json};
1238 use crate::usage::{read_usage, UsageRow};
1239 use crate::workflow::{scan_dir, scan_yaml, RemoteWorkflow, ReusableSource, Workflow};
1240 use std::fs;
1241 use std::path::{Path, PathBuf};
1242
1243 fn analyze(yaml: &str) -> Vec<Finding> {
1244 let workflow = scan_yaml(Path::new("fixture.yml"), yaml).expect("valid fixture");
1245 findings(&[workflow]).expect("findings")
1246 }
1247
1248 fn of_kind(found: &[Finding], kind: FindingKind) -> Vec<&Finding> {
1249 found.iter().filter(|item| item.kind == kind).collect()
1250 }
1251
1252 #[test]
1253 fn f1_flags_lint_tools_on_macos_and_windows() {
1254 let cases = [
1255 ("lint", "macos-latest", "lint on macOS"),
1256 ("fmt", "windows-latest", "lint on Windows"),
1257 ("clippy", "macos-14", "lint on macOS"),
1258 ("eslint", "Windows-2022", "lint on Windows"),
1259 ("prettier", "macOS", "lint on macOS"),
1260 ];
1261 for (name, runner, message) in cases {
1262 let yaml = format!(
1263 "jobs:\n {name}:\n runs-on: {runner}\n steps:\n - run: echo hi\n"
1264 );
1265 let found = analyze(&yaml);
1266 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1267 assert_eq!(f1.len(), 1, "{name} on {runner}: {found:?}");
1268 assert_eq!(f1[0].line, 3, "{name}");
1269 assert_eq!(f1[0].message, message, "{name}");
1270 assert_eq!(f1[0].path, Path::new("fixture.yml"));
1271 }
1272
1273 let yaml = r#"jobs:
1274 build:
1275 name: Check
1276 runs-on: [self-hosted, macos]
1277 steps:
1278 - run: npx prettier --check .
1279 package:
1280 runs-on: windows-latest
1281 steps:
1282 - name: Style
1283 run: cargo clippy -- -D warnings
1284"#;
1285 let found = analyze(yaml);
1286 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1287 assert_eq!(f1.len(), 1, "{found:?}");
1288 assert_eq!(f1[0].line, 8);
1289 assert_eq!(f1[0].message, "lint on Windows");
1290 assert!(
1291 f1.iter().all(|item| item.line != 4),
1292 "self-hosted label set is not paid macOS: {found:?}"
1293 );
1294 }
1295
1296 #[test]
1297 fn f1_ignores_ubuntu_and_non_lint_jobs() {
1298 let yaml = r#"jobs:
1299 lint:
1300 runs-on: ubuntu-latest
1301 steps:
1302 - run: cargo fmt --check
1303 test:
1304 runs-on: macos-latest
1305 steps:
1306 - run: cargo test
1307 hosted:
1308 runs-on: [self-hosted, linux]
1309 steps:
1310 - run: cargo clippy -- -D warnings
1311 formatted:
1312 runs-on: windows-latest
1313 steps:
1314 - run: echo formatted
1315"#;
1316 let found = analyze(yaml);
1317 assert!(
1318 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
1319 "{found:?}"
1320 );
1321 }
1322
1323 #[test]
1324 fn f2_flags_jobs_without_timeout_only() {
1325 let yaml = r#"jobs:
1326 slow:
1327 runs-on: ubuntu-latest
1328 steps:
1329 - timeout-minutes: 5
1330 run: cargo test
1331 fast:
1332 runs-on: ubuntu-latest
1333 timeout-minutes: 10
1334 steps:
1335 - run: cargo test
1336"#;
1337 let found = analyze(yaml);
1338 let f2 = of_kind(&found, FindingKind::MissingTimeout);
1339 assert_eq!(f2.len(), 1, "{found:?}");
1340 assert_eq!(f2[0].line, 2);
1341 assert_eq!(f2[0].message, "default timeout is 360 minutes");
1342 assert_eq!(f2[0].path, Path::new("fixture.yml"));
1343 }
1344
1345 #[test]
1346 fn f3_flags_pull_request_without_cancel() {
1347 let positive = r#"on:
1348 pull_request:
1349jobs:
1350 test:
1351 runs-on: ubuntu-latest
1352 timeout-minutes: 5
1353 steps:
1354 - run: cargo test
1355"#;
1356 let found = analyze(positive);
1357 let f3 = of_kind(&found, FindingKind::PullRequestConcurrency);
1358 assert_eq!(f3.len(), 1, "{found:?}");
1359 assert_eq!(f3[0].line, 1);
1360 assert_eq!(
1361 f3[0].message,
1362 "pull_request without concurrency cancel-in-progress"
1363 );
1364 assert!(of_kind(&found, FindingKind::MissingTimeout).is_empty());
1365
1366 let inline = analyze(
1367 "on: [push, pull_request]\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
1368 );
1369 let f3 = of_kind(&inline, FindingKind::PullRequestConcurrency);
1370 assert_eq!(f3.len(), 1, "{inline:?}");
1371 assert_eq!(f3[0].line, 1);
1372 }
1373
1374 #[test]
1375 fn f3_ignores_cancel_and_non_pull_request() {
1376 let cancelled = r#"on:
1377 pull_request:
1378concurrency:
1379 group: ci
1380 cancel-in-progress: true
1381jobs:
1382 test:
1383 runs-on: ubuntu-latest
1384 timeout-minutes: 5
1385 steps:
1386 - run: cargo test
1387"#;
1388 let found = analyze(cancelled);
1389 assert!(
1390 of_kind(&found, FindingKind::PullRequestConcurrency).is_empty(),
1391 "{found:?}"
1392 );
1393
1394 let push_only =
1395 "on: push\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
1396 let found = analyze(push_only);
1397 assert!(of_kind(&found, FindingKind::PullRequestConcurrency).is_empty());
1398 assert!(of_kind(&found, FindingKind::MissingTimeout).is_empty());
1399 }
1400
1401 #[test]
1402 fn ci_fixture_has_no_f2_or_f3() {
1403 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/.github/workflows");
1404 let workflows = scan_dir(&dir).unwrap();
1405 let ci = workflows
1406 .into_iter()
1407 .find(|workflow| {
1408 workflow.path.file_name().and_then(|name| name.to_str()) == Some("ci.yml")
1409 })
1410 .expect("ci.yml fixture");
1411 let found = findings(&[ci]).expect("findings");
1412 assert!(
1413 found.iter().all(|item| {
1414 !matches!(
1415 item.kind,
1416 FindingKind::MissingTimeout | FindingKind::PullRequestConcurrency
1417 )
1418 }),
1419 "{found:?}"
1420 );
1421 assert!(found.is_empty(), "{found:?}");
1422 }
1423
1424 #[test]
1425 fn f1_f2_f3_apply_to_flow_style() {
1426 let flagged = "on: {pull_request: {}}\njobs: {lint: {runs-on: macos-latest, steps: [{run: cargo fmt --check}]}}\n";
1427 let found = analyze(flagged);
1428 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1429 assert_eq!(f1.len(), 1, "{found:?}");
1430 assert_eq!(f1[0].line, 2);
1431 assert_eq!(f1[0].message, "lint on macOS");
1432 let f2 = of_kind(&found, FindingKind::MissingTimeout);
1433 assert_eq!(f2.len(), 1, "{found:?}");
1434 assert_eq!(f2[0].line, 2);
1435 assert_eq!(f2[0].message, "default timeout is 360 minutes");
1436 let f3 = of_kind(&found, FindingKind::PullRequestConcurrency);
1437 assert_eq!(f3.len(), 1, "{found:?}");
1438 assert_eq!(f3[0].line, 1);
1439 assert_eq!(
1440 f3[0].message,
1441 "pull_request without concurrency cancel-in-progress"
1442 );
1443
1444 let windows = "on: [push, pull_request]\njobs: {lint: {runs-on: windows-latest, timeout-minutes: 5, steps: [{run: cargo clippy -- -D warnings}]}}\n";
1445 let found = analyze(windows);
1446 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1447 assert_eq!(f1.len(), 1, "{found:?}");
1448 assert_eq!(f1[0].line, 2);
1449 assert_eq!(f1[0].message, "lint on Windows");
1450 assert_eq!(
1451 of_kind(&found, FindingKind::PullRequestConcurrency).len(),
1452 1,
1453 "{found:?}"
1454 );
1455 assert_eq!(
1456 of_kind(&found, FindingKind::PullRequestConcurrency)[0].line,
1457 1
1458 );
1459 assert!(
1460 of_kind(&found, FindingKind::MissingTimeout).is_empty(),
1461 "{found:?}"
1462 );
1463
1464 let clean = "on: {pull_request: {}}\nconcurrency: {group: ci, cancel-in-progress: true}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 5, steps: [{run: cargo test}]}}\n";
1465 let found = analyze(clean);
1466 assert!(found.is_empty(), "{found:?}");
1467
1468 let ubuntu_lint = "on: push\njobs: {lint: {runs-on: ubuntu-latest, timeout-minutes: 1, steps: [{run: cargo fmt --check}]}}\n";
1469 let found = analyze(ubuntu_lint);
1470 assert!(found.is_empty(), "{found:?}");
1471
1472 let map_runner = "jobs:\n lint:\n runs-on: {group: large, labels: [self-hosted, macos]}\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n";
1473 let found = analyze(map_runner);
1474 assert!(
1475 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
1476 "self-hosted label set is not paid macOS: {found:?}"
1477 );
1478 assert!(
1479 of_kind(&found, FindingKind::MissingTimeout).is_empty(),
1480 "{found:?}"
1481 );
1482
1483 let block_map = "jobs:\n prettier:\n runs-on:\n group: large\n labels:\n - self-hosted\n - windows\n timeout-minutes: 5\n steps:\n - run: npx prettier --check .\n";
1484 let found = analyze(block_map);
1485 assert!(
1486 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
1487 "self-hosted label set is not paid Windows: {found:?}"
1488 );
1489
1490 let merged = "defaults: &defaults\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - run: cargo test\non: pull_request\njobs:\n lint:\n <<: *defaults\n runs-on: macos-latest\n steps:\n - run: cargo clippy -- -D warnings\n";
1491 let found = analyze(merged);
1492 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1493 assert_eq!(f1.len(), 1, "{found:?}");
1494 assert_eq!(f1[0].line, 10);
1495 assert_eq!(f1[0].message, "lint on macOS");
1496 assert!(
1497 of_kind(&found, FindingKind::MissingTimeout).is_empty(),
1498 "{found:?}"
1499 );
1500 let f3 = of_kind(&found, FindingKind::PullRequestConcurrency);
1501 assert_eq!(f3.len(), 1, "{found:?}");
1502 assert_eq!(f3[0].line, 6);
1503
1504 let inherited = "defaults: &defaults\n name: clippy\n runs-on: macos-latest\njobs:\n build:\n <<: *defaults\n";
1505 let found = analyze(inherited);
1506 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1507 assert_eq!(f1.len(), 1, "{found:?}");
1508 assert_eq!(f1[0].line, 3);
1509 assert_eq!(f1[0].message, "lint on macOS");
1510 let f2 = of_kind(&found, FindingKind::MissingTimeout);
1511 assert_eq!(f2.len(), 1, "{found:?}");
1512 assert_eq!(f2[0].line, 5);
1513 }
1514
1515 fn matrix_case(yaml: &str) -> (Vec<Finding>, Matrix) {
1516 let workflow = scan_yaml(Path::new("fixture.yml"), yaml).expect("valid fixture");
1517 let matrix = workflow.jobs[0].matrix.clone().expect("matrix");
1518 let found = findings(&[workflow]).expect("findings");
1519 (found, matrix)
1520 }
1521
1522 fn f4_message(jobs: u64) -> String {
1523 format!("matrix runs {jobs} jobs; each job is billed in whole minutes")
1524 }
1525
1526 fn f4_at_least(jobs: u64) -> String {
1527 let noun = if jobs == 1 { "job" } else { "jobs" };
1528 format!("matrix runs at least {jobs} {noun}; each job is billed in whole minutes")
1529 }
1530
1531 #[test]
1532 fn f4_flags_two_by_three() {
1533 let yaml = "jobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n";
1534 let (found, matrix) = matrix_case(yaml);
1535 assert_eq!(matrix.jobs, 6);
1536 assert!(!matrix.at_least);
1537 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1538 assert_eq!(f4.len(), 1, "{found:?}");
1539 assert_eq!(f4[0].line, 6);
1540 assert_eq!(f4[0].path, Path::new("fixture.yml"));
1541 assert_eq!(f4[0].message, f4_message(6));
1542 assert!(!f4[0].message.contains("at least"));
1543 assert!(of_kind(&found, FindingKind::MissingTimeout).is_empty());
1544 assert!(of_kind(&found, FindingKind::LintOnRunner).is_empty());
1545
1546 let block = "jobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os:\n - ubuntu-latest\n - windows-latest\n node:\n - 14\n - 16\n - 18\n";
1547 let (found, matrix) = matrix_case(block);
1548 assert_eq!(matrix.jobs, 6);
1549 assert!(!matrix.at_least);
1550 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1551 assert_eq!(f4.len(), 1, "{found:?}");
1552 assert_eq!(f4[0].line, 6);
1553 assert_eq!(f4[0].message, f4_message(6));
1554
1555 let bill = crate::rank::Bill {
1556 total_net: crate::money::Micros::ZERO,
1557 total_minutes: crate::money::Micros::ZERO,
1558 other_products_net: crate::money::Micros::ZERO,
1559 period_from: None,
1560 period_to: None,
1561 repositories: Vec::new(),
1562 };
1563 let text = crate::report::render(&[], &bill, Some(&found), None);
1564 assert!(
1565 text.contains(
1566 "F4 fixture.yml:6: matrix runs 6 jobs; each job is billed in whole minutes"
1567 ),
1568 "{text}"
1569 );
1570 let json = crate::report::render_json(&[], &bill, Some(&found), None, None, &[]);
1571 let value: serde_json::Value = serde_json::from_str(&json).expect("json");
1572 assert_eq!(value["unattached_findings"][0]["id"], "F4");
1573 assert_eq!(value["unattached_findings"][0]["line"], 6);
1574 assert_eq!(value["unattached_findings"][0]["file"], "fixture.yml");
1575 assert_eq!(value["unattached_findings"][0]["message"], f4_message(6));
1576 }
1577
1578 #[test]
1579 fn f4_ignores_two_by_two() {
1580 let yaml = "jobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n";
1581 let (found, matrix) = matrix_case(yaml);
1582 assert_eq!(matrix.jobs, 4);
1583 assert!(!matrix.at_least);
1584 assert!(
1585 of_kind(&found, FindingKind::MatrixJobs).is_empty(),
1586 "{found:?}"
1587 );
1588 assert!(found.is_empty(), "{found:?}");
1589 }
1590
1591 #[test]
1592 fn f4_ignores_two_by_three_with_one_exclude() {
1593 let yaml = "jobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude:\n - os: ubuntu-latest\n node: 14\n";
1594 let (found, matrix) = matrix_case(yaml);
1595 assert_eq!(matrix.line, 6);
1596 assert_eq!(matrix.jobs, 5);
1597 assert!(!matrix.at_least);
1598 assert!(
1599 of_kind(&found, FindingKind::MatrixJobs).is_empty(),
1600 "{found:?}"
1601 );
1602
1603 let flow = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: [{os: ubuntu-latest, node: 14}]\n";
1604 let (found, matrix) = matrix_case(flow);
1605 assert_eq!(matrix.jobs, 5);
1606 assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1607 }
1608
1609 #[test]
1610 fn f4_flags_two_by_two_plus_two_new_includes() {
1611 let yaml = "jobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n include:\n - os: macos-latest\n node: 18\n - os: macos-latest\n node: 20\n";
1612 let (found, matrix) = matrix_case(yaml);
1613 assert_eq!(matrix.jobs, 6);
1614 assert!(!matrix.at_least);
1615 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1616 assert_eq!(f4.len(), 1, "{found:?}");
1617 assert_eq!(f4[0].line, 6);
1618 assert_eq!(f4[0].path, Path::new("fixture.yml"));
1619 assert_eq!(f4[0].message, f4_message(6));
1620
1621 let flow = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n include: [{os: macos-latest, node: 18}, {os: macos-latest, node: 20}]\n";
1622 let (found, matrix) = matrix_case(flow);
1623 assert_eq!(matrix.jobs, 6);
1624 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1625 assert_eq!(f4.len(), 1, "{found:?}");
1626 assert_eq!(f4[0].line, 5);
1627 assert_eq!(f4[0].message, f4_message(6));
1628 }
1629
1630 #[test]
1631 fn f4_expression_axis_says_at_least() {
1632 let wide = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n extra: ${{ fromJSON(needs.setup.outputs.extra) }}\n";
1633 let (found, matrix) = matrix_case(wide);
1634 assert_eq!(matrix.jobs, 6);
1635 assert!(matrix.at_least);
1636 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1637 assert_eq!(f4.len(), 1, "{found:?}");
1638 assert_eq!(f4[0].line, 5);
1639 assert_eq!(f4[0].message, f4_at_least(6));
1640
1641 let rest = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: ${{ fromJSON(needs.setup.outputs.node) }}\n";
1642 let (found, matrix) = matrix_case(rest);
1643 assert_eq!(matrix.jobs, 2);
1644 assert!(matrix.at_least);
1645 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1646 assert_eq!(f4.len(), 1, "{found:?}");
1647 assert_eq!(f4[0].line, 5);
1648 assert_eq!(f4[0].message, f4_at_least(2));
1649
1650 let quoted = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: \"${{ fromJSON(needs.setup.outputs.node) }}\"\n";
1651 let (found, matrix) = matrix_case(quoted);
1652 assert_eq!(matrix.jobs, 2);
1653 assert!(matrix.at_least);
1654 assert_eq!(
1655 of_kind(&found, FindingKind::MatrixJobs)[0].message,
1656 f4_at_least(2)
1657 );
1658
1659 let only = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n node: ${{ fromJSON(needs.setup.outputs.node) }}\n";
1660 let (found, matrix) = matrix_case(only);
1661 assert_eq!(matrix.jobs, 1);
1662 assert!(matrix.at_least);
1663 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1664 assert_eq!(f4.len(), 1, "{found:?}");
1665 assert_eq!(f4[0].message, f4_at_least(1));
1666
1667 let list_item = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, \"${{ matrix.extra }}\"]\n node: [14, 16, 18]\n";
1668 let (found, matrix) = matrix_case(list_item);
1669 assert_eq!(matrix.jobs, 6);
1670 assert!(!matrix.at_least);
1671 assert_eq!(
1672 of_kind(&found, FindingKind::MatrixJobs)[0].message,
1673 f4_message(6)
1674 );
1675 }
1676
1677 #[test]
1678 fn f4_flow_style_matrix_uses_matrix_line() {
1679 let inline = "jobs: {build: {timeout-minutes: 5, strategy: {matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}}}}\n";
1680 let (found, matrix) = matrix_case(inline);
1681 assert_eq!(matrix.jobs, 6);
1682 assert!(!matrix.at_least);
1683 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1684 assert_eq!(f4.len(), 1, "{found:?}");
1685 assert_eq!(f4[0].line, 1);
1686 assert_eq!(f4[0].message, f4_message(6));
1687
1688 let split = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}\n";
1689 let (found, matrix) = matrix_case(split);
1690 assert_eq!(matrix.line, 5);
1691 assert_eq!(matrix.jobs, 6);
1692 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1693 assert_eq!(f4.len(), 1, "{found:?}");
1694 assert_eq!(f4[0].line, 5);
1695 assert_eq!(f4[0].path, Path::new("fixture.yml"));
1696 assert_eq!(f4[0].message, f4_message(6));
1697 }
1698
1699 #[test]
1700 fn f4_expanding_include_and_partial_exclude_keep_real_counts() {
1701 let expanded = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n include:\n - os: ubuntu-latest\n node: 14\n npm: 6\n";
1702 let (found, matrix) = matrix_case(expanded);
1703 assert_eq!(matrix.jobs, 6);
1704 assert_eq!(
1705 of_kind(&found, FindingKind::MatrixJobs)[0].message,
1706 f4_message(6)
1707 );
1708
1709 let partial = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [macos-latest, windows-latest]\n version: [12, 14, 16]\n environment: [staging, production]\n exclude:\n - os: macos-latest\n version: 12\n environment: production\n - os: windows-latest\n version: 16\n";
1710 let (found, matrix) = matrix_case(partial);
1711 assert_eq!(matrix.jobs, 9);
1712 assert!(!matrix.at_least);
1713 assert_eq!(
1714 of_kind(&found, FindingKind::MatrixJobs)[0].message,
1715 f4_message(9)
1716 );
1717
1718 let added_back = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n exclude:\n - os: ubuntu-latest\n node: 14\n include:\n - os: ubuntu-latest\n node: 14\n npm: 6\n";
1719 let (found, matrix) = matrix_case(added_back);
1720 assert_eq!(matrix.jobs, 4);
1721 assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1722 }
1723
1724 #[test]
1725 fn f4_only_flags_the_wide_matrix_job() {
1726 let yaml = "jobs:\n small:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n wide:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n lint:\n runs-on: macos-latest\n strategy:\n matrix:\n node: [14, 16, 18, 20, 22, 24]\n";
1727 let workflow = scan_yaml(Path::new("fixture.yml"), yaml).expect("valid");
1728 assert_eq!(workflow.jobs[0].matrix.as_ref().unwrap().jobs, 4);
1729 assert_eq!(workflow.jobs[1].matrix.as_ref().unwrap().jobs, 6);
1730 assert_eq!(workflow.jobs[2].matrix.as_ref().unwrap().jobs, 6);
1731 let found = findings(&[workflow]).expect("findings");
1732 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1733 assert_eq!(f4.len(), 2, "{found:?}");
1734 assert_eq!(f4[0].line, 11);
1735 assert_eq!(f4[0].message, f4_message(6));
1736 assert_eq!(f4[1].line, 17);
1737 assert_eq!(f4[1].message, f4_message(6));
1738 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1739 assert_eq!(f1.len(), 1, "{found:?}");
1740 assert_eq!(f1[0].line, 15);
1741 assert_eq!(f1[0].message, "lint on macOS");
1742 let f2 = of_kind(&found, FindingKind::MissingTimeout);
1743 assert_eq!(f2.len(), 1, "{found:?}");
1744 assert_eq!(f2[0].line, 14);
1745 }
1746
1747 fn f4_between(lo: u64, hi: u64) -> String {
1748 let noun = if hi == 1 { "job" } else { "jobs" };
1749 format!("matrix runs between {lo} and {hi} {noun}; each job is billed in whole minutes")
1750 }
1751
1752 fn f8(key: &str, first: usize, second: usize) -> String {
1753 format!("duplicate key {key} (line {first} and {second})")
1754 }
1755
1756 #[test]
1757 fn f8_reports_the_later_runs_on_value() {
1758 let yaml = "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: macos-latest\n steps:\n - run: cargo fmt --check\n";
1759 let found = analyze(yaml);
1760 let f8s = of_kind(&found, FindingKind::DuplicateKey);
1761 assert_eq!(f8s.len(), 1, "{found:?}");
1762 assert_eq!(f8s[0].line, 4);
1763 assert_eq!(f8s[0].message, f8("runs-on", 3, 4));
1764 let f1 = of_kind(&found, FindingKind::LintOnRunner);
1765 assert_eq!(f1.len(), 1, "{found:?}");
1766 assert_eq!(f1[0].line, 4);
1767 assert_eq!(f1[0].message, "lint on macOS");
1768
1769 let crlf = analyze(&yaml.replace('\n', "\r\n"));
1770 let f8s = of_kind(&crlf, FindingKind::DuplicateKey);
1771 assert_eq!(f8s[0].message, f8("runs-on", 3, 4));
1772 assert_eq!(
1773 of_kind(&crlf, FindingKind::LintOnRunner)[0].message,
1774 "lint on macOS"
1775 );
1776
1777 let reversed = analyze(
1778 "jobs:\n lint:\n runs-on: macos-latest\n runs-on: ubuntu-latest\n steps:\n - run: cargo fmt --check\n",
1779 );
1780 assert_eq!(
1781 of_kind(&reversed, FindingKind::DuplicateKey)[0].message,
1782 f8("runs-on", 3, 4)
1783 );
1784 assert!(of_kind(&reversed, FindingKind::LintOnRunner).is_empty());
1785
1786 let capitals = analyze(
1787 "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: MacOS-latest\n steps:\n - run: cargo fmt --check\n",
1788 );
1789 assert_eq!(
1790 of_kind(&capitals, FindingKind::LintOnRunner)[0].message,
1791 "lint on macOS"
1792 );
1793 assert_eq!(
1794 of_kind(&capitals, FindingKind::DuplicateKey)[0].message,
1795 f8("runs-on", 3, 4)
1796 );
1797
1798 let flow = analyze(
1799 "jobs:\n lint: {\n runs-on: ubuntu-latest,\n runs-on: macos-latest,\n steps: [{run: cargo fmt --check}]\n }\n",
1800 );
1801 assert_eq!(
1802 of_kind(&flow, FindingKind::DuplicateKey)[0].message,
1803 f8("runs-on", 3, 4)
1804 );
1805 assert_eq!(
1806 of_kind(&flow, FindingKind::LintOnRunner)[0].message,
1807 "lint on macOS"
1808 );
1809
1810 let quoted = analyze(
1811 "jobs:\n lint:\n \"runs-on\": \"ubuntu-latest\"\n \"runs-on\": \"macos-latest\"\n steps:\n - run: cargo fmt --check\n",
1812 );
1813 assert_eq!(
1814 of_kind("ed, FindingKind::DuplicateKey)[0].message,
1815 f8("runs-on", 3, 4)
1816 );
1817
1818 let empty_then = analyze(
1819 "jobs:\n lint:\n runs-on: \"\"\n runs-on: macos-latest\n steps:\n - run: cargo fmt --check\n",
1820 );
1821 assert_eq!(
1822 of_kind(&empty_then, FindingKind::LintOnRunner)[0].message,
1823 "lint on macOS"
1824 );
1825
1826 let value_then_empty = analyze(
1827 "jobs:\n lint:\n runs-on: macos-latest\n runs-on: \"\"\n steps:\n - run: cargo fmt --check\n",
1828 );
1829 assert!(of_kind(&value_then_empty, FindingKind::LintOnRunner).is_empty());
1830 assert_eq!(
1831 of_kind(&value_then_empty, FindingKind::DuplicateKey)[0].message,
1832 f8("runs-on", 3, 4)
1833 );
1834
1835 let third = analyze(
1836 "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: windows-latest\n runs-on: macos-latest\n steps:\n - run: cargo fmt --check\n",
1837 );
1838 let f8s = of_kind(&third, FindingKind::DuplicateKey);
1839 assert_eq!(f8s.len(), 2, "{third:?}");
1840 assert_eq!(f8s[0].message, f8("runs-on", 3, 4));
1841 assert_eq!(f8s[1].message, f8("runs-on", 4, 5));
1842 assert_eq!(of_kind(&third, FindingKind::LintOnRunner).len(), 1);
1843
1844 let unchanged = analyze(
1845 "defaults: &defaults\n runs-on: ubuntu-latest\njobs:\n lint:\n <<: *defaults\n runs-on: macos-latest\n steps:\n - run: cargo fmt --check\n",
1846 );
1847 assert!(of_kind(&unchanged, FindingKind::DuplicateKey).is_empty());
1848 assert_eq!(
1849 of_kind(&unchanged, FindingKind::LintOnRunner)[0].message,
1850 "lint on macOS"
1851 );
1852
1853 let explicit_first = analyze(
1854 "defaults: &defaults\n runs-on: macos-latest\njobs:\n lint:\n runs-on: ubuntu-latest\n <<: *defaults\n steps:\n - run: cargo fmt --check\n",
1855 );
1856 assert!(of_kind(&explicit_first, FindingKind::DuplicateKey).is_empty());
1857 assert!(of_kind(&explicit_first, FindingKind::LintOnRunner).is_empty());
1858
1859 let missing = analyze(
1860 "jobs:\n lint:\n runs-on: ubuntu-latest\n steps:\n - run: cargo fmt --check\n",
1861 );
1862 assert!(of_kind(&missing, FindingKind::DuplicateKey).is_empty());
1863 assert!(of_kind(&missing, FindingKind::LintOnRunner).is_empty());
1864 }
1865
1866 #[test]
1867 fn f4_dynamic_exclude_says_between_lower_and_upper() {
1868 let yaml = "jobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n";
1869 let (found, matrix) = matrix_case(yaml);
1870 assert_eq!(matrix.jobs, 0);
1871 assert_eq!(matrix.jobs_hi, Some(6));
1872 assert!(!matrix.at_least);
1873 let f4 = of_kind(&found, FindingKind::MatrixJobs);
1874 assert_eq!(f4.len(), 1, "{found:?}");
1875 assert_eq!(f4[0].line, 6);
1876 assert_eq!(f4[0].message, f4_between(0, 6));
1877
1878 let crlf = yaml.replace('\n', "\r\n");
1879 let (found, matrix) = matrix_case(&crlf);
1880 assert_eq!(matrix.jobs_hi, Some(6));
1881 assert_eq!(
1882 of_kind(&found, FindingKind::MatrixJobs)[0].message,
1883 f4_between(0, 6)
1884 );
1885
1886 let added = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n include:\n - os: macos-latest\n node: 20\n";
1887 let (found, matrix) = matrix_case(added);
1888 assert_eq!(matrix.jobs, 1);
1889 assert_eq!(matrix.jobs_hi, Some(7));
1890 assert_eq!(
1891 of_kind(&found, FindingKind::MatrixJobs)[0].message,
1892 f4_between(1, 7)
1893 );
1894
1895 let narrow = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n exclude: ${{ inputs.rules }}\n";
1896 let (found, matrix) = matrix_case(narrow);
1897 assert_eq!(matrix.jobs, 0);
1898 assert_eq!(matrix.jobs_hi, Some(4));
1899 assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1900
1901 let chain = "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n include:\n - os: macos-latest\n - os: macos-latest\n npm: \"6\"\n";
1902 let (found, matrix) = matrix_case(chain);
1903 assert_eq!(matrix.jobs, 1);
1904 assert!(matrix.jobs_hi.is_none());
1905 assert!(of_kind(&found, FindingKind::MatrixJobs).is_empty());
1906 }
1907
1908 fn usd(text: &str) -> crate::money::Micros {
1909 crate::money::Micros::parse(text).unwrap()
1910 }
1911
1912 fn usage_row(
1913 sku: &str,
1914 date: &str,
1915 quantity: &str,
1916 price: &str,
1917 gross: &str,
1918 discount: &str,
1919 net: &str,
1920 ) -> UsageRow {
1921 UsageRow {
1922 line: 2,
1923 date: date.to_string(),
1924 product: "actions".to_string(),
1925 sku: sku.to_string(),
1926 quantity: usd(quantity),
1927 unit_type: "minutes".to_string(),
1928 applied_cost_per_quantity: usd(price),
1929 gross: usd(gross),
1930 discount: usd(discount),
1931 net: usd(net),
1932 username: String::new(),
1933 organization: "example-org".to_string(),
1934 repository: "example-org/app".to_string(),
1935 workflow_path: ".github/workflows/lint.yml".to_string(),
1936 }
1937 }
1938
1939 #[test]
1940 fn invoice_accepts_118_minutes_at_0_062() {
1941 let row = usage_row(
1942 "actions_macos",
1943 "2026-09-01",
1944 "118",
1945 "0.062",
1946 "7.316",
1947 "0",
1948 "7.316",
1949 );
1950 let found = invoice_findings(Path::new("usage.csv"), std::slice::from_ref(&row));
1951 assert!(found.is_empty(), "{found:?}");
1952 assert_eq!(row.net, usd("7.316"));
1953 assert_eq!(
1954 crate::money::rounded_product(row.quantity, row.applied_cost_per_quantity),
1955 row.gross.0 as i128
1956 );
1957 }
1958
1959 #[test]
1960 fn invoice_allows_one_micro_and_flags_two() {
1961 let exact = usage_row(
1962 "actions_macos",
1963 "2026-09-01",
1964 "118",
1965 "0.062",
1966 "7.316",
1967 "0",
1968 "7.316",
1969 );
1970 for (gross, expect_finding) in [
1971 ("7.315999", false),
1972 ("7.316000", false),
1973 ("7.316001", false),
1974 ("7.315998", true),
1975 ("7.316002", true),
1976 ] {
1977 let mut row = exact.clone();
1978 row.gross = usd(gross);
1979 row.net = usd(gross);
1980 let found = invoice_findings(Path::new("usage.csv"), &[row]);
1981 assert_eq!(found.is_empty(), !expect_finding, "{gross} {found:?}");
1982 if expect_finding {
1983 assert_eq!(found[0].kind.code(), "F9");
1984 assert!(
1985 found[0].message.starts_with("invoice row mismatch:"),
1986 "{found:?}"
1987 );
1988 assert!(
1989 found[0].message.contains("sku \"actions_macos\""),
1990 "{found:?}"
1991 );
1992 assert!(
1993 found[0].message.contains("date \"2026-09-01\""),
1994 "{found:?}"
1995 );
1996 assert!(
1997 found[0].message.contains(&format!(
1998 "gross {gross} != quantity * applied_cost_per_quantity 7.316000"
1999 )),
2000 "{found:?}"
2001 );
2002 assert!(!found[0].message.contains("gross - discount"), "{found:?}");
2003 }
2004 }
2005 }
2006
2007 #[test]
2008 fn invoice_rounds_a_half_micro_before_the_tolerance() {
2009 let price = "0.000001";
2011 for (gross, net, flagged) in [
2012 ("0.000000", "0.000000", false),
2013 ("0.000001", "0.000001", false),
2014 ("0.000002", "0.000002", false),
2015 ("0.000003", "0.000003", true),
2016 ("-0.000001", "-0.000001", true),
2017 ] {
2018 let row = usage_row(
2019 "actions_storage",
2020 "2026-09-02",
2021 "0.5",
2022 price,
2023 gross,
2024 "0",
2025 net,
2026 );
2027 let found = invoice_findings(Path::new("usage.csv"), &[row]);
2028 assert_eq!(found.is_empty(), !flagged, "{gross} {found:?}");
2029 }
2030
2031 let negative = usage_row(
2032 "actions_storage",
2033 "2026-09-02",
2034 "-0.5",
2035 price,
2036 "-0.000001",
2037 "0",
2038 "-0.000001",
2039 );
2040 assert!(invoice_findings(Path::new("usage.csv"), &[negative]).is_empty());
2041 }
2042
2043 #[test]
2044 fn invoice_net_must_equal_gross_minus_discount() {
2045 let matched = usage_row(
2046 "actions_macos",
2047 "2026-09-01",
2048 "118",
2049 "0.062",
2050 "7.316",
2051 "1",
2052 "6.316",
2053 );
2054 assert!(invoice_findings(Path::new("usage.csv"), &[matched]).is_empty());
2055
2056 let off_by_one = usage_row(
2057 "actions_macos",
2058 "2026-09-01",
2059 "118",
2060 "0.062",
2061 "7.316",
2062 "0",
2063 "7.315999",
2064 );
2065 let found = invoice_findings(Path::new("usage.csv"), &[off_by_one]);
2066 assert_eq!(found.len(), 1, "{found:?}");
2067 assert!(
2068 found[0]
2069 .message
2070 .contains("net 7.315999 != gross - discount 7.316000"),
2071 "{found:?}"
2072 );
2073 assert!(!found[0].message.contains("quantity *"), "{found:?}");
2074
2075 let both = usage_row(
2076 "Actions_MacOS",
2077 "2026-09-14",
2078 "118",
2079 "0.080",
2080 "7.316",
2081 "1",
2082 "7.316",
2083 );
2084 let found = invoice_findings(Path::new("bill.csv"), &[both]);
2085 assert_eq!(found.len(), 1, "{found:?}");
2086 assert_eq!(found[0].kind, FindingKind::InvoiceMismatch);
2087 assert_eq!(found[0].line, 2);
2088 assert_eq!(found[0].path, Path::new("bill.csv"));
2089 assert_eq!(found[0].repository.as_deref(), Some("example-org/app"));
2090 assert_eq!(
2091 found[0].workflow_path.as_deref(),
2092 Some(".github/workflows/lint.yml")
2093 );
2094 assert_eq!(
2095 found[0].message,
2096 "invoice row mismatch: sku \"Actions_MacOS\", date \"2026-09-14\", gross 7.316000 != quantity * applied_cost_per_quantity 9.440000, net 7.316000 != gross - discount 6.316000"
2097 );
2098 }
2099
2100 #[test]
2101 fn invoice_keeps_an_empty_sku_and_date() {
2102 let row = usage_row("", "", "10", "0.5", "1", "0", "1");
2103 let found = invoice_findings(Path::new("usage.csv"), &[row]);
2104 assert_eq!(found.len(), 1, "{found:?}");
2105 assert!(found[0].message.contains("sku \"\""), "{found:?}");
2106 assert!(found[0].message.contains("date \"\""), "{found:?}");
2107 assert!(
2108 found[0]
2109 .message
2110 .contains("gross 1.000000 != quantity * applied_cost_per_quantity 5.000000"),
2111 "{found:?}"
2112 );
2113 }
2114
2115 #[test]
2116 fn sample_usage_has_no_invoice_mismatch() {
2117 let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/usage-sample.csv");
2118 let rows = crate::usage::read_usage(&path).unwrap();
2119 let found = invoice_findings(&path, &rows);
2120 assert!(found.is_empty(), "{found:?}");
2121 assert_eq!(rows[0].line, 2);
2122 assert_eq!(rows[0].quantity, usd("118"));
2123 assert_eq!(rows[0].applied_cost_per_quantity, usd("0.062"));
2124 assert_eq!(rows[0].gross, usd("7.316"));
2125 assert_eq!(rows[0].net, usd("7.316"));
2126 }
2127
2128 fn joined(lines: &[&str]) -> String {
2129 let mut text = lines.join("\n");
2130 text.push('\n');
2131 text
2132 }
2133
2134 fn crlf(text: &str) -> String {
2135 text.replace('\n', "\r\n")
2136 }
2137
2138 fn axis_msg(os: &str, runs_on: usize, axis: &str, axis_line: usize) -> String {
2139 format!("lint on {os} (runs-on line {runs_on}, axis {axis} line {axis_line})")
2140 }
2141
2142 fn assert_f1(found: &[Finding], line: usize, messages: &[&str]) {
2143 let f1 = of_kind(found, FindingKind::LintOnRunner);
2144 let got: Vec<&str> = f1.iter().map(|item| item.message.as_str()).collect();
2145 assert_eq!(got, messages, "{found:?}");
2146 assert!(f1.iter().all(|item| item.line == line), "{found:?}");
2147 assert!(
2148 f1.iter().all(|item| item.path == Path::new("fixture.yml")),
2149 "{found:?}"
2150 );
2151 }
2152
2153 fn assert_no_f1(found: &[Finding]) {
2154 assert!(
2155 of_kind(found, FindingKind::LintOnRunner).is_empty(),
2156 "{found:?}"
2157 );
2158 }
2159
2160 const F3_PR: &str = "pull_request without concurrency cancel-in-progress";
2161 const F3_TARGET: &str = "pull_request_target without concurrency cancel-in-progress";
2162
2163 fn assert_f3(found: &[Finding], line: usize, message: &str) {
2164 let f3 = of_kind(found, FindingKind::PullRequestConcurrency);
2165 assert_eq!(f3.len(), 1, "{found:?}");
2166 assert_eq!(f3[0].line, line, "{found:?}");
2167 assert_eq!(f3[0].message, message, "{found:?}");
2168 assert_eq!(f3[0].path, Path::new("fixture.yml"));
2169 }
2170
2171 fn assert_no_f3(found: &[Finding]) {
2172 assert!(
2173 of_kind(found, FindingKind::PullRequestConcurrency).is_empty(),
2174 "{found:?}"
2175 );
2176 }
2177
2178 #[test]
2179 fn matrix_runs_on_couples_axis_and_include_values() {
2180 let positive = joined(&[
2181 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [ubuntu-latest, windows-latest]", " include:", " - os: macos-latest", ]);
2193 let windows = axis_msg("Windows", 3, "os", 9);
2194 let macos = axis_msg("macOS", 3, "os", 9);
2195 assert_f1(&analyze(&positive), 3, &[&windows, &macos]);
2196 assert_f1(&analyze(&crlf(&positive)), 3, &[&windows, &macos]);
2197
2198 let include_only = joined(&[
2199 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " include:", " - os: macos-latest", " npm: \"9\"", " - os: ubuntu-latest", ]);
2212 assert_f1(
2213 &analyze(&include_only),
2214 3,
2215 &[&axis_msg("macOS", 3, "os", 10)],
2216 );
2217
2218 let include_first = joined(&[
2219 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " include:", " - os: macos-latest", " os: [ubuntu-latest]", ]);
2231 assert_f1(
2232 &analyze(&include_first),
2233 3,
2234 &[&axis_msg("macOS", 3, "os", 11)],
2235 );
2236 assert_f1(
2237 &analyze(&crlf(&include_first)),
2238 3,
2239 &[&axis_msg("macOS", 3, "os", 11)],
2240 );
2241
2242 let expression_axis = joined(&[
2243 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " include:", " - os: windows-latest", " os: ${{ inputs.os }}", ]);
2255 assert_f1(
2256 &analyze(&expression_axis),
2257 3,
2258 &[&axis_msg("Windows", 3, "os", 11)],
2259 );
2260
2261 let capitals = joined(&[
2262 "jobs:", " lint:", " runs-on: ${{ matrix.OS }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " OS: [MacOS-latest, WINDOWS-latest]", " include:", " - OS: ubuntu-latest", ]);
2274 let windows = axis_msg("Windows", 3, "OS", 9);
2275 let macos = axis_msg("macOS", 3, "OS", 9);
2276 assert_f1(&analyze(&capitals), 3, &[&windows, &macos]);
2277
2278 let reordered = joined(&[
2279 "jobs:", " lint:", " strategy:", " matrix:", " os: [macos-latest]", " steps:", " - run: cargo fmt --check", " timeout-minutes: 5", " runs-on: ${{ matrix.os }}", ]);
2289 assert_f1(&analyze(&reordered), 9, &[&axis_msg("macOS", 9, "os", 5)]);
2290
2291 let split_values = joined(&[
2292 "jobs:", " prettier:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: npx prettier --check .", " strategy:", " matrix:", " os: [self-hosted, macos-latest]", ]);
2302 assert_f1(
2303 &analyze(&split_values),
2304 3,
2305 &[&axis_msg("macOS", 3, "os", 9)],
2306 );
2307
2308 let reversed_split = joined(&[
2309 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo clippy -- -D warnings", " strategy:", " matrix:", " os: [WINDOWS-2022, Self-Hosted]", ]);
2319 assert_f1(
2320 &analyze(&reversed_split),
2321 3,
2322 &[&axis_msg("Windows", 3, "os", 9)],
2323 );
2324
2325 let map_value = joined(&[
2326 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os:", " - group: large", " labels: [self-hosted, macos]", " - group: large", " labels: [windows-latest]", ]);
2340 assert_f1(&analyze(&map_value), 3, &[&axis_msg("Windows", 3, "os", 9)]);
2341
2342 let excluded = joined(&[
2343 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [ubuntu-latest, macos-latest]", " exclude:", " - os: macos-latest", ]);
2355 assert_f1(&analyze(&excluded), 3, &[&axis_msg("macOS", 3, "os", 9)]);
2356
2357 let flow = joined(&[
2358 "jobs: {", " lint: {", " runs-on: \"${{ matrix.os }}\",", " timeout-minutes: 5,", " steps: [{run: cargo fmt --check}],", " strategy: {", " matrix: {", " os: [ubuntu-latest, windows-latest],", " include: [{os: macos-latest}]", " }", " }", " }", "}", ]);
2372 let windows = axis_msg("Windows", 3, "os", 8);
2373 let macos = axis_msg("macOS", 3, "os", 8);
2374 assert_f1(&analyze(&flow), 3, &[&windows, &macos]);
2375 assert_f1(&analyze(&crlf(&flow)), 3, &[&windows, &macos]);
2376
2377 let ubuntu = joined(&[
2378 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [ubuntu-latest, ubuntu-22.04]", " include:", " - os: ubuntu-24.04", ]);
2390 assert_no_f1(&analyze(&ubuntu));
2391 assert_no_f1(&analyze(&crlf(&ubuntu)));
2392
2393 let nested_hosted = joined(&[
2394 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os:", " - [self-hosted, macos]", " - [Self-Hosted, Windows]", ]);
2406 assert_no_f1(&analyze(&nested_hosted));
2407
2408 let include_hosted = joined(&[
2409 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " include:", " - os: [self-hosted, macos]", " - os: [windows, Self-Hosted]", ]);
2421 assert_no_f1(&analyze(&include_hosted));
2422
2423 let not_lint = joined(&[
2424 "jobs:", " test:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo test", " strategy:", " matrix:", " os: [macos-latest, windows-latest]", ]);
2434 assert_no_f1(&analyze(¬_lint));
2435
2436 let other_axis = joined(&[
2437 "jobs:", " lint:", " runs-on: ${{ matrix.node }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [macos-latest, windows-latest]", " node: ['14', '16']", ]);
2448 assert_no_f1(&analyze(&other_axis));
2449
2450 let wrong_case = joined(&[
2451 "jobs:", " lint:", " runs-on: ${{ matrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " OS: [MacOS-latest, WINDOWS-latest]", ]);
2461 assert_no_f1(&analyze(&wrong_case));
2462
2463 let missing = [
2464 joined(&[
2465 "jobs:",
2466 " lint:",
2467 " runs-on: ${{ matrix.os }}",
2468 " timeout-minutes: 5",
2469 " steps:",
2470 " - run: cargo fmt --check",
2471 ]),
2472 joined(&[
2473 "jobs:",
2474 " lint:",
2475 " runs-on: ${{ matrix.os }}",
2476 " timeout-minutes: 5",
2477 " steps:",
2478 " - run: cargo fmt --check",
2479 " strategy:",
2480 " matrix: {}",
2481 ]),
2482 joined(&[
2483 "jobs:",
2484 " lint:",
2485 " runs-on: ${{ matrix.os }}",
2486 " timeout-minutes: 5",
2487 " steps:",
2488 " - run: cargo fmt --check",
2489 " strategy:",
2490 " matrix:",
2491 " os: []",
2492 ]),
2493 joined(&[
2494 "jobs:",
2495 " lint:",
2496 " runs-on: ${{ matrix.os }}",
2497 " timeout-minutes: 5",
2498 " steps:",
2499 " - run: cargo fmt --check",
2500 " strategy:",
2501 " matrix:",
2502 " os: ${{ inputs.os }}",
2503 ]),
2504 joined(&[
2505 "jobs:",
2506 " lint:",
2507 " runs-on: \"\"",
2508 " timeout-minutes: 5",
2509 " steps:",
2510 " - run: cargo fmt --check",
2511 " strategy:",
2512 " matrix:",
2513 " os: [macos-latest]",
2514 ]),
2515 joined(&[
2516 "jobs:",
2517 " lint:",
2518 " timeout-minutes: 5",
2519 " steps:",
2520 " - run: cargo fmt --check",
2521 " strategy:",
2522 " matrix:",
2523 " os: [macos-latest, windows-latest]",
2524 ]),
2525 joined(&[
2526 "jobs:",
2527 " lint:",
2528 " runs-on: ${{ matrix.os }}",
2529 " timeout-minutes: 5",
2530 " steps:",
2531 " - run: cargo fmt --check",
2532 " strategy:",
2533 " matrix:",
2534 " include: []",
2535 ]),
2536 joined(&[
2537 "jobs:",
2538 " lint:",
2539 " runs-on: ${{ matrix.missing }}",
2540 " timeout-minutes: 5",
2541 " steps:",
2542 " - run: cargo fmt --check",
2543 " strategy:",
2544 " matrix:",
2545 " os: [macos-latest]",
2546 ]),
2547 joined(&[
2548 "jobs:",
2549 " lint:",
2550 " runs-on: ${{ matrix.os }}",
2551 " timeout-minutes: 5",
2552 " steps:",
2553 " - run: cargo fmt --check",
2554 " strategy:",
2555 " matrix:",
2556 " include:",
2557 " - os: \"\"",
2558 ]),
2559 String::new(),
2560 "{}\n".to_string(),
2561 ];
2562 for yaml in missing {
2563 assert_no_f1(&analyze(&yaml));
2564 assert_no_f1(&analyze(&crlf(&yaml)));
2565 }
2566
2567 let flow_negative = joined(&[
2568 "jobs: {",
2569 " lint: {",
2570 " runs-on: \"${{ matrix.os }}\",",
2571 " timeout-minutes: 5,",
2572 " steps: [{run: cargo fmt --check}],",
2573 " strategy: {matrix: {os: [ubuntu-latest]}}",
2574 " }",
2575 "}",
2576 ]);
2577 assert_no_f1(&analyze(&flow_negative));
2578 }
2579
2580 #[test]
2581 fn matrix_reference_inside_text_couples_that_axis() {
2582 let inside = joined(&[
2583 "jobs:", " fmt:", " runs-on: ${{ format('{0}', matrix.os) }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [ubuntu-latest, macos-14]", ]);
2593 assert_f1(&analyze(&inside), 3, &[&axis_msg("macOS", 3, "os", 9)]);
2594 assert_f1(
2595 &analyze(&crlf(&inside)),
2596 3,
2597 &[&axis_msg("macOS", 3, "os", 9)],
2598 );
2599
2600 let both = joined(&[
2601 "jobs:", " lint:", " runs-on: pre-${{ matrix.os }}-${{ matrix.image }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [ubuntu-latest]", " image: [macos-latest, windows-latest]", ]);
2612 let windows = axis_msg("Windows", 3, "image", 10);
2613 let macos = axis_msg("macOS", 3, "image", 10);
2614 assert_f1(&analyze(&both), 3, &[&windows, &macos]);
2615
2616 let bracket = joined(&[
2617 "jobs:", " eslint:", " runs-on: ${{ matrix['os'] }}", " timeout-minutes: 5", " steps:", " - run: npx eslint .", " strategy:", " matrix:", " os: [windows-latest]", ]);
2627 assert_f1(&analyze(&bracket), 3, &[&axis_msg("Windows", 3, "os", 9)]);
2628
2629 let spaced = joined(&[
2630 "jobs:", " lint:", " name: Clippy", " runs-on: ${{ matrix[ \"OS\" ] }}", " timeout-minutes: 5", " steps:", " - run: echo hi", " strategy:", " matrix:", " OS: [MacOS]", ]);
2641 assert_f1(&analyze(&spaced), 4, &[&axis_msg("macOS", 4, "OS", 10)]);
2642
2643 let extra = joined(&[
2644 "jobs:", " lint:", " runs-on:", " - ubuntu-latest", " - ${{ matrix.extra }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " extra: [windows]", ]);
2656 assert_f1(&analyze(&extra), 3, &[&axis_msg("Windows", 3, "extra", 11)]);
2657
2658 let hosted_list = joined(&[
2659 "jobs:", " lint:", " runs-on:", " - ${{ matrix.os }}", " - self-hosted", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [macos-latest, windows-latest]", ]);
2671 assert_no_f1(&analyze(&hosted_list));
2672 assert_no_f1(&analyze(&crlf(&hosted_list)));
2673
2674 let not_matrix = joined(&[
2675 "jobs:", " lint:", " runs-on: ${{ notmatrix.os }}", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", " strategy:", " matrix:", " os: [macos-latest]", ]);
2685 assert_no_f1(&analyze(¬_matrix));
2686
2687 let flow = joined(&[
2688 "jobs: {", " lint: {", " runs-on: \"runner-${{ matrix.os }}\",", " timeout-minutes: 5,", " steps: [{run: cargo fmt --check}],", " strategy: {matrix: {os: [ubuntu-latest, MacOS-latest]}}", " }", "}", ]);
2697 assert_f1(&analyze(&flow), 3, &[&axis_msg("macOS", 3, "os", 6)]);
2698
2699 let flow_negative = joined(&[
2700 "jobs: {",
2701 " lint: {",
2702 " runs-on: \"${{ matrix.node }}\",",
2703 " timeout-minutes: 5,",
2704 " steps: [{run: cargo fmt --check}],",
2705 " strategy: {matrix: {os: [macos-latest], node: ['14']}}",
2706 " }",
2707 "}",
2708 ]);
2709 assert_no_f1(&analyze(&flow_negative));
2710 assert_no_f1(&analyze(&crlf(&flow_negative)));
2711 }
2712
2713 #[test]
2714 fn self_hosted_label_set_is_not_paid_macos_or_windows() {
2715 let negative = [
2716 "jobs:\n lint:\n runs-on: [self-hosted, macos]\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2717 "jobs:\n lint:\n runs-on: [macos, self-hosted]\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2718 "jobs:\n lint:\n runs-on: [Self-Hosted, MacOS]\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2719 "jobs:\n prettier:\n runs-on: [WINDOWS, SELF-HOSTED]\n timeout-minutes: 5\n steps:\n - run: npx prettier --check .\n",
2720 "jobs:\n lint:\n runs-on: [windows-latest, Self-Hosted]\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2721 "jobs:\n lint:\n runs-on:\n - self-hosted\n - windows\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2722 "jobs:\n lint:\n runs-on:\n group: large\n labels:\n - windows\n - self-hosted\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2723 "jobs:\n lint:\n runs-on: {group: large, labels: [self-hosted, macos]}\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2724 "jobs:\n lint:\n runs-on: {group: macos}\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2725 "jobs:\n lint:\n runs-on: []\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2726 "jobs:\n lint:\n runs-on: \"\"\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2727 "jobs:\n lint:\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
2728 "jobs: {lint: {runs-on: [self-hosted, macos], timeout-minutes: 5, steps: [{run: cargo fmt --check}]}}\n",
2729 "jobs: {lint: {runs-on: {group: large, labels: [macos, Self-Hosted]}, timeout-minutes: 5, steps: [{run: cargo fmt --check}]}}\n",
2730 ];
2731 for yaml in negative {
2732 assert_no_f1(&analyze(yaml));
2733 assert_no_f1(&analyze(&crlf(yaml)));
2734 }
2735
2736 let paid = joined(&[
2737 "jobs:", " lint:", " runs-on: [MacOS-14]", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", ]);
2744 assert_f1(&analyze(&paid), 3, &["lint on macOS"]);
2745 assert_f1(&analyze(&crlf(&paid)), 3, &["lint on macOS"]);
2746
2747 let both = joined(&[
2748 "jobs:", " lint:", " runs-on: [macos-latest, windows-latest]", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", ]);
2755 assert_f1(&analyze(&both), 3, &["lint on Windows", "lint on macOS"]);
2756
2757 let exact = joined(&[
2758 "jobs:", " eslint:", " runs-on: Windows", " timeout-minutes: 5", " steps:", " - run: echo hi", ]);
2765 assert_f1(&analyze(&exact), 3, &["lint on Windows"]);
2766
2767 let map = joined(&[
2768 "jobs:", " lint:", " runs-on:", " group: large", " labels: [macos-latest]", " timeout-minutes: 5", " steps:", " - run: cargo fmt --check", ]);
2777 assert_f1(&analyze(&map), 3, &["lint on macOS"]);
2778
2779 let flow = "jobs: {lint: {runs-on: {group: large, labels: [macos-latest, windows]}, timeout-minutes: 5, steps: [{run: cargo fmt --check}]}}\n";
2780 assert_f1(&analyze(flow), 1, &["lint on Windows", "lint on macOS"]);
2781 assert_f1(
2782 &analyze(&crlf(flow)),
2783 1,
2784 &["lint on Windows", "lint on macOS"],
2785 );
2786 }
2787
2788 fn pr_with_cancel(value: &str) -> String {
2789 format!("on: pull_request\nconcurrency:\n group: ci\n cancel-in-progress: {value}\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n steps:\n - run: cargo test\n")
2790 }
2791
2792 fn job_with_cancel(value: &str) -> String {
2793 format!("on: pull_request\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n concurrency:\n group: ci\n cancel-in-progress: {value}\n steps:\n - run: cargo test\n")
2794 }
2795
2796 #[test]
2797 fn cancel_expression_counts_as_true_for_f3() {
2798 let cases = [
2799 (r"${{ github.event_name == 'pull_request' }}", true),
2800 (r"${{github.event_name=='pull_request'}}", true),
2801 (r#"${{ github.event_name == "pull_request" }}"#, true),
2802 (r"${{ github.event_name == 'pull_request' }}", true),
2803 (r"${{ GitHub.Event_Name == 'PULL_REQUEST' }}", true),
2804 (r"${{ github.event_name == 'pull_request_target' }}", true),
2805 (r#"${{ github.event_name == "Pull_Request_Target" }}"#, true),
2806 (
2807 r"${{ github.event_name == 'pull_request' || github.event_name == 'push' }}",
2808 true,
2809 ),
2810 (r"${{ false || github.event_name == 'pull_request' }}", true),
2811 (r"${{ (github.event_name == 'pull_request') }}", true),
2812 ("(github.event_name == 'pull_request')", true),
2813 ("true", true),
2814 ("TRUE", true),
2815 ("True", true),
2816 (r"${{ true }}", true),
2817 ("false", false),
2818 ("FALSE", false),
2819 (r"${{ false }}", false),
2820 (r"${{ github.event_name == 'push' }}", false),
2821 (r"${{ github.event_name != 'pull_request' }}", false),
2822 (r"${{ github.event_name == 'pull_request_review' }}", false),
2823 (
2824 r"${{ github.event_name == 'pull_request' && false }}",
2825 false,
2826 ),
2827 ("yes", false),
2828 ("", false),
2829 ("~", false),
2830 ("null", false),
2831 ];
2832 for (value, enables) in cases {
2833 for yaml in [
2834 pr_with_cancel(value),
2835 crlf(&pr_with_cancel(value)),
2836 job_with_cancel(value),
2837 crlf(&job_with_cancel(value)),
2838 ] {
2839 let found = analyze(&yaml);
2840 if enables {
2841 assert_no_f3(&found);
2842 } else {
2843 assert_f3(&found, 1, F3_PR);
2844 }
2845 }
2846 }
2847
2848 let folded = "on: pull_request\nconcurrency:\n cancel-in-progress: >-\n ${{ github.event_name == 'pull_request' }}\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n";
2849 assert_no_f3(&analyze(folded));
2850 assert_no_f3(&analyze(&crlf(folded)));
2851
2852 let literal_block = "on: pull_request\nconcurrency:\n cancel-in-progress: |\n ${{ github.event_name == \"pull_request\" }}\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2853 assert_no_f3(&analyze(literal_block));
2854
2855 let flow = "on: {pull_request: {}}\nconcurrency: {group: ci, cancel-in-progress: \"${{ github.event_name == 'pull_request' }}\"}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 1, steps: [{run: cargo test}]}}\n";
2856 assert_no_f3(&analyze(flow));
2857 assert_no_f3(&analyze(&crlf(flow)));
2858
2859 let flow_job = "on: {pull_request: {}}\njobs: {test: {concurrency: {cancel-in-progress: \"${{ github.event_name == \\\"pull_request\\\" }}\"}, runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
2860 assert_no_f3(&analyze(flow_job));
2861
2862 let flow_false = "on: [pull_request]\njobs: {test: {concurrency: {cancel-in-progress: false}, timeout-minutes: 1, runs-on: ubuntu-latest}}\n";
2863 assert_f3(&analyze(flow_false), 1, F3_PR);
2864 assert_f3(&analyze(&crlf(flow_false)), 1, F3_PR);
2865
2866 let missing_key = "on: pull_request\nconcurrency:\n group: ci\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2867 assert_f3(&analyze(missing_key), 1, F3_PR);
2868
2869 let empty_map = "on: pull_request\nconcurrency: {}\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2870 assert_f3(&analyze(empty_map), 1, F3_PR);
2871 assert_f3(&analyze(&crlf(empty_map)), 1, F3_PR);
2872
2873 let empty_value = "on: pull_request\nconcurrency:\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2874 assert_f3(&analyze(empty_value), 1, F3_PR);
2875
2876 let group_only = "on: pull_request\nconcurrency: ci\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2877 assert_f3(&analyze(group_only), 1, F3_PR);
2878
2879 let job_group_only = "on: pull_request\njobs:\n test:\n concurrency:\n group: ci\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2880 assert_f3(&analyze(job_group_only), 1, F3_PR);
2881
2882 let last_wins = "on: pull_request\nconcurrency:\n cancel-in-progress: false\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2883 let found = analyze(last_wins);
2884 assert_no_f3(&found);
2885 assert_eq!(of_kind(&found, FindingKind::DuplicateKey).len(), 1);
2886
2887 let last_false = "on: pull_request\nconcurrency:\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n cancel-in-progress: false\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2888 let found = analyze(last_false);
2889 assert_f3(&found, 1, F3_PR);
2890 assert_eq!(of_kind(&found, FindingKind::DuplicateKey).len(), 1);
2891
2892 let job_last_true = "on: pull_request\njobs:\n test:\n concurrency:\n cancel-in-progress: false\n cancel-in-progress: true\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2893 assert_no_f3(&analyze(job_last_true));
2894 assert_no_f3(&analyze(&crlf(job_last_true)));
2895 }
2896
2897 #[test]
2898 fn job_level_concurrency_counts_for_f3() {
2899 let one_job = joined(&[
2900 "on: pull_request", "jobs:", " test:", " runs-on: ubuntu-latest", " timeout-minutes: 1", " concurrency:", " group: ci", " cancel-in-progress: true", " steps:", " - run: cargo test", ]);
2911 assert_no_f3(&analyze(&one_job));
2912 assert_no_f3(&analyze(&crlf(&one_job)));
2913
2914 let concurrency_first = joined(&[
2915 "on: pull_request",
2916 "jobs:",
2917 " test:",
2918 " concurrency:",
2919 " cancel-in-progress: TRUE",
2920 " runs-on: ubuntu-latest",
2921 " timeout-minutes: 1",
2922 ]);
2923 assert_no_f3(&analyze(&concurrency_first));
2924
2925 let one_of_two = joined(&[
2926 "on: pull_request",
2927 "jobs:",
2928 " lint:",
2929 " runs-on: ubuntu-latest",
2930 " timeout-minutes: 1",
2931 " concurrency:",
2932 " cancel-in-progress: true",
2933 " steps:",
2934 " - run: cargo test",
2935 " build:",
2936 " runs-on: ubuntu-latest",
2937 " timeout-minutes: 1",
2938 " steps:",
2939 " - run: cargo test",
2940 ]);
2941 assert_no_f3(&analyze(&one_of_two));
2942 assert_no_f3(&analyze(&crlf(&one_of_two)));
2943
2944 let workflow_false_job_true = "on: pull_request\nconcurrency:\n cancel-in-progress: false\njobs:\n test:\n concurrency:\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2945 assert_no_f3(&analyze(workflow_false_job_true));
2946
2947 let workflow_true_job_false = "on: pull_request\nconcurrency:\n cancel-in-progress: true\njobs:\n test:\n concurrency:\n cancel-in-progress: false\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
2948 assert_no_f3(&analyze(workflow_true_job_false));
2949 assert_no_f3(&analyze(&crlf(workflow_true_job_false)));
2950
2951 let anchored = "on: pull_request\ndefaults: &job\n concurrency:\n cancel-in-progress: true\n timeout-minutes: 5\n runs-on: ubuntu-latest\njobs:\n test:\n <<: *job\n steps:\n - run: cargo test\n";
2952 assert_no_f3(&analyze(anchored));
2953
2954 let flow = "on: {pull_request: {}}\njobs: {test: {concurrency: {group: ci, cancel-in-progress: true}, runs-on: ubuntu-latest, timeout-minutes: 1, steps: [{run: cargo test}]}}\n";
2955 assert_no_f3(&analyze(flow));
2956 assert_no_f3(&analyze(&crlf(flow)));
2957
2958 let job_false = joined(&[
2959 "on: pull_request",
2960 "jobs:",
2961 " test:",
2962 " concurrency:",
2963 " cancel-in-progress: false",
2964 " runs-on: ubuntu-latest",
2965 " timeout-minutes: 1",
2966 ]);
2967 assert_f3(&analyze(&job_false), 1, F3_PR);
2968 assert_f3(&analyze(&crlf(&job_false)), 1, F3_PR);
2969
2970 let neither = joined(&[
2971 "on: pull_request",
2972 "jobs:",
2973 " lint:",
2974 " runs-on: ubuntu-latest",
2975 " timeout-minutes: 1",
2976 " build:",
2977 " runs-on: ubuntu-latest",
2978 " timeout-minutes: 1",
2979 ]);
2980 assert_f3(&analyze(&neither), 1, F3_PR);
2981
2982 let no_jobs = "on: pull_request\n";
2983 assert_f3(&analyze(no_jobs), 1, F3_PR);
2984 assert_f3(&analyze(&crlf(no_jobs)), 1, F3_PR);
2985
2986 let empty_jobs = "on: pull_request\njobs: {}\n";
2987 assert_f3(&analyze(empty_jobs), 1, F3_PR);
2988
2989 let flow_false = "on: [pull_request]\njobs: {lint: {concurrency: {cancel-in-progress: false}, timeout-minutes: 1}, test: {runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
2990 assert_f3(&analyze(flow_false), 1, F3_PR);
2991 }
2992
2993 #[test]
2994 fn pull_request_target_triggers_f3() {
2995 let positive = [
2996 "on: pull_request_target\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n",
2997 "on: \"pull_request_target\"\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
2998 "on: 'PULL_REQUEST_TARGET'\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
2999 "on: [push, pull_request_target]\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
3000 "on: [pull_request_target, push]\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
3001 "on: [Push, Pull_Request_Target]\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
3002 "on:\n pull_request_target:\n types: [opened]\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
3003 "on: {pull_request_target: {}}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 1}}\n",
3004 "on: PULL_REQUEST_TARGET\n",
3005 ];
3006 for yaml in positive {
3007 assert_f3(&analyze(yaml), 1, F3_TARGET);
3008 assert_f3(&analyze(&crlf(yaml)), 1, F3_TARGET);
3009 }
3010
3011 let both = "on:\n pull_request_target:\n pull_request:\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
3012 assert_f3(&analyze(both), 1, F3_PR);
3013 assert_f3(&analyze(&crlf(both)), 1, F3_PR);
3014
3015 let both_flow = "on: [pull_request, pull_request_target]\n";
3016 assert_f3(&analyze(both_flow), 1, F3_PR);
3017
3018 let both_order = "on: {pull_request_target: {}, pull_request: {}}\n";
3019 assert_f3(&analyze(both_order), 1, F3_PR);
3020
3021 let cancelled = "on: pull_request_target\nconcurrency:\n group: ci\n cancel-in-progress: true\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n";
3022 assert_no_f3(&analyze(cancelled));
3023 assert_no_f3(&analyze(&crlf(cancelled)));
3024
3025 let expression = "on: pull_request_target\nconcurrency:\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
3026 assert_no_f3(&analyze(expression));
3027
3028 let target_expression = "on: PULL_REQUEST_TARGET\nconcurrency:\n cancel-in-progress: ${{ github.event_name == \"pull_request_target\" }}\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n";
3029 assert_no_f3(&analyze(target_expression));
3030 assert_no_f3(&analyze(&crlf(target_expression)));
3031
3032 let job_level = "on: {pull_request_target: {types: [opened]}}\njobs: {test: {concurrency: {cancel-in-progress: true}, runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
3033 assert_no_f3(&analyze(job_level));
3034 assert_no_f3(&analyze(&crlf(job_level)));
3035
3036 let negative = [
3037 "on: push\njobs:\n test:\n timeout-minutes: 1\n runs-on: ubuntu-latest\n",
3038 "on: pull_request_review\n",
3039 "on: workflow_dispatch\n",
3040 "on: [push, workflow_dispatch]\n",
3041 "on:\n",
3042 "on: {}\n",
3043 "on: []\n",
3044 "on: \"\"\n",
3045 "jobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 1\n",
3046 "",
3047 "{}\n",
3048 "on: {push: null, workflow_dispatch: {}}\nconcurrency: {cancel-in-progress: false}\n",
3049 ];
3050 for yaml in negative {
3051 assert_no_f3(&analyze(yaml));
3052 assert_no_f3(&analyze(&crlf(yaml)));
3053 }
3054
3055 let flow_positive = "on: {pull_request_target: {types: [opened, synchronize]}}\njobs: {lint: {runs-on: ubuntu-latest, timeout-minutes: 5, steps: [{run: cargo test}]}}\n";
3056 assert_f3(&analyze(flow_positive), 1, F3_TARGET);
3057
3058 let flow_cancelled = "on: [pull_request_target]\nconcurrency: {cancel-in-progress: \"${{ github.event_name == 'pull_request_target' }}\"}\njobs: {test: {runs-on: ubuntu-latest, timeout-minutes: 1}}\n";
3059 assert_no_f3(&analyze(flow_cancelled));
3060 assert_no_f3(&analyze(&crlf(flow_cancelled)));
3061 }
3062
3063 const LINT_CALLEE: &str = "\
3064name: Lint
3065on: workflow_call
3066jobs:
3067 lint:
3068 runs-on: macos-latest
3069 timeout-minutes: 10
3070 steps:
3071 - run: cargo fmt --check
3072";
3073
3074 const MATRIX_CALLEE: &str = "\
3075name: Wide
3076on: workflow_call
3077jobs:
3078 build:
3079 runs-on: ubuntu-latest
3080 timeout-minutes: 5
3081 strategy:
3082 matrix:
3083 os: [ubuntu-latest, windows-latest]
3084 node: [14, 16, 18]
3085 steps:
3086 - run: echo build
3087";
3088
3089 fn workflows_dir(name: &str) -> PathBuf {
3090 let root = std::env::temp_dir().join("ci-bill-b4a").join(name);
3091 let _ = fs::remove_dir_all(&root);
3092 let dir = root.join(".github").join("workflows");
3093 fs::create_dir_all(&dir).unwrap();
3094 dir
3095 }
3096
3097 fn write_workflow(dir: &Path, name: &str, text: &str, crlf_file: bool, bom: bool) {
3098 let mut body = if crlf_file {
3099 text.replace('\n', "\r\n")
3100 } else {
3101 text.to_string()
3102 };
3103 if bom {
3104 body.insert(0, '\u{feff}');
3105 }
3106 fs::write(dir.join(name), body).unwrap();
3107 }
3108
3109 fn load_dir(dir: &Path) -> (Vec<Workflow>, Vec<Finding>) {
3110 let workflows = scan_dir(dir).expect("scan workflows");
3111 let found = findings(&workflows).expect("findings");
3112 (workflows, found)
3113 }
3114
3115 fn job_named<'a>(workflows: &'a [Workflow], file: &str, job: &str) -> &'a crate::workflow::Job {
3116 let workflow = workflows
3117 .iter()
3118 .find(|item| item.path.file_name().and_then(|name| name.to_str()) == Some(file))
3119 .unwrap_or_else(|| panic!("missing {file}"));
3120 workflow
3121 .jobs
3122 .iter()
3123 .find(|item| item.name == job)
3124 .unwrap_or_else(|| panic!("missing job {job} in {file}"))
3125 }
3126
3127 fn caller_key(file: &str) -> String {
3128 format!(".github/workflows/{file}")
3129 }
3130
3131 fn attributed<'a>(
3132 found: &'a [Finding],
3133 caller_file: &str,
3134 kind: FindingKind,
3135 ) -> Vec<&'a Finding> {
3136 let key = caller_key(caller_file);
3137 found
3138 .iter()
3139 .filter(|item| item.kind == kind && item.workflow_path.as_deref() == Some(key.as_str()))
3140 .collect()
3141 }
3142
3143 fn usage(path: &str, net: &str, price: &str) -> UsageRow {
3144 UsageRow {
3145 line: 2,
3146 date: "2026-09-01".into(),
3147 product: "actions".into(),
3148 sku: "actions_linux".into(),
3149 quantity: Micros::parse("10").unwrap(),
3150 unit_type: "minutes".into(),
3151 applied_cost_per_quantity: Micros::parse(price).unwrap(),
3152 gross: Micros::parse(net).unwrap(),
3153 discount: Micros::ZERO,
3154 net: Micros::parse(net).unwrap(),
3155 username: "ci".into(),
3156 organization: "example-org".into(),
3157 repository: "example-org/app".into(),
3158 workflow_path: path.into(),
3159 }
3160 }
3161
3162 fn report_for(found: &[Finding], rows: &[UsageRow]) -> (String, serde_json::Value) {
3163 let ranking = rank(rows, None).unwrap();
3164 let text = render(&ranking.workflows, &ranking.bill, Some(found), None);
3165 let json: serde_json::Value = serde_json::from_str(
3166 render_json(
3167 &ranking.workflows,
3168 &ranking.bill,
3169 Some(found),
3170 None,
3171 None,
3172 &[],
3173 )
3174 .trim_end(),
3175 )
3176 .expect("json report");
3177 (text, json)
3178 }
3179
3180 fn section_after<'a>(text: &'a str, label: &str) -> &'a str {
3181 let start = text
3182 .find(label)
3183 .unwrap_or_else(|| panic!("missing {label} in {text}"));
3184 &text[start..]
3185 }
3186
3187 fn assert_no_timeout_on_call(found: &[Finding], workflow_file: &str, job_line: usize) {
3188 assert!(
3189 found.iter().all(|item| {
3190 !(item.kind == FindingKind::MissingTimeout
3191 && item.line == job_line
3192 && item.path.ends_with(workflow_file))
3193 }),
3194 "uses job must not be flagged for the default timeout: {found:?}"
3195 );
3196 }
3197
3198 #[test]
3199 fn local_callee_macos_lint_is_f1_under_caller() {
3200 let callee_runs_on = 5;
3201 let cases = [
3202 ("lint-plain", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\n", false, false, 5),
3203 ("lint-yaml", "caller.yaml", "lint.yaml", "uses: ./.github/workflows/lint.yaml\n", false, false, 5),
3204 ("lint-with", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith:\n extra: \"1\"\n", false, false, 5),
3205 ("lint-secrets-inherit", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nsecrets: inherit\n", false, false, 5),
3206 ("lint-secrets-map", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nsecrets:\n token: value\n", false, false, 5),
3207 ("lint-with-and-secrets", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith:\n extra: \"1\"\nsecrets: inherit\n", false, false, 5),
3208 ("lint-empty-with-secrets", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith: {}\nsecrets: {}\n", false, false, 5),
3209 ("lint-secrets-before-uses", "caller.yml", "lint.yml", "secrets: inherit\nwith:\n extra: \"1\"\nuses: ./.github/workflows/lint.yml\n", false, false, 8),
3210 ("lint-with-before-uses", "caller.yml", "lint.yml", "with:\n extra: \"1\"\nuses: ./.github/workflows/lint.yml\nsecrets:\n token: value\n", false, false, 7),
3211 ("lint-quoted", "caller.yml", "lint.yml", "uses: \"./.github/workflows/lint.yml\"\n", false, false, 5),
3212 ("lint-single-quoted", "caller.yml", "lint.yml", "uses: './.github/workflows/lint.yml'\n", false, false, 5),
3213 ("lint-capitals", "caller.yml", "lint.yml", "uses: ./.GitHub/Workflows/lint.YML\n", false, false, 5),
3214 ("lint-backslash", "caller.yml", "lint.yml", "uses: ./.github\\workflows\\lint.yml\n", false, false, 5),
3215 ("lint-dot-segments", "caller.yml", "lint.yml", "uses: ./.github/workflows/../workflows/./lint.yml\n", false, false, 5),
3216 ("lint-double-slash", "caller.yml", "lint.yml", "uses: ./.github//workflows/lint.yml\n", false, false, 5),
3217 ("lint-crlf", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\nwith:\n extra: \"1\"\nsecrets: inherit\n", true, false, 5),
3218 ("lint-bom", "caller.yml", "lint.yml", "uses: ./.github/workflows/lint.yml\n", false, true, 5),
3219 ("lint-block-scalar", "caller.yml", "lint.yml", "uses: |\n ./.github/workflows/lint.yml\n", false, false, 5),
3220 ];
3221 for (name, caller_file, callee_file, job_body, crlf_file, bom, uses_line) in cases {
3222 let dir = workflows_dir(name);
3223 let caller = format!("name: Caller\non: push\njobs:\n call-lint:\n{job_body}");
3224 let caller = indent_job_body(&caller);
3225 write_workflow(&dir, caller_file, &caller, crlf_file, false);
3226 write_workflow(&dir, callee_file, LINT_CALLEE, crlf_file, bom);
3227 let (workflows, found) = load_dir(&dir);
3228 let call = job_named(&workflows, caller_file, "call-lint");
3229 assert!(call.uses.is_some(), "{name}");
3230 assert_eq!(call.uses.as_ref().unwrap().line, uses_line, "{name}");
3231 assert_no_timeout_on_call(&found, caller_file, call.line);
3232 let f1 = attributed(&found, caller_file, FindingKind::LintOnRunner);
3233 assert_eq!(f1.len(), 1, "{name}: {found:?}");
3234 assert_eq!(f1[0].line, callee_runs_on, "{name}");
3235 assert!(
3236 f1[0].path.ends_with(callee_file),
3237 "{name}: {}",
3238 f1[0].path.display()
3239 );
3240 assert_eq!(f1[0].message, "lint on macOS", "{name}");
3241 let rows = [usage(&caller_key(caller_file), "0.06", "0.006")];
3242 let (text, json) = report_for(&found, &rows);
3243 let label = format!("example-org/app .github/workflows/{caller_file}");
3244 let section = section_after(&text, &label);
3245 let needle = format!(
3246 "F1 {}:{}: lint on macOS",
3247 crate::display::display_path(&f1[0].path),
3248 callee_runs_on
3249 );
3250 assert!(section.contains(&needle), "{name}: {text}");
3251 assert!(!text.contains("Workflows without usage"), "{name}: {text}");
3252 assert_eq!(
3253 json["workflows"][0]["path"],
3254 caller_key(caller_file),
3255 "{name}"
3256 );
3257 assert_eq!(json["workflows"][0]["net"], "0.060000", "{name}");
3258 assert_eq!(json["workflows"].as_array().unwrap().len(), 1, "{name}");
3259 assert!(
3260 json["unattached_findings"].as_array().unwrap().is_empty(),
3261 "{name}"
3262 );
3263 let listed = json["workflows"][0]["findings"].as_array().unwrap();
3264 assert!(
3265 listed.iter().any(|item| {
3266 item["id"] == "F1"
3267 && item["line"] == callee_runs_on
3268 && item["message"] == "lint on macOS"
3269 && item["file"].as_str().unwrap().ends_with(callee_file)
3270 }),
3271 "{name}: {listed:?}"
3272 );
3273 }
3274
3275 let flow_dir = workflows_dir("lint-flow");
3276 write_workflow(
3277 &flow_dir,
3278 "caller.yml",
3279 "on: push\njobs: {call-lint: {uses: ./.github/workflows/lint.yml, with: {extra: \"1\"}, secrets: inherit}}\n",
3280 false,
3281 false,
3282 );
3283 write_workflow(
3284 &flow_dir,
3285 "lint.yml",
3286 "on: workflow_call\njobs: {lint: {runs-on: macos-latest, timeout-minutes: 10, steps: [{run: cargo fmt --check}]}}\n",
3287 false,
3288 false,
3289 );
3290 let (workflows, found) = load_dir(&flow_dir);
3291 let call = job_named(&workflows, "caller.yml", "call-lint");
3292 let callee = job_named(&workflows, "lint.yml", "lint");
3293 let runs_on = callee.runs_on.as_ref().unwrap().line;
3294 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3295 assert_eq!(f1.len(), 1, "{found:?}");
3296 assert_eq!(f1[0].line, runs_on);
3297 assert_eq!(
3298 call.uses.as_ref().unwrap().spec,
3299 "./.github/workflows/lint.yml"
3300 );
3301 let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
3302 assert!(text.contains("lint on macOS"), "{text}");
3303 assert!(text.contains(&format!("lint.yml:{runs_on}:")), "{text}");
3304 assert!(!text.contains("Workflows without usage"), "{text}");
3305
3306 let nested = workflows_dir("lint-nested");
3307 write_workflow(
3308 &nested,
3309 "caller.yml",
3310 "name: Caller\non: push\njobs:\n call-mid:\n uses: ./.github/workflows/mid.yml\n secrets: inherit\n",
3311 false,
3312 false,
3313 );
3314 write_workflow(
3315 &nested,
3316 "mid.yml",
3317 "name: Mid\non: workflow_call\njobs:\n forward:\n with:\n extra: \"1\"\n secrets:\n token: value\n uses: ./.github/workflows/lint.yml\n",
3318 false,
3319 false,
3320 );
3321 write_workflow(&nested, "lint.yml", LINT_CALLEE, false, false);
3322 let (workflows, found) = load_dir(&nested);
3323 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3324 assert_eq!(f1.len(), 1, "{found:?}");
3325 assert_eq!(f1[0].line, callee_runs_on);
3326 assert!(f1[0].path.ends_with("lint.yml"), "{}", f1[0].path.display());
3327 assert_no_timeout_on_call(
3328 &found,
3329 "caller.yml",
3330 job_named(&workflows, "caller.yml", "call-mid").line,
3331 );
3332 assert_no_timeout_on_call(
3333 &found,
3334 "mid.yml",
3335 job_named(&workflows, "mid.yml", "forward").line,
3336 );
3337 let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
3338 assert!(
3339 section_after(&text, "example-org/app .github/workflows/caller.yml")
3340 .contains("lint.yml:5: lint on macOS"),
3341 "{text}"
3342 );
3343 assert!(!text.contains("Workflows without usage"), "{text}");
3344
3345 let anchor = workflows_dir("lint-anchor");
3346 write_workflow(
3347 &anchor,
3348 "caller.yml",
3349 "call: &call\n uses: ./.github/workflows/missing.yml\njobs:\n call-lint:\n <<: *call\n uses: ./.github/workflows/lint.yml\n secrets: inherit\n",
3350 false,
3351 false,
3352 );
3353 write_workflow(&anchor, "lint.yml", LINT_CALLEE, false, false);
3354 let (workflows, found) = load_dir(&anchor);
3355 let call = job_named(&workflows, "caller.yml", "call-lint");
3356 assert_eq!(
3357 call.uses.as_ref().unwrap().spec,
3358 "./.github/workflows/lint.yml"
3359 );
3360 assert!(
3361 attributed(&found, "caller.yml", FindingKind::CalleeNotFound).is_empty(),
3362 "{found:?}"
3363 );
3364 assert!(
3365 attributed(&found, "caller.yml", FindingKind::DuplicateKey).is_empty(),
3366 "{found:?}"
3367 );
3368 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3369 assert_eq!(f1.len(), 1, "{found:?}");
3370 assert_eq!(f1[0].message, "lint on macOS");
3371
3372 let windows = workflows_dir("lint-windows");
3373 write_workflow(
3374 &windows,
3375 "caller.yml",
3376 "name: Caller\non: push\njobs:\n call-lint:\n uses: ./.github/workflows/lint.yml\n",
3377 false,
3378 false,
3379 );
3380 write_workflow(
3381 &windows,
3382 "lint.yml",
3383 "name: Lint\non: workflow_call\njobs:\n fmt:\n runs-on: windows-latest\n timeout-minutes: 10\n steps:\n - run: cargo fmt --check\n",
3384 false,
3385 false,
3386 );
3387 let (_, found) = load_dir(&windows);
3388 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
3389 assert_eq!(f1.len(), 1, "{found:?}");
3390 assert_eq!(f1[0].line, 5);
3391 assert_eq!(f1[0].message, "lint on Windows");
3392
3393 let hosted = workflows_dir("lint-self-hosted");
3394 write_workflow(
3395 &hosted,
3396 "caller.yml",
3397 "name: Caller\non: push\njobs:\n call-lint:\n uses: ./.github/workflows/lint.yml\n",
3398 false,
3399 false,
3400 );
3401 write_workflow(
3402 &hosted,
3403 "lint.yml",
3404 "name: Lint\non: workflow_call\njobs:\n lint:\n runs-on: [self-hosted, macos]\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n",
3405 false,
3406 false,
3407 );
3408 let (_, found) = load_dir(&hosted);
3409 assert!(
3410 attributed(&found, "caller.yml", FindingKind::LintOnRunner).is_empty(),
3411 "{found:?}"
3412 );
3413 assert!(
3414 attributed(&found, "caller.yml", FindingKind::CalleeNotFound).is_empty(),
3415 "{found:?}"
3416 );
3417
3418 let step = workflows_dir("lint-step-uses");
3419 write_workflow(
3420 &step,
3421 "caller.yml",
3422 "name: Caller\non: push\njobs:\n lint:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n steps:\n - uses: ./.github/workflows/lint.yml\n",
3423 false,
3424 false,
3425 );
3426 write_workflow(&step, "lint.yml", LINT_CALLEE, false, false);
3427 let (workflows, found) = load_dir(&step);
3428 assert!(job_named(&workflows, "caller.yml", "lint").uses.is_none());
3429 assert!(
3430 attributed(&found, "caller.yml", FindingKind::LintOnRunner).is_empty(),
3431 "{found:?}"
3432 );
3433 assert!(
3434 found
3435 .iter()
3436 .all(|item| item.kind != FindingKind::CalleeNotFound),
3437 "{found:?}"
3438 );
3439
3440 let rows = vec![
3441 usage(".github/workflows/caller.yml", "0.06", "0.006"),
3442 usage(".github/workflows/lint.yml", "1.00", "0.100"),
3443 ];
3444 let dir = workflows_dir("lint-both-rows");
3445 write_workflow(
3446 &dir,
3447 "caller.yml",
3448 "name: Caller\non: push\njobs:\n call-lint:\n uses: ./.github/workflows/lint.yml\n with:\n extra: \"1\"\n secrets: inherit\n",
3449 false,
3450 false,
3451 );
3452 write_workflow(&dir, "lint.yml", LINT_CALLEE, false, false);
3453 let (_, found) = load_dir(&dir);
3454 let ranking = rank(&rows, None).unwrap();
3455 assert_eq!(ranking.workflows.len(), 2);
3456 let caller = ranking
3457 .workflows
3458 .iter()
3459 .find(|item| item.workflow_path == ".github/workflows/caller.yml")
3460 .unwrap();
3461 let lint = ranking
3462 .workflows
3463 .iter()
3464 .find(|item| item.workflow_path == ".github/workflows/lint.yml")
3465 .unwrap();
3466 assert_eq!(caller.net, Micros::parse("0.06").unwrap());
3467 assert_eq!(lint.net, Micros::parse("1.00").unwrap());
3468 let (text, json) = report_for(&found, &rows);
3469 let caller_at = text
3470 .find("example-org/app .github/workflows/caller.yml")
3471 .unwrap();
3472 let lint_at = text
3473 .find("example-org/app .github/workflows/lint.yml")
3474 .unwrap();
3475 assert!(lint_at < caller_at, "{text}");
3476 let lint_section = &text[lint_at..caller_at];
3477 let caller_section = &text[caller_at..];
3478 assert!(lint_section.contains("1.00"), "{lint_section}");
3479 assert!(caller_section.contains("0.06"), "{caller_section}");
3480 assert!(
3481 caller_section.contains("lint.yml:5: lint on macOS"),
3482 "{text}"
3483 );
3484 assert!(lint_section.contains("lint.yml:5: lint on macOS"), "{text}");
3485 assert!(!caller_section.contains("1.00"), "{caller_section}");
3486 assert_eq!(json["workflows"].as_array().unwrap().len(), 2);
3487 let caller_json = json["workflows"]
3488 .as_array()
3489 .unwrap()
3490 .iter()
3491 .find(|item| item["path"] == ".github/workflows/caller.yml")
3492 .unwrap();
3493 assert_eq!(caller_json["net"], "0.060000");
3494 assert!(caller_json["findings"]
3495 .as_array()
3496 .unwrap()
3497 .iter()
3498 .any(|item| item["id"] == "F1" && item["line"] == 5));
3499 }
3500
3501 fn indent_job_body(text: &str) -> String {
3506 let mut out = String::new();
3507 let mut job_started = false;
3508 for line in text.lines() {
3509 if !job_started {
3510 out.push_str(line);
3511 out.push('\n');
3512 if line.starts_with(" ")
3513 && !line.starts_with(" ")
3514 && line.trim_end().ends_with(':')
3515 {
3516 job_started = true;
3517 }
3518 continue;
3519 }
3520 if line.is_empty() {
3521 out.push('\n');
3522 continue;
3523 }
3524 out.push_str(" ");
3525 out.push_str(line);
3526 out.push('\n');
3527 }
3528 out
3529 }
3530
3531 #[test]
3532 fn missing_local_callee_is_visible_at_the_uses_line() {
3533 let cases = [
3534 (
3535 "miss-yml",
3536 "caller.yml",
3537 "uses: ./.github/workflows/missing.yml\n",
3538 false,
3539 5,
3540 "call-lint",
3541 "./.github/workflows/missing.yml",
3542 ),
3543 (
3544 "miss-yaml",
3545 "caller.yaml",
3546 "uses: ./.github/workflows/missing.yaml\n",
3547 false,
3548 5,
3549 "call-lint",
3550 "./.github/workflows/missing.yaml",
3551 ),
3552 (
3553 "miss-with",
3554 "caller.yml",
3555 "uses: ./.github/workflows/missing.yml\nwith:\n extra: \"1\"\n",
3556 false,
3557 5,
3558 "call-lint",
3559 "./.github/workflows/missing.yml",
3560 ),
3561 (
3562 "miss-secrets",
3563 "caller.yml",
3564 "secrets: inherit\nuses: ./.github/workflows/missing.yml\n",
3565 false,
3566 6,
3567 "call-lint",
3568 "./.github/workflows/missing.yml",
3569 ),
3570 (
3571 "miss-both",
3572 "caller.yml",
3573 "with: {}\nsecrets: {}\nuses: ./.github/workflows/gone.yaml\n",
3574 false,
3575 7,
3576 "call-lint",
3577 "./.github/workflows/gone.yaml",
3578 ),
3579 (
3580 "miss-crlf",
3581 "caller.yml",
3582 "uses: ./.github/workflows/missing.yml\nsecrets:\n token: value\n",
3583 true,
3584 5,
3585 "call-lint",
3586 "./.github/workflows/missing.yml",
3587 ),
3588 (
3589 "miss-capitals",
3590 "caller.yml",
3591 "uses: ./.GitHub/Workflows/Missing.YML\n",
3592 false,
3593 5,
3594 "call-lint",
3595 "./.GitHub/Workflows/Missing.YML",
3596 ),
3597 (
3598 "miss-quoted",
3599 "caller.yml",
3600 "uses: \"./.github/workflows/missing.yml\"\n",
3601 false,
3602 5,
3603 "call-lint",
3604 "./.github/workflows/missing.yml",
3605 ),
3606 (
3607 "miss-empty-file-name-not",
3608 "caller.yml",
3609 "uses: ./.github/workflows/no-such.yml\nwith:\nsecrets: inherit\n",
3610 false,
3611 5,
3612 "call-lint",
3613 "./.github/workflows/no-such.yml",
3614 ),
3615 ];
3616 for (name, caller_file, job_body, crlf_file, uses_line, job, spec) in cases {
3617 let dir = workflows_dir(name);
3618 let caller = format!("name: Caller\non: push\njobs:\n {job}:\n{job_body}");
3619 let caller = indent_job_body(&caller);
3620 write_workflow(&dir, caller_file, &caller, crlf_file, false);
3621 let (workflows, found) = load_dir(&dir);
3622 let call = job_named(&workflows, caller_file, job);
3623 assert_eq!(
3624 call.uses.as_ref().unwrap().line,
3625 uses_line,
3626 "{name}\n{caller}"
3627 );
3628 assert_eq!(call.uses.as_ref().unwrap().spec, spec, "{name}");
3629 let missing = attributed(&found, caller_file, FindingKind::CalleeNotFound);
3630 assert_eq!(missing.len(), 1, "{name}: {found:?}");
3631 assert_eq!(missing[0].line, uses_line, "{name}");
3632 assert!(missing[0].path.ends_with(caller_file), "{name}");
3633 assert!(
3634 missing[0].message.starts_with("callee not found"),
3635 "{name}: {}",
3636 missing[0].message
3637 );
3638 assert!(
3639 missing[0].message.contains(job),
3640 "{name}: {}",
3641 missing[0].message
3642 );
3643 assert!(
3644 missing[0].message.contains(spec),
3645 "{name}: {}",
3646 missing[0].message
3647 );
3648 assert_eq!(missing[0].kind.code(), "F6");
3649 let (text, json) =
3650 report_for(&found, &[usage(&caller_key(caller_file), "0.06", "0.006")]);
3651 let section = section_after(
3652 &text,
3653 &format!("example-org/app .github/workflows/{caller_file}"),
3654 );
3655 let needle = format!(
3656 "F6 {}:{}: {}",
3657 crate::display::display_path(&missing[0].path),
3658 uses_line,
3659 missing[0].message
3660 );
3661 assert!(section.contains(&needle), "{name}: {text}");
3662 assert!(section.contains(job), "{name}: {text}");
3663 assert!(!text.contains("Workflows without usage"), "{name}: {text}");
3664 let listed = json["workflows"][0]["findings"].as_array().unwrap();
3665 assert_eq!(listed[0]["id"], "F6", "{name}");
3666 assert_eq!(listed[0]["line"], uses_line, "{name}");
3667 assert!(
3668 listed[0]["message"]
3669 .as_str()
3670 .unwrap()
3671 .contains("callee not found"),
3672 "{name}"
3673 );
3674 assert!(
3675 json["unattached_findings"].as_array().unwrap().is_empty(),
3676 "{name}"
3677 );
3678 }
3679
3680 let nested = workflows_dir("miss-nested");
3681 write_workflow(
3682 &nested,
3683 "caller.yml",
3684 "name: Caller\non: push\njobs:\n call-mid:\n uses: ./.github/workflows/mid.yml\n with:\n extra: \"1\"\n",
3685 false,
3686 false,
3687 );
3688 write_workflow(
3689 &nested,
3690 "mid.yml",
3691 "name: Mid\non: workflow_call\njobs:\n forward:\n secrets: inherit\n uses: ./.github/workflows/missing.yml\n",
3692 true,
3693 false,
3694 );
3695 let (workflows, found) = load_dir(&nested);
3696 let forward = job_named(&workflows, "mid.yml", "forward");
3697 let missing = attributed(&found, "caller.yml", FindingKind::CalleeNotFound);
3698 assert_eq!(missing.len(), 1, "{found:?}");
3699 assert_eq!(missing[0].line, forward.uses.as_ref().unwrap().line);
3700 assert!(
3701 missing[0].path.ends_with("mid.yml"),
3702 "{}",
3703 missing[0].path.display()
3704 );
3705 assert!(
3706 missing[0].message.contains("callee not found"),
3707 "{}",
3708 missing[0].message
3709 );
3710 assert!(
3711 missing[0].message.contains("forward"),
3712 "{}",
3713 missing[0].message
3714 );
3715 let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
3716 assert!(
3717 section_after(&text, "example-org/app .github/workflows/caller.yml")
3718 .contains("callee not found"),
3719 "{text}"
3720 );
3721 assert!(
3722 text.contains(&format!("mid.yml:{}:", forward.uses.as_ref().unwrap().line)),
3723 "{text}"
3724 );
3725 assert!(!text.contains("Workflows without usage"), "{text}");
3726
3727 let empty = workflows_dir("miss-empty-callee");
3728 write_workflow(
3729 &empty,
3730 "caller.yml",
3731 "name: Caller\non: push\njobs:\n call-lint:\n uses: ./.github/workflows/empty.yml\n secrets: inherit\n",
3732 false,
3733 false,
3734 );
3735 write_workflow(
3736 &empty,
3737 "empty.yml",
3738 "name: Empty\non: workflow_call\n",
3739 false,
3740 false,
3741 );
3742 let (workflows, found) = load_dir(&empty);
3743 assert!(job_named(&workflows, "caller.yml", "call-lint")
3744 .uses
3745 .is_some());
3746 assert!(
3747 found
3748 .iter()
3749 .all(|item| item.kind != FindingKind::CalleeNotFound),
3750 "{found:?}"
3751 );
3752
3753 let ignored = workflows_dir("miss-not-local");
3754 write_workflow(
3755 &ignored,
3756 "caller.yml",
3757 "name: Caller\non: push\njobs:\n blank:\n uses: \"\"\n timeout-minutes: 1\n remote:\n uses: owner/repo/.github/workflows/x.yml@main\n timeout-minutes: 1\n action:\n uses: actions/checkout@v4\n timeout-minutes: 1\n other:\n uses: ./foo.yml\n timeout-minutes: 1\n list:\n uses:\n - ./.github/workflows/lint.yml\n timeout-minutes: 1\n",
3758 false,
3759 false,
3760 );
3761 let (_, found) = load_dir(&ignored);
3762 assert!(
3763 found
3764 .iter()
3765 .all(|item| item.kind != FindingKind::CalleeNotFound),
3766 "{found:?}"
3767 );
3768 assert!(
3769 found
3770 .iter()
3771 .all(|item| item.kind != FindingKind::ReusableCycle),
3772 "{found:?}"
3773 );
3774 }
3775
3776 #[test]
3777 fn reusable_cycle_stops_with_a_finding() {
3778 let pairs = [
3779 ("cycle-yml", "a.yml", "b.yml", false),
3780 ("cycle-yaml", "a.yaml", "b.yaml", false),
3781 ("cycle-crlf", "a.yml", "b.yml", true),
3782 ];
3783 for (name, a_file, b_file, crlf_file) in pairs {
3784 let dir = workflows_dir(name);
3785 let a = format!(
3786 "name: A\non: push\njobs:\n call-b:\n uses: ./.github/workflows/{b_file}\n with:\n extra: \"1\"\n secrets: inherit\n"
3787 );
3788 let b = format!(
3789 "name: B\non: workflow_call\njobs:\n lint:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - run: cargo fmt --check\n call-a:\n secrets:\n token: value\n uses: ./.github/workflows/{a_file}\n"
3790 );
3791 write_workflow(&dir, a_file, &a, crlf_file, false);
3792 write_workflow(&dir, b_file, &b, crlf_file, false);
3793 let (workflows, found) = load_dir(&dir);
3794 assert!(found.len() < 12, "{name}: {found:?}");
3795 let call_b = job_named(&workflows, a_file, "call-b");
3796 let call_a = job_named(&workflows, b_file, "call-a");
3797 let cycles = attributed(&found, a_file, FindingKind::ReusableCycle);
3798 assert_eq!(cycles.len(), 1, "{name}: {found:?}");
3799 assert_eq!(cycles[0].line, call_a.uses.as_ref().unwrap().line, "{name}");
3800 assert!(cycles[0].path.ends_with(b_file), "{name}");
3801 assert_eq!(cycles[0].kind.code(), "F7");
3802 assert!(
3803 cycles[0].message.contains("reusable workflow cycle"),
3804 "{name}"
3805 );
3806 assert!(
3807 cycles[0].message.contains(a_file),
3808 "{name}: {}",
3809 cycles[0].message
3810 );
3811 assert!(
3812 cycles[0].message.contains(b_file),
3813 "{name}: {}",
3814 cycles[0].message
3815 );
3816 assert!(cycles[0].message.contains(" -> "), "{name}");
3817 let f1 = attributed(&found, a_file, FindingKind::LintOnRunner);
3818 assert_eq!(f1.len(), 1, "{name}: {found:?}");
3819 assert!(f1[0].path.ends_with(b_file), "{name}");
3820 assert_eq!(f1[0].message, "lint on macOS");
3821 assert_no_timeout_on_call(&found, a_file, call_b.line);
3822 assert_no_timeout_on_call(&found, b_file, call_a.line);
3823 let (text, json) = report_for(&found, &[usage(&caller_key(a_file), "0.06", "0.006")]);
3824 let section = section_after(
3825 &text,
3826 &format!("example-org/app .github/workflows/{a_file}"),
3827 );
3828 assert!(
3829 section.contains("reusable workflow cycle"),
3830 "{name}: {text}"
3831 );
3832 assert!(section.contains("lint on macOS"), "{name}: {text}");
3833 assert!(section.contains(&format!("{b_file}:")), "{name}: {text}");
3834 let listed = json["workflows"][0]["findings"].as_array().unwrap();
3835 assert!(
3836 listed.iter().any(|item| item["id"] == "F7"),
3837 "{name}: {listed:?}"
3838 );
3839 assert!(
3840 listed
3841 .iter()
3842 .any(|item| item["id"] == "F1"
3843 && item["file"].as_str().unwrap().ends_with(b_file)),
3844 "{name}"
3845 );
3846 }
3847
3848 let self_dir = workflows_dir("cycle-self");
3849 write_workflow(
3850 &self_dir,
3851 "loop.yml",
3852 "name: Loop\non: push\njobs:\n again:\n uses: ./.github/workflows/loop.yml\n secrets: inherit\n",
3853 false,
3854 false,
3855 );
3856 let (workflows, found) = load_dir(&self_dir);
3857 assert!(found.len() < 6, "{found:?}");
3858 let again = job_named(&workflows, "loop.yml", "again");
3859 let cycles = attributed(&found, "loop.yml", FindingKind::ReusableCycle);
3860 assert_eq!(cycles.len(), 1, "{found:?}");
3861 assert_eq!(cycles[0].line, again.uses.as_ref().unwrap().line);
3862 assert!(
3863 cycles[0].message.contains("loop.yml -> loop.yml"),
3864 "{}",
3865 cycles[0].message
3866 );
3867 let (text, _) = report_for(&found, &[usage(&caller_key("loop.yml"), "0.06", "0.006")]);
3868 assert!(
3869 section_after(&text, "example-org/app .github/workflows/loop.yml")
3870 .contains("reusable workflow cycle"),
3871 "{text}"
3872 );
3873
3874 let three = workflows_dir("cycle-three");
3875 write_workflow(
3876 &three,
3877 "a.yml",
3878 "name: A\non: push\njobs:\n call-b:\n uses: ./.github/workflows/b.yml\n",
3879 false,
3880 false,
3881 );
3882 write_workflow(
3883 &three,
3884 "b.yml",
3885 "name: B\non: workflow_call\njobs:\n call-c:\n uses: ./.github/workflows/c.yml\n with:\n extra: \"1\"\n",
3886 false,
3887 false,
3888 );
3889 write_workflow(
3890 &three,
3891 "c.yml",
3892 "name: C\non: workflow_call\njobs:\n lint:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - run: cargo fmt --check\n call-b:\n uses: ./.github/workflows/b.yml\n secrets: inherit\n",
3893 false,
3894 false,
3895 );
3896 let (_, found) = load_dir(&three);
3897 assert!(found.len() < 16, "{found:?}");
3898 let cycles = attributed(&found, "a.yml", FindingKind::ReusableCycle);
3899 assert_eq!(cycles.len(), 1, "{found:?}");
3900 assert!(
3901 cycles[0].path.ends_with("c.yml"),
3902 "{}",
3903 cycles[0].path.display()
3904 );
3905 assert!(cycles[0].message.contains("b.yml"), "{}", cycles[0].message);
3906 assert!(cycles[0].message.contains("c.yml"), "{}", cycles[0].message);
3907 let f1 = attributed(&found, "a.yml", FindingKind::LintOnRunner);
3908 assert_eq!(f1.len(), 1, "{found:?}");
3909 assert!(f1[0].path.ends_with("c.yml"));
3910 let (text, _) = report_for(&found, &[usage(&caller_key("a.yml"), "0.06", "0.006")]);
3911 assert!(
3912 section_after(&text, "example-org/app .github/workflows/a.yml")
3913 .contains("reusable workflow cycle"),
3914 "{text}"
3915 );
3916 assert!(text.contains("lint on macOS"), "{text}");
3917
3918 let flow = workflows_dir("cycle-flow");
3919 write_workflow(
3920 &flow,
3921 "a.yml",
3922 "on: push\njobs: {call-b: {uses: ./.github/workflows/b.yml, secrets: inherit}}\n",
3923 false,
3924 false,
3925 );
3926 write_workflow(
3927 &flow,
3928 "b.yml",
3929 "on: workflow_call\njobs: {call-a: {with: {extra: \"1\"}, uses: ./.github/workflows/a.yml}}\n",
3930 false,
3931 false,
3932 );
3933 let (_, found) = load_dir(&flow);
3934 assert!(found.len() < 8, "{found:?}");
3935 let cycles = attributed(&found, "a.yml", FindingKind::ReusableCycle);
3936 assert_eq!(cycles.len(), 1, "{found:?}");
3937 assert!(cycles[0].message.contains("a.yml"), "{}", cycles[0].message);
3938 assert!(cycles[0].message.contains("b.yml"), "{}", cycles[0].message);
3939 }
3940
3941 #[test]
3942 fn callee_matrix_is_reported_under_caller() {
3943 let canonical_matrix_line = 8;
3944 let cases = [
3945 (
3946 "matrix-plain",
3947 "caller.yml",
3948 "wide.yml",
3949 "uses: ./.github/workflows/wide.yml\n",
3950 false,
3951 ),
3952 (
3953 "matrix-yaml",
3954 "caller.yaml",
3955 "wide.yaml",
3956 "uses: ./.github/workflows/wide.yaml\n",
3957 false,
3958 ),
3959 (
3960 "matrix-with-secrets",
3961 "caller.yml",
3962 "wide.yml",
3963 "secrets: inherit\nwith:\n extra: \"1\"\nuses: ./.github/workflows/wide.yml\n",
3964 false,
3965 ),
3966 (
3967 "matrix-crlf",
3968 "caller.yml",
3969 "wide.yml",
3970 "uses: ./.github/workflows/wide.yml\nwith: {}\nsecrets: {}\n",
3971 true,
3972 ),
3973 (
3974 "matrix-capitals",
3975 "caller.yml",
3976 "wide.yml",
3977 "uses: ./.GitHub/Workflows/wide.YML\n",
3978 false,
3979 ),
3980 (
3981 "matrix-quoted",
3982 "caller.yml",
3983 "wide.yml",
3984 "uses: \"./.github/workflows/wide.yml\"\n",
3985 false,
3986 ),
3987 ];
3988 for (name, caller_file, callee_file, job_body, crlf_file) in cases {
3989 let dir = workflows_dir(name);
3990 let caller = format!("name: Caller\non: push\njobs:\n call-wide:\n{job_body}");
3991 write_workflow(
3992 &dir,
3993 caller_file,
3994 &indent_job_body(&caller),
3995 crlf_file,
3996 false,
3997 );
3998 write_workflow(&dir, callee_file, MATRIX_CALLEE, crlf_file, false);
3999 let (workflows, found) = load_dir(&dir);
4000 let matrix_line = job_named(&workflows, callee_file, "build")
4001 .matrix
4002 .as_ref()
4003 .unwrap()
4004 .line;
4005 if !crlf_file && job_body.starts_with("uses:") {
4006 assert_eq!(matrix_line, canonical_matrix_line, "{name}");
4007 }
4008 let f4 = attributed(&found, caller_file, FindingKind::MatrixJobs);
4009 assert_eq!(f4.len(), 1, "{name}: {found:?}");
4010 assert_eq!(f4[0].line, matrix_line, "{name}");
4011 assert!(f4[0].path.ends_with(callee_file), "{name}");
4012 assert_eq!(f4[0].message, f4_message(6), "{name}");
4013 assert_eq!(f4[0].kind.code(), "F4");
4014 let (text, json) =
4015 report_for(&found, &[usage(&caller_key(caller_file), "0.06", "0.006")]);
4016 let section = section_after(
4017 &text,
4018 &format!("example-org/app .github/workflows/{caller_file}"),
4019 );
4020 let needle = format!(
4021 "F4 {}:{}: {}",
4022 crate::display::display_path(&f4[0].path),
4023 matrix_line,
4024 f4_message(6)
4025 );
4026 assert!(section.contains(&needle), "{name}: {text}");
4027 assert!(!text.contains("Workflows without usage"), "{name}: {text}");
4028 assert_eq!(json["workflows"][0]["net"], "0.060000", "{name}");
4029 let listed = json["workflows"][0]["findings"].as_array().unwrap();
4030 assert!(
4031 listed.iter().any(|item| {
4032 item["id"] == "F4"
4033 && item["line"] == matrix_line
4034 && item["message"] == f4_message(6)
4035 && item["file"].as_str().unwrap().ends_with(callee_file)
4036 }),
4037 "{name}: {listed:?}"
4038 );
4039 }
4040
4041 let narrow = workflows_dir("matrix-narrow");
4042 write_workflow(
4043 &narrow,
4044 "caller.yml",
4045 "name: Caller\non: push\njobs:\n call-wide:\n uses: ./.github/workflows/wide.yml\n secrets: inherit\n",
4046 false,
4047 false,
4048 );
4049 write_workflow(
4050 &narrow,
4051 "wide.yml",
4052 "name: Wide\non: workflow_call\njobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n",
4053 false,
4054 false,
4055 );
4056 let (workflows, found) = load_dir(&narrow);
4057 assert_eq!(
4058 job_named(&workflows, "wide.yml", "build")
4059 .matrix
4060 .as_ref()
4061 .unwrap()
4062 .jobs,
4063 4
4064 );
4065 assert!(
4066 attributed(&found, "caller.yml", FindingKind::MatrixJobs).is_empty(),
4067 "{found:?}"
4068 );
4069 assert!(
4070 attributed(&found, "caller.yml", FindingKind::CalleeNotFound).is_empty(),
4071 "{found:?}"
4072 );
4073
4074 let flow = workflows_dir("matrix-flow");
4075 write_workflow(
4076 &flow,
4077 "caller.yml",
4078 "on: push\njobs: {call-wide: {uses: ./.github/workflows/wide.yml, with: {extra: \"1\"}, secrets: inherit}}\n",
4079 false,
4080 false,
4081 );
4082 write_workflow(
4083 &flow,
4084 "wide.yml",
4085 "on: workflow_call\njobs: {build: {runs-on: ubuntu-latest, timeout-minutes: 5, strategy: {matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}}}}\n",
4086 false,
4087 false,
4088 );
4089 let (workflows, found) = load_dir(&flow);
4090 let matrix_line = job_named(&workflows, "wide.yml", "build")
4091 .matrix
4092 .as_ref()
4093 .unwrap()
4094 .line;
4095 let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4096 assert_eq!(f4.len(), 1, "{found:?}");
4097 assert_eq!(f4[0].line, matrix_line);
4098 assert_eq!(f4[0].message, f4_message(6));
4099 let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4100 assert!(text.contains(&format!("wide.yml:{matrix_line}:")), "{text}");
4101 assert!(text.contains(&f4_message(6)), "{text}");
4102 assert!(!text.contains("Workflows without usage"), "{text}");
4103
4104 let at_least = workflows_dir("matrix-at-least");
4105 write_workflow(
4106 &at_least,
4107 "caller.yml",
4108 "name: Caller\non: push\njobs:\n call-wide:\n uses: ./.github/workflows/wide.yml\n",
4109 true,
4110 false,
4111 );
4112 write_workflow(
4113 &at_least,
4114 "wide.yml",
4115 "name: Wide\non: workflow_call\njobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: ${{ fromJSON(needs.setup.outputs.node) }}\n",
4116 true,
4117 false,
4118 );
4119 let (workflows, found) = load_dir(&at_least);
4120 let matrix_line = job_named(&workflows, "wide.yml", "build")
4121 .matrix
4122 .as_ref()
4123 .unwrap()
4124 .line;
4125 let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4126 assert_eq!(f4.len(), 1, "{found:?}");
4127 assert_eq!(f4[0].line, matrix_line);
4128 assert_eq!(f4[0].message, f4_at_least(2));
4129 let (text, json) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4130 assert!(
4131 section_after(&text, "example-org/app .github/workflows/caller.yml")
4132 .contains(&f4_at_least(2)),
4133 "{text}"
4134 );
4135 assert_eq!(
4136 json["workflows"][0]["findings"][0]["message"],
4137 f4_at_least(2)
4138 );
4139
4140 let between = workflows_dir("matrix-between");
4141 write_workflow(
4142 &between,
4143 "caller.yml",
4144 "name: Caller\non: push\njobs:\n call-wide:\n uses: ./.github/workflows/wide.yml\n with:\n extra: \"1\"\n secrets: inherit\n",
4145 false,
4146 false,
4147 );
4148 write_workflow(
4149 &between,
4150 "wide.yml",
4151 "name: Wide\non: workflow_call\njobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n",
4152 false,
4153 false,
4154 );
4155 let (workflows, found) = load_dir(&between);
4156 let matrix_line = job_named(&workflows, "wide.yml", "build")
4157 .matrix
4158 .as_ref()
4159 .unwrap()
4160 .line;
4161 let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4162 assert_eq!(f4.len(), 1, "{found:?}");
4163 assert_eq!(f4[0].line, matrix_line);
4164 assert_eq!(f4[0].message, f4_between(0, 6));
4165 let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4166 assert!(text.contains(&f4_between(0, 6)), "{text}");
4167 assert!(!text.contains("Workflows without usage"), "{text}");
4168
4169 let nested = workflows_dir("matrix-nested");
4170 write_workflow(
4171 &nested,
4172 "caller.yml",
4173 "name: Caller\non: push\njobs:\n call-mid:\n uses: ./.github/workflows/mid.yml\n",
4174 false,
4175 false,
4176 );
4177 write_workflow(
4178 &nested,
4179 "mid.yml",
4180 "name: Mid\non: workflow_call\njobs:\n forward:\n uses: ./.github/workflows/wide.yml\n secrets: inherit\n",
4181 false,
4182 false,
4183 );
4184 write_workflow(&nested, "wide.yml", MATRIX_CALLEE, false, false);
4185 let (workflows, found) = load_dir(&nested);
4186 let matrix_line = job_named(&workflows, "wide.yml", "build")
4187 .matrix
4188 .as_ref()
4189 .unwrap()
4190 .line;
4191 let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4192 assert_eq!(f4.len(), 1, "{found:?}");
4193 assert_eq!(f4[0].line, matrix_line);
4194 assert!(f4[0].path.ends_with("wide.yml"));
4195 assert_eq!(f4[0].message, f4_message(6));
4196 let (text, _) = report_for(&found, &[usage(&caller_key("caller.yml"), "0.06", "0.006")]);
4197 assert!(
4198 section_after(&text, "example-org/app .github/workflows/caller.yml")
4199 .contains("wide.yml:8:"),
4200 "{text}"
4201 );
4202 assert!(!text.contains("Workflows without usage"), "{text}");
4203
4204 let caller_matrix = workflows_dir("matrix-on-caller");
4205 write_workflow(
4206 &caller_matrix,
4207 "caller.yml",
4208 "name: Caller\non: push\njobs:\n call-wide:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n uses: ./.github/workflows/wide.yml\n secrets: inherit\n",
4209 false,
4210 false,
4211 );
4212 write_workflow(
4213 &caller_matrix,
4214 "wide.yml",
4215 "name: Wide\non: workflow_call\njobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n steps:\n - run: echo build\n",
4216 false,
4217 false,
4218 );
4219 let (workflows, found) = load_dir(&caller_matrix);
4220 let call = job_named(&workflows, "caller.yml", "call-wide");
4221 let f4 = attributed(&found, "caller.yml", FindingKind::MatrixJobs);
4222 assert_eq!(f4.len(), 1, "{found:?}");
4223 assert_eq!(f4[0].line, call.matrix.as_ref().unwrap().line);
4224 assert!(f4[0].path.ends_with("caller.yml"));
4225 assert_eq!(f4[0].message, f4_message(6));
4226 }
4227
4228 #[test]
4229 fn caller_costs_stay_on_the_caller_csv_path() {
4230 let dir = workflows_dir("costs-stay");
4231 write_workflow(
4232 &dir,
4233 "caller.yml",
4234 "name: Caller\non: [push, pull_request]\njobs:\n call-lint:\n uses: ./.github/workflows/lint.yml\n with:\n extra: \"1\"\n secrets: inherit\n",
4235 true,
4236 false,
4237 );
4238 write_workflow(&dir, "lint.yml", LINT_CALLEE, true, false);
4239 let csv = dir.join("usage.csv");
4240 fs::write(
4241 &csv,
4242 "\
4243workflow_path,net_amount,gross_amount,discount_amount,quantity,unit_type,sku,product,date,repository,organization,username,applied_cost_per_quantity\r\n\
4244.github/workflows/caller.yml,0.06,0.06,0,10,Minutes,actions_linux,Actions,2026-09-01,example-org/app,example-org,ci,0.006\r\n\
4245.github/workflows/lint.yml,1.00,1.00,0,10,minutes,actions_macos,actions,2026-09-30,example-org/app,example-org,ci,0.100\r\n",
4246 )
4247 .unwrap();
4248 let rows = read_usage(&csv).unwrap();
4249 let (_, found) = load_dir(&dir);
4250 let ranking = rank(&rows, None).unwrap();
4251 assert_eq!(ranking.workflows.len(), 2);
4252 assert_eq!(ranking.bill.period_from.as_deref(), Some("2026-09-01"));
4253 assert_eq!(ranking.bill.period_to.as_deref(), Some("2026-09-30"));
4254 let caller = ranking
4255 .workflows
4256 .iter()
4257 .find(|item| item.workflow_path == ".github/workflows/caller.yml")
4258 .unwrap();
4259 let lint = ranking
4260 .workflows
4261 .iter()
4262 .find(|item| item.workflow_path == ".github/workflows/lint.yml")
4263 .unwrap();
4264 assert_eq!(caller.net, Micros::parse("0.06").unwrap());
4265 assert_eq!(caller.gross, Micros::parse("0.06").unwrap());
4266 assert_eq!(lint.net, Micros::parse("1.00").unwrap());
4267 assert_ne!(caller.net, lint.net);
4268 let (text, json) = report_for(&found, &rows);
4269 let caller_at = text
4270 .find("example-org/app .github/workflows/caller.yml")
4271 .unwrap();
4272 let lint_at = text
4273 .find("example-org/app .github/workflows/lint.yml")
4274 .unwrap();
4275 assert!(lint_at < caller_at, "{text}");
4276 assert!(text[caller_at..].contains("0.06"), "{text}");
4277 assert!(
4278 text[caller_at..].contains("lint.yml:5: lint on macOS"),
4279 "{text}"
4280 );
4281 assert!(!text[caller_at..].contains("1.00"), "{text}");
4282 let caller_json = json["workflows"]
4283 .as_array()
4284 .unwrap()
4285 .iter()
4286 .find(|item| item["path"] == ".github/workflows/caller.yml")
4287 .unwrap();
4288 assert_eq!(caller_json["net"], "0.060000");
4289 assert_eq!(caller_json["gross"], "0.060000");
4290 }
4291
4292 struct RemoteCase {
4293 name: &'static str,
4294 caller_file: &'static str,
4295 job_body: &'static str,
4296 spec: &'static str,
4297 relative: &'static str,
4298 crlf: bool,
4299 bom: bool,
4300 }
4301
4302 fn remote_cases() -> Vec<RemoteCase> {
4303 let path = "acme/tools/.github/workflows/lint.yml@v1";
4304 vec![
4305 RemoteCase {
4306 name: "tag",
4307 caller_file: "caller.yml",
4308 job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\n",
4309 spec: path,
4310 relative: path,
4311 crlf: false,
4312 bom: false,
4313 },
4314 RemoteCase {
4315 name: "yaml-ext",
4316 caller_file: "caller.yaml",
4317 job_body: "uses: acme/tools/.github/workflows/lint.yaml@v1\n",
4318 spec: "acme/tools/.github/workflows/lint.yaml@v1",
4319 relative: "acme/tools/.github/workflows/lint.yaml@v1",
4320 crlf: false,
4321 bom: false,
4322 },
4323 RemoteCase {
4324 name: "tag-dotted",
4325 caller_file: "caller.yml",
4326 job_body: "uses: acme/tools/.github/workflows/lint.yml@v1.2.3\n",
4327 spec: "acme/tools/.github/workflows/lint.yml@v1.2.3",
4328 relative: "acme/tools/.github/workflows/lint.yml@v1.2.3",
4329 crlf: false,
4330 bom: false,
4331 },
4332 RemoteCase {
4333 name: "branch",
4334 caller_file: "caller.yml",
4335 job_body: "uses: acme/tools/.github/workflows/lint.yml@main\n",
4336 spec: "acme/tools/.github/workflows/lint.yml@main",
4337 relative: "acme/tools/.github/workflows/lint.yml@main",
4338 crlf: false,
4339 bom: false,
4340 },
4341 RemoteCase {
4342 name: "branch-slash",
4343 caller_file: "caller.yml",
4344 job_body: "uses: acme/tools/.github/workflows/lint.yml@feature/widget\n",
4345 spec: "acme/tools/.github/workflows/lint.yml@feature/widget",
4346 relative: "acme/tools/.github/workflows/lint.yml@feature/widget",
4347 crlf: false,
4348 bom: false,
4349 },
4350 RemoteCase {
4351 name: "refs-heads",
4352 caller_file: "caller.yml",
4353 job_body: "uses: acme/tools/.github/workflows/lint.yml@refs/heads/main\n",
4354 spec: "acme/tools/.github/workflows/lint.yml@refs/heads/main",
4355 relative: "acme/tools/.github/workflows/lint.yml@refs/heads/main",
4356 crlf: false,
4357 bom: false,
4358 },
4359 RemoteCase {
4360 name: "sha",
4361 caller_file: "caller.yml",
4362 job_body: "uses: acme/tools/.github/workflows/lint.yml@0123456789abcdef0123456789abcdef01234567\n",
4363 spec: "acme/tools/.github/workflows/lint.yml@0123456789abcdef0123456789abcdef01234567",
4364 relative: "acme/tools/.github/workflows/lint.yml@0123456789abcdef0123456789abcdef01234567",
4365 crlf: false,
4366 bom: false,
4367 },
4368 RemoteCase {
4369 name: "short-sha",
4370 caller_file: "caller.yml",
4371 job_body: "uses: acme/tools/.github/workflows/lint.yml@abc1234\n",
4372 spec: "acme/tools/.github/workflows/lint.yml@abc1234",
4373 relative: "acme/tools/.github/workflows/lint.yml@abc1234",
4374 crlf: false,
4375 bom: false,
4376 },
4377 RemoteCase {
4378 name: "with-after",
4379 caller_file: "caller.yml",
4380 job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\nwith:\n extra: \"1\"\n",
4381 spec: path,
4382 relative: path,
4383 crlf: false,
4384 bom: false,
4385 },
4386 RemoteCase {
4387 name: "secrets-before",
4388 caller_file: "caller.yml",
4389 job_body: "secrets: inherit\nwith:\n extra: \"1\"\nuses: acme/tools/.github/workflows/lint.yml@v1\n",
4390 spec: path,
4391 relative: path,
4392 crlf: false,
4393 bom: false,
4394 },
4395 RemoteCase {
4396 name: "with-and-secrets",
4397 caller_file: "caller.yml",
4398 job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\nwith:\n extra: \"1\"\nsecrets: inherit\n",
4399 spec: path,
4400 relative: path,
4401 crlf: false,
4402 bom: false,
4403 },
4404 RemoteCase {
4405 name: "empty-with-secrets",
4406 caller_file: "caller.yml",
4407 job_body: "with: {}\nsecrets: {}\nuses: acme/tools/.github/workflows/lint.yml@v1\n",
4408 spec: path,
4409 relative: path,
4410 crlf: false,
4411 bom: false,
4412 },
4413 RemoteCase {
4414 name: "secrets-map-before",
4415 caller_file: "caller.yml",
4416 job_body: "secrets:\n token: value\nuses: acme/tools/.github/workflows/lint.yml@v1\nwith:\n extra: \"1\"\n",
4417 spec: path,
4418 relative: path,
4419 crlf: false,
4420 bom: false,
4421 },
4422 RemoteCase {
4423 name: "double-quoted",
4424 caller_file: "caller.yml",
4425 job_body: "uses: \"acme/tools/.github/workflows/lint.yml@v1\"\n",
4426 spec: path,
4427 relative: path,
4428 crlf: false,
4429 bom: false,
4430 },
4431 RemoteCase {
4432 name: "single-quoted",
4433 caller_file: "caller.yml",
4434 job_body: "uses: 'acme/tools/.github/workflows/lint.yml@v1'\n",
4435 spec: path,
4436 relative: path,
4437 crlf: false,
4438 bom: false,
4439 },
4440 RemoteCase {
4441 name: "capitals",
4442 caller_file: "caller.yml",
4443 job_body: "uses: Acme/Tools/.GitHub/Workflows/Lint.YML@V1\n",
4444 spec: "Acme/Tools/.GitHub/Workflows/Lint.YML@V1",
4445 relative: "Acme/Tools/.GitHub/Workflows/Lint.YML@V1",
4446 crlf: false,
4447 bom: false,
4448 },
4449 RemoteCase {
4450 name: "backslash",
4451 caller_file: "caller.yml",
4452 job_body: "uses: acme/tools/.github\\workflows\\lint.yml@v1\n",
4453 spec: "acme/tools/.github\\workflows\\lint.yml@v1",
4454 relative: path,
4455 crlf: false,
4456 bom: false,
4457 },
4458 RemoteCase {
4459 name: "crlf",
4460 caller_file: "caller.yml",
4461 job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\nsecrets: inherit\nwith:\n extra: \"1\"\n",
4462 spec: path,
4463 relative: path,
4464 crlf: true,
4465 bom: false,
4466 },
4467 RemoteCase {
4468 name: "bom",
4469 caller_file: "caller.yml",
4470 job_body: "uses: acme/tools/.github/workflows/lint.yml@v1\n",
4471 spec: path,
4472 relative: path,
4473 crlf: false,
4474 bom: true,
4475 },
4476 RemoteCase {
4477 name: "block-scalar",
4478 caller_file: "caller.yml",
4479 job_body: "uses: |\n acme/tools/.github/workflows/lint.yml@v1\n",
4480 spec: path,
4481 relative: path,
4482 crlf: false,
4483 bom: false,
4484 },
4485 ]
4486 }
4487
4488 fn b4b_dirs(name: &str) -> (PathBuf, PathBuf) {
4489 let root = std::env::temp_dir().join("ci-bill-b4b").join(name);
4490 let _ = fs::remove_dir_all(&root);
4491 let workflows = root.join(".github").join("workflows");
4492 let runs = root.join("runs");
4493 fs::create_dir_all(&workflows).unwrap();
4494 fs::create_dir_all(&runs).unwrap();
4495 (workflows, runs)
4496 }
4497
4498 fn filesystem_is_case_sensitive(dir: &Path) -> bool {
4500 std::fs::create_dir_all(dir).unwrap();
4501 let probe = dir.join("case-probe.tmp");
4502 std::fs::write(&probe, "").unwrap();
4503 let sensitive = !dir.join("CASE-PROBE.TMP").exists();
4504 std::fs::remove_file(&probe).unwrap();
4505 sensitive
4506 }
4507
4508 fn path_from_slash(relative: &str) -> PathBuf {
4509 relative
4510 .split('/')
4511 .filter(|part| !part.is_empty())
4512 .collect()
4513 }
4514
4515 fn write_cache(runs: &Path, relative: &str, text: &str, crlf_file: bool, bom: bool) {
4516 let path = runs.join(path_from_slash(relative));
4517 if let Some(parent) = path.parent() {
4518 fs::create_dir_all(parent).unwrap();
4519 }
4520 let mut body = if crlf_file {
4521 text.replace('\n', "\r\n")
4522 } else {
4523 text.to_string()
4524 };
4525 if bom {
4526 body.insert(0, '\u{feff}');
4527 }
4528 fs::write(path, body).unwrap();
4529 }
4530
4531 fn caller_yaml(job_body: &str) -> String {
4532 indent_job_body(&format!(
4533 "name: Caller\non: push\njobs:\n call-lint:\n{job_body}"
4534 ))
4535 }
4536
4537 fn uses_key_line(yaml: &str) -> usize {
4538 yaml.lines()
4539 .position(|line| line.contains("uses:"))
4540 .unwrap_or_else(|| panic!("no uses key in {yaml}"))
4541 + 1
4542 }
4543
4544 fn remote_of(job: &crate::workflow::Job) -> RemoteWorkflow {
4545 match &job.uses.as_ref().expect("uses").source {
4546 ReusableSource::Remote(remote) => remote.clone(),
4547 ReusableSource::Local => panic!("expected a remote reusable workflow"),
4548 }
4549 }
4550
4551 fn assert_no_limitation(text: &str) {
4552 let lower = text.to_ascii_lowercase();
4553 assert!(!lower.contains("not supported"), "{text}");
4554 assert!(!lower.contains("unsupported"), "{text}");
4555 assert!(!lower.contains("skipped"), "{text}");
4556 assert!(!lower.contains("not implemented"), "{text}");
4557 }
4558
4559 fn assert_costs_stay_on_caller(
4560 found: &[Finding],
4561 caller_file: &str,
4562 needle: &str,
4563 ) -> (String, serde_json::Value) {
4564 let rows = [
4565 usage(&caller_key(caller_file), "0.06", "0.006"),
4566 usage(".github/workflows/other.yml", "1.00", "0.100"),
4567 ];
4568 let ranking = rank(&rows, None).unwrap();
4569 assert_eq!(ranking.workflows.len(), 2);
4570 let caller = ranking
4571 .workflows
4572 .iter()
4573 .find(|item| item.workflow_path == caller_key(caller_file))
4574 .unwrap();
4575 let other = ranking
4576 .workflows
4577 .iter()
4578 .find(|item| item.workflow_path == ".github/workflows/other.yml")
4579 .unwrap();
4580 assert_eq!(caller.net, Micros::parse("0.06").unwrap());
4581 assert_eq!(other.net, Micros::parse("1.00").unwrap());
4582 assert_eq!(
4583 ranking.bill.total_net,
4584 Micros::parse("1.06").unwrap(),
4585 "remote calls must not add or move invoice net"
4586 );
4587 let (text, json) = report_for(found, &rows);
4588 assert_no_limitation(&text);
4589 assert_no_limitation(&json.to_string());
4590 let label = format!("example-org/app {}", caller_key(caller_file));
4591 let other_label = "example-org/app .github/workflows/other.yml";
4592 let other_at = text
4593 .find(other_label)
4594 .unwrap_or_else(|| panic!("missing {other_label} in {text}"));
4595 let caller_at = text
4596 .find(&label)
4597 .unwrap_or_else(|| panic!("missing {label} in {text}"));
4598 assert!(other_at < caller_at, "{text}");
4599 assert!(
4600 !text[other_at..caller_at].contains(needle),
4601 "{needle} landed on the other workflow:\n{text}"
4602 );
4603 assert!(
4604 text[caller_at..].contains(needle),
4605 "missing {needle} under the caller:\n{text}"
4606 );
4607 assert!(!text.contains("Workflows without usage"), "{text}");
4608 let caller_json = json["workflows"]
4609 .as_array()
4610 .unwrap()
4611 .iter()
4612 .find(|item| item["path"] == caller_key(caller_file))
4613 .unwrap();
4614 assert_eq!(caller_json["net"], "0.060000");
4615 assert!(
4616 json["unattached_findings"].as_array().unwrap().is_empty(),
4617 "{json}"
4618 );
4619 (text, json)
4620 }
4621
4622 #[test]
4623 fn remote_without_cache_shows_the_job_at_the_uses_line() {
4624 for case in remote_cases() {
4625 let (dir, runs) = b4b_dirs(&format!("miss-{}", case.name));
4626 let yaml = caller_yaml(case.job_body);
4627 let expected_line = uses_key_line(&yaml);
4628 write_workflow(&dir, case.caller_file, &yaml, case.crlf, case.bom);
4629 for runs_arg in [None, Some(runs.as_path())] {
4630 let scanned = scan_dir(&dir).expect("scan");
4631 let found = findings_with_cache(&scanned, runs_arg).expect("findings");
4632 let call = job_named(&scanned, case.caller_file, "call-lint");
4633 assert_eq!(call.uses.as_ref().unwrap().spec, case.spec, "{}", case.name);
4634 assert_eq!(
4635 call.uses.as_ref().unwrap().line,
4636 expected_line,
4637 "{}",
4638 case.name
4639 );
4640 assert_eq!(
4641 remote_of(call).cache_relative(),
4642 path_from_slash(case.relative),
4643 "{}",
4644 case.name
4645 );
4646 assert!(call.invalid_uses.is_none(), "{}", case.name);
4647 assert_no_timeout_on_call(&found, case.caller_file, call.line);
4648 let shown = attributed(&found, case.caller_file, FindingKind::RemoteWorkflow);
4649 assert_eq!(shown.len(), 1, "{}: {found:?}", case.name);
4650 assert_eq!(shown[0].line, expected_line, "{}", case.name);
4651 assert_eq!(shown[0].kind.code(), "F10");
4652 assert_eq!(
4653 shown[0].message,
4654 format!("job call-lint uses {}", case.spec),
4655 "{}",
4656 case.name
4657 );
4658 assert!(shown[0].path.ends_with(case.caller_file), "{}", case.name);
4659 assert!(
4660 found
4661 .iter()
4662 .all(|item| item.kind != FindingKind::CalleeNotFound),
4663 "{}: {found:?}",
4664 case.name
4665 );
4666 let needle = format!(
4667 "F10 {}:{}: job call-lint uses {}",
4668 crate::display::display_path(&shown[0].path),
4669 expected_line,
4670 case.spec
4671 );
4672 let (_text, json) = assert_costs_stay_on_caller(&found, case.caller_file, &needle);
4673 let listed = json["workflows"]
4674 .as_array()
4675 .unwrap()
4676 .iter()
4677 .find(|item| item["path"] == caller_key(case.caller_file))
4678 .unwrap()["findings"]
4679 .as_array()
4680 .unwrap()
4681 .clone();
4682 assert!(
4683 listed.iter().any(|item| {
4684 item["id"] == "F10"
4685 && item["line"] == expected_line
4686 && item["message"] == format!("job call-lint uses {}", case.spec)
4687 }),
4688 "{}: {listed:?}",
4689 case.name
4690 );
4691 }
4692 }
4693
4694 let (dir, _) = b4b_dirs("miss-flow");
4695 let flow = "name: Caller\non: push\njobs: {call-lint: {secrets: inherit, with: {extra: \"1\"}, uses: acme/tools/.github/workflows/lint.yml@v1}}\n";
4696 write_workflow(&dir, "caller.yml", flow, false, false);
4697 let (workflows, found) = load_cached_pair(&dir, None);
4698 let call = job_named(&workflows, "caller.yml", "call-lint");
4699 let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
4700 assert_eq!(shown.len(), 1, "{found:?}");
4701 assert_eq!(shown[0].line, call.uses.as_ref().unwrap().line);
4702 assert_eq!(
4703 shown[0].message,
4704 "job call-lint uses acme/tools/.github/workflows/lint.yml@v1"
4705 );
4706 assert_eq!(shown[0].line, uses_key_line(flow));
4707
4708 let (dir, _) = b4b_dirs("miss-two-jobs");
4709 write_workflow(
4710 &dir,
4711 "caller.yml",
4712 "name: Caller\non: push\njobs:\n first:\n uses: acme/tools/.github/workflows/lint.yml@v1\n second:\n with: {}\n uses: other/repo/.github/workflows/build.yaml@release/1.2\n",
4713 false,
4714 false,
4715 );
4716 let (workflows, found) = load_cached_pair(&dir, None);
4717 let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
4718 assert_eq!(shown.len(), 2, "{found:?}");
4719 let first = job_named(&workflows, "caller.yml", "first");
4720 let second = job_named(&workflows, "caller.yml", "second");
4721 assert!(shown.iter().any(|item| {
4722 item.line == first.uses.as_ref().unwrap().line
4723 && item.message == "job first uses acme/tools/.github/workflows/lint.yml@v1"
4724 }));
4725 assert!(shown.iter().any(|item| {
4726 item.line == second.uses.as_ref().unwrap().line
4727 && item.message
4728 == "job second uses other/repo/.github/workflows/build.yaml@release/1.2"
4729 }));
4730 assert_eq!(
4731 remote_of(second).cache_relative(),
4732 path_from_slash("other/repo/.github/workflows/build.yaml@release/1.2")
4733 );
4734 }
4735
4736 fn load_cached_pair(dir: &Path, runs: Option<&Path>) -> (Vec<Workflow>, Vec<Finding>) {
4737 let workflows = scan_dir(dir).expect("scan workflows");
4738 let found = findings_with_cache(&workflows, runs).expect("findings");
4739 (workflows, found)
4740 }
4741
4742 #[test]
4743 fn remote_cache_file_puts_callee_findings_under_the_caller() {
4744 for case in remote_cases() {
4745 let (dir, runs) = b4b_dirs(&format!("hit-{}", case.name));
4746 let yaml = caller_yaml(case.job_body);
4747 write_workflow(&dir, case.caller_file, &yaml, case.crlf, case.bom);
4748 write_cache(&runs, case.relative, LINT_CALLEE, case.crlf, case.bom);
4749 let (workflows, found) = load_cached_pair(&dir, Some(&runs));
4750 let call = job_named(&workflows, case.caller_file, "call-lint");
4751 assert_eq!(call.uses.as_ref().unwrap().spec, case.spec, "{}", case.name);
4752 assert_no_timeout_on_call(&found, case.caller_file, call.line);
4753 let f1 = attributed(&found, case.caller_file, FindingKind::LintOnRunner);
4754 assert_eq!(f1.len(), 1, "{}: {found:?}", case.name);
4755 assert_eq!(f1[0].line, 5, "{}", case.name);
4756 assert_eq!(f1[0].message, "lint on macOS", "{}", case.name);
4757 assert_eq!(f1[0].kind.code(), "F1");
4758 assert!(
4759 f1[0].path.ends_with(path_from_slash(case.relative)),
4760 "{}: {}",
4761 case.name,
4762 f1[0].path.display()
4763 );
4764 assert!(
4765 attributed(&found, case.caller_file, FindingKind::RemoteWorkflow).is_empty(),
4766 "{} still listed the call as uncached: {found:?}",
4767 case.name
4768 );
4769 assert!(
4770 found
4771 .iter()
4772 .all(|item| item.kind != FindingKind::CalleeNotFound),
4773 "{}",
4774 case.name
4775 );
4776 let needle = format!(
4777 "F1 {}:5: lint on macOS",
4778 crate::display::display_path(&f1[0].path)
4779 );
4780 let (_text, json) = assert_costs_stay_on_caller(&found, case.caller_file, &needle);
4781 let listed = json["workflows"]
4782 .as_array()
4783 .unwrap()
4784 .iter()
4785 .find(|item| item["path"] == caller_key(case.caller_file))
4786 .unwrap()["findings"]
4787 .as_array()
4788 .unwrap()
4789 .clone();
4790 assert!(
4791 listed.iter().any(|item| {
4792 item["id"] == "F1"
4793 && item["line"] == 5
4794 && item["message"] == "lint on macOS"
4795 && item["file"]
4796 .as_str()
4797 .unwrap()
4798 .ends_with(case.relative.rsplit('/').next().unwrap())
4799 }),
4800 "{}: {listed:?}",
4801 case.name
4802 );
4803 }
4804
4805 let (dir, runs) = b4b_dirs("hit-flow");
4806 let flow = "on: push\njobs: {call-lint: {uses: acme/tools/.github/workflows/lint.yml@v1, secrets: inherit, with: {extra: \"1\"}}}\n";
4807 write_workflow(&dir, "caller.yml", flow, true, false);
4808 write_cache(
4809 &runs,
4810 "acme/tools/.github/workflows/lint.yml@v1",
4811 LINT_CALLEE,
4812 true,
4813 true,
4814 );
4815 let (_, found) = load_cached_pair(&dir, Some(&runs));
4816 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4817 assert_eq!(f1.len(), 1, "{found:?}");
4818 assert_eq!(f1[0].line, 5);
4819 assert_eq!(f1[0].message, "lint on macOS");
4820
4821 let (dir, runs) = b4b_dirs("hit-casefold");
4822 write_workflow(
4823 &dir,
4824 "caller.yml",
4825 "name: Caller\non: push\njobs:\n call-lint:\n uses: Acme/Tools/.GitHub/Workflows/Lint.YML@V1\n",
4826 false,
4827 false,
4828 );
4829 write_cache(
4830 &runs,
4831 "acme/tools/.github/workflows/lint.yml@v1",
4832 LINT_CALLEE,
4833 false,
4834 false,
4835 );
4836 let (_, found) = load_cached_pair(&dir, Some(&runs));
4837 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4838 assert_eq!(f1.len(), 1, "{found:?}");
4839 let shown = f1[0].path.to_string_lossy().to_lowercase();
4843 let expected = path_from_slash("acme/tools/.github/workflows/lint.yml@v1");
4844 assert!(
4845 shown.ends_with(&*expected.to_string_lossy()),
4846 "{}",
4847 f1[0].path.display()
4848 );
4849
4850 let (dir, runs) = b4b_dirs("hit-exact-wins");
4854 if filesystem_is_case_sensitive(&runs) {
4855 write_workflow(
4856 &dir,
4857 "caller.yml",
4858 "name: Caller\non: push\njobs:\n call-lint:\n uses: Acme/Tools/.GitHub/Workflows/Lint.YML@V1\n",
4859 false,
4860 false,
4861 );
4862 write_cache(
4863 &runs,
4864 "Acme/Tools/.GitHub/Workflows/Lint.YML@V1",
4865 LINT_CALLEE,
4866 false,
4867 false,
4868 );
4869 write_cache(
4870 &runs,
4871 "acme/tools/.github/workflows/lint.yml@v1",
4872 "name: Lint\non: workflow_call\njobs:\n fmt:\n runs-on: windows-latest\n timeout-minutes: 10\n steps:\n - run: cargo fmt --check\n",
4873 false,
4874 false,
4875 );
4876 let (_, found) = load_cached_pair(&dir, Some(&runs));
4877 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4878 assert_eq!(f1.len(), 1, "{found:?}");
4879 assert_eq!(f1[0].message, "lint on macOS");
4880 assert!(
4881 f1[0]
4882 .path
4883 .ends_with(path_from_slash("Acme/Tools/.GitHub/Workflows/Lint.YML@V1")),
4884 "{}",
4885 f1[0].path.display()
4886 );
4887 }
4888
4889 let bodies = [
4890 (
4891 "windows",
4892 "name: Lint\non: workflow_call\njobs:\n fmt:\n runs-on: windows-latest\n timeout-minutes: 10\n steps:\n - run: cargo fmt --check\n",
4893 FindingKind::LintOnRunner,
4894 5,
4895 "lint on Windows",
4896 ),
4897 (
4898 "timeout",
4899 "name: Called\non: workflow_call\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo build\n",
4900 FindingKind::MissingTimeout,
4901 4,
4902 "default timeout is 360 minutes",
4903 ),
4904 (
4905 "pull-request",
4906 "name: Called\non: pull_request\njobs:\n test:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n steps:\n - run: cargo test\n",
4907 FindingKind::PullRequestConcurrency,
4908 2,
4909 "pull_request without concurrency cancel-in-progress",
4910 ),
4911 (
4912 "matrix",
4913 MATRIX_CALLEE,
4914 FindingKind::MatrixJobs,
4915 8,
4916 "matrix runs 6 jobs; each job is billed in whole minutes",
4917 ),
4918 (
4919 "duplicate",
4920 "name: Called\non: workflow_call\njobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: macos-latest\n steps:\n - run: cargo fmt --check\n",
4921 FindingKind::DuplicateKey,
4922 6,
4923 "duplicate key runs-on (line 5 and 6)",
4924 ),
4925 ];
4926 for (name, body, kind, line, message) in bodies {
4927 let (dir, runs) = b4b_dirs(&format!("hit-{name}"));
4928 write_workflow(
4929 &dir,
4930 "caller.yml",
4931 "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
4932 false,
4933 false,
4934 );
4935 write_cache(
4936 &runs,
4937 "acme/tools/.github/workflows/lint.yml@v1",
4938 body,
4939 false,
4940 false,
4941 );
4942 let (workflows, found) = load_cached_pair(&dir, Some(&runs));
4943 let call = job_named(&workflows, "caller.yml", "call-lint");
4944 assert_no_timeout_on_call(&found, "caller.yml", call.line);
4945 let matched = attributed(&found, "caller.yml", kind);
4946 assert_eq!(matched.len(), 1, "{name}: {found:?}");
4947 assert_eq!(matched[0].line, line, "{name}");
4948 assert_eq!(matched[0].message, message, "{name}");
4949 assert!(
4950 matched[0]
4951 .path
4952 .ends_with(path_from_slash("acme/tools/.github/workflows/lint.yml@v1")),
4953 "{name}: {}",
4954 matched[0].path.display()
4955 );
4956 let needle = format!(
4957 "{} {}:{}: {message}",
4958 kind.code(),
4959 crate::display::display_path(&matched[0].path),
4960 line
4961 );
4962 let _ = assert_costs_stay_on_caller(&found, "caller.yml", &needle);
4963 }
4964 }
4965
4966 #[test]
4967 fn remote_cache_expands_nested_calls_and_stops_cycles() {
4968 let (dir, runs) = b4b_dirs("nested-remote");
4969 write_workflow(
4970 &dir,
4971 "caller.yml",
4972 "name: Caller\non: push\njobs:\n call-mid:\n secrets: inherit\n uses: acme/tools/.github/workflows/mid.yml@v1\n",
4973 false,
4974 false,
4975 );
4976 write_cache(
4977 &runs,
4978 "acme/tools/.github/workflows/mid.yml@v1",
4979 "name: Mid\non: workflow_call\njobs:\n forward:\n with:\n extra: \"1\"\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
4980 false,
4981 false,
4982 );
4983 write_cache(
4984 &runs,
4985 "acme/tools/.github/workflows/lint.yml@v1",
4986 LINT_CALLEE,
4987 false,
4988 false,
4989 );
4990 let (_, found) = load_cached_pair(&dir, Some(&runs));
4991 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
4992 assert_eq!(f1.len(), 1, "{found:?}");
4993 assert_eq!(f1[0].line, 5);
4994 assert!(
4995 f1[0].path.ends_with("lint.yml@v1"),
4996 "{}",
4997 f1[0].path.display()
4998 );
4999 assert!(
5000 attributed(&found, "caller.yml", FindingKind::ReusableCycle).is_empty(),
5001 "{found:?}"
5002 );
5003
5004 let (dir, runs) = b4b_dirs("nested-local-inside-remote");
5005 write_workflow(
5006 &dir,
5007 "caller.yml",
5008 "name: Caller\non: push\njobs:\n call-mid:\n uses: acme/tools/.github/workflows/mid.yml@release/1.2\n",
5009 true,
5010 false,
5011 );
5012 write_cache(
5013 &runs,
5014 "acme/tools/.github/workflows/mid.yml@release/1.2",
5015 "name: Mid\non: workflow_call\njobs:\n forward:\n uses: ./.github/workflows/lint.yml\n secrets: inherit\n",
5016 true,
5017 false,
5018 );
5019 write_cache(
5020 &runs,
5021 "acme/tools/.github/workflows/lint.yml@release/1.2",
5022 LINT_CALLEE,
5023 true,
5024 false,
5025 );
5026 let (_, found) = load_cached_pair(&dir, Some(&runs));
5027 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
5028 assert_eq!(f1.len(), 1, "{found:?}");
5029 assert_eq!(f1[0].message, "lint on macOS");
5030 assert!(
5031 f1[0].path.ends_with(path_from_slash(
5032 "acme/tools/.github/workflows/lint.yml@release/1.2"
5033 )),
5034 "{}",
5035 f1[0].path.display()
5036 );
5037
5038 let (dir, runs) = b4b_dirs("nested-local-missing");
5039 write_workflow(
5040 &dir,
5041 "caller.yml",
5042 "name: Caller\non: push\njobs:\n call-mid:\n uses: acme/tools/.github/workflows/mid.yml@v1\n",
5043 false,
5044 false,
5045 );
5046 write_cache(
5047 &runs,
5048 "acme/tools/.github/workflows/mid.yml@v1",
5049 "name: Mid\non: workflow_call\njobs:\n forward:\n uses: ./.github/workflows/missing.yml\n",
5050 false,
5051 false,
5052 );
5053 let (_, found) = load_cached_pair(&dir, Some(&runs));
5054 let missing = attributed(&found, "caller.yml", FindingKind::CalleeNotFound);
5055 assert_eq!(missing.len(), 1, "{found:?}");
5056 assert!(
5057 missing[0].message.contains("callee not found"),
5058 "{}",
5059 missing[0].message
5060 );
5061 assert!(
5062 missing[0]
5063 .message
5064 .contains("./.github/workflows/missing.yml"),
5065 "{}",
5066 missing[0].message
5067 );
5068 assert!(
5069 missing[0].path.ends_with("mid.yml@v1"),
5070 "{}",
5071 missing[0].path.display()
5072 );
5073 assert_no_limitation(&missing[0].message);
5074
5075 let (dir, runs) = b4b_dirs("cycle-two");
5076 write_workflow(
5077 &dir,
5078 "caller.yml",
5079 "name: Caller\non: push\njobs:\n call-a:\n uses: acme/tools/.github/workflows/a.yml@v1\n",
5080 false,
5081 false,
5082 );
5083 let spec_a = "acme/tools/.github/workflows/a.yml@v1";
5084 let spec_b = "acme/tools/.github/workflows/b.yml@v1";
5085 write_cache(
5086 &runs,
5087 spec_a,
5088 &format!(
5089 "name: A\non: workflow_call\njobs:\n lint:\n runs-on: macos-latest\n timeout-minutes: 5\n steps:\n - run: cargo fmt --check\n call-b:\n uses: {spec_b}\n"
5090 ),
5091 false,
5092 false,
5093 );
5094 write_cache(
5095 &runs,
5096 spec_b,
5097 &format!("name: B\non: workflow_call\njobs:\n call-a:\n uses: {spec_a}\n secrets: inherit\n"),
5098 false,
5099 false,
5100 );
5101 let (_, found) = load_cached_pair(&dir, Some(&runs));
5102 assert!(found.len() < 12, "{found:?}");
5103 let cycles = attributed(&found, "caller.yml", FindingKind::ReusableCycle);
5104 assert_eq!(cycles.len(), 1, "{found:?}");
5105 assert!(cycles[0].message.contains(spec_a), "{}", cycles[0].message);
5106 assert!(cycles[0].message.contains(spec_b), "{}", cycles[0].message);
5107 assert!(cycles[0].message.contains(" -> "), "{}", cycles[0].message);
5108 assert_eq!(cycles[0].kind.code(), "F7");
5109 let f1 = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
5110 assert_eq!(f1.len(), 1, "{found:?}");
5111 assert_eq!(f1[0].message, "lint on macOS");
5112
5113 let (dir, runs) = b4b_dirs("cycle-self");
5114 let spec = "acme/tools/.github/workflows/loop.yml@main";
5115 write_workflow(
5116 &dir,
5117 "caller.yml",
5118 &format!("name: Caller\non: push\njobs:\n again:\n uses: {spec}\n"),
5119 false,
5120 false,
5121 );
5122 write_cache(
5123 &runs,
5124 spec,
5125 &format!("name: Loop\non: workflow_call\njobs:\n again:\n uses: {spec}\n"),
5126 false,
5127 false,
5128 );
5129 let (_, found) = load_cached_pair(&dir, Some(&runs));
5130 assert!(found.len() < 6, "{found:?}");
5131 let cycles = attributed(&found, "caller.yml", FindingKind::ReusableCycle);
5132 assert_eq!(cycles.len(), 1, "{found:?}");
5133 assert!(
5134 cycles[0].message.contains(&format!("{spec} -> {spec}")),
5135 "{}",
5136 cycles[0].message
5137 );
5138 }
5139
5140 #[test]
5141 fn remote_cache_errors_name_the_file_and_keep_a_visible_job() {
5142 let (dir, runs) = b4b_dirs("bad-yaml");
5143 write_workflow(
5144 &dir,
5145 "caller.yml",
5146 "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
5147 false,
5148 false,
5149 );
5150 write_cache(
5151 &runs,
5152 "acme/tools/.github/workflows/lint.yml@v1",
5153 "name: Broken\non: push\njobs: [\nUNIQUE_SECRET_TOKEN\n",
5154 false,
5155 false,
5156 );
5157 let scanned = scan_dir(&dir).unwrap();
5158 let err = findings_with_cache(&scanned, Some(&runs)).unwrap_err();
5159 let message = err.to_string();
5160 assert!(message.starts_with("line "), "{message}");
5161 assert!(message.contains("invalid YAML"), "{message}");
5162 assert!(!message.contains("UNIQUE_SECRET_TOKEN"), "{message}");
5163 assert!(
5164 err.path
5165 .ends_with(path_from_slash("acme/tools/.github/workflows/lint.yml@v1")),
5166 "{}",
5167 err.path.display()
5168 );
5169
5170 let (dir, runs) = b4b_dirs("bad-bytes");
5171 write_workflow(
5172 &dir,
5173 "caller.yml",
5174 "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
5175 false,
5176 false,
5177 );
5178 let bytes = runs.join(path_from_slash("acme/tools/.github/workflows/lint.yml@v1"));
5179 fs::create_dir_all(bytes.parent().unwrap()).unwrap();
5180 fs::write(&bytes, [0xff, 0xfe, 0xfd]).unwrap();
5181 let scanned = scan_dir(&dir).unwrap();
5182 let err = findings_with_cache(&scanned, Some(&runs)).unwrap_err();
5183 assert_eq!(err.to_string(), "cannot read file");
5184 assert!(!format!("{err} {}", err.path.display()).contains("not supported"));
5185
5186 let (dir, runs) = b4b_dirs("empty-callee");
5187 write_workflow(
5188 &dir,
5189 "caller.yml",
5190 "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
5191 false,
5192 false,
5193 );
5194 write_cache(
5195 &runs,
5196 "acme/tools/.github/workflows/lint.yml@v1",
5197 "",
5198 false,
5199 false,
5200 );
5201 let (_, found) = load_cached_pair(&dir, Some(&runs));
5202 let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
5203 assert_eq!(shown.len(), 1, "{found:?}");
5204 assert_eq!(
5205 shown[0].message,
5206 "job call-lint uses acme/tools/.github/workflows/lint.yml@v1"
5207 );
5208 assert_eq!(shown[0].line, 5);
5209
5210 let (dir, runs) = b4b_dirs("dir-callee");
5211 write_workflow(
5212 &dir,
5213 "caller.yml",
5214 "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
5215 false,
5216 false,
5217 );
5218 fs::create_dir_all(runs.join(path_from_slash("acme/tools/.github/workflows/lint.yml@v1")))
5219 .unwrap();
5220 let (_, found) = load_cached_pair(&dir, Some(&runs));
5221 let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
5222 assert_eq!(shown.len(), 1, "{found:?}");
5223 assert_eq!(shown[0].line, 5);
5224 assert!(shown[0]
5225 .message
5226 .contains("acme/tools/.github/workflows/lint.yml@v1"));
5227
5228 let (dir, runs) = b4b_dirs("invalid-inside-callee");
5229 write_workflow(
5230 &dir,
5231 "caller.yml",
5232 "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
5233 false,
5234 false,
5235 );
5236 write_cache(
5237 &runs,
5238 "acme/tools/.github/workflows/lint.yml@v1",
5239 "name: Called\non: workflow_call\njobs:\n bad:\n uses: actions/checkout@v4\n",
5240 false,
5241 false,
5242 );
5243 let (_, found) = load_cached_pair(&dir, Some(&runs));
5244 let invalid = attributed(&found, "caller.yml", FindingKind::InvalidUses);
5245 assert_eq!(invalid.len(), 1, "{found:?}");
5246 assert_eq!(
5247 invalid[0].message,
5248 "invalid reusable workflow uses: job bad uses \"actions/checkout@v4\""
5249 );
5250 assert!(invalid[0].path.ends_with("lint.yml@v1"));
5251 }
5252
5253 struct InvalidCase {
5254 name: &'static str,
5255 yaml: &'static str,
5256 job: &'static str,
5257 detail: &'static str,
5258 crlf: bool,
5259 }
5260
5261 #[test]
5262 fn invalid_uses_text_is_a_finding_and_does_not_panic() {
5263 let cases = [
5264 InvalidCase {
5265 name: "empty",
5266 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: \"\"\n",
5267 job: "call-lint",
5268 detail: "\"\"",
5269 crlf: false,
5270 },
5271 InvalidCase {
5272 name: "blank",
5273 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: \" \"\n",
5274 job: "call-lint",
5275 detail: "\"\"",
5276 crlf: false,
5277 },
5278 InvalidCase {
5279 name: "missing-ref",
5280 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml\n",
5281 job: "call-lint",
5282 detail: "\"acme/tools/.github/workflows/lint.yml\"",
5283 crlf: false,
5284 },
5285 InvalidCase {
5286 name: "empty-ref",
5287 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@\n",
5288 job: "call-lint",
5289 detail: "\"acme/tools/.github/workflows/lint.yml@\"",
5290 crlf: false,
5291 },
5292 InvalidCase {
5293 name: "bad-extension",
5294 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.txt@v1\n",
5295 job: "call-lint",
5296 detail: "\"acme/tools/.github/workflows/lint.txt@v1\"",
5297 crlf: false,
5298 },
5299 InvalidCase {
5300 name: "action",
5301 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: actions/checkout@v4\n",
5302 job: "call-lint",
5303 detail: "\"actions/checkout@v4\"",
5304 crlf: false,
5305 },
5306 InvalidCase {
5307 name: "action-capitals",
5308 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: ACTIONS/CHECKOUT@V4\n",
5309 job: "call-lint",
5310 detail: "\"ACTIONS/CHECKOUT@V4\"",
5311 crlf: true,
5312 },
5313 InvalidCase {
5314 name: "local-other",
5315 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: ./foo.yml\n",
5316 job: "call-lint",
5317 detail: "\"./foo.yml\"",
5318 crlf: false,
5319 },
5320 InvalidCase {
5321 name: "url",
5322 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: https://github.com/acme/tools/.github/workflows/lint.yml@v1\n",
5323 job: "call-lint",
5324 detail: "\"https://github.com/acme/tools/.github/workflows/lint.yml@v1\"",
5325 crlf: false,
5326 },
5327 InvalidCase {
5328 name: "owner-repo-only",
5329 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools@v1\n",
5330 job: "call-lint",
5331 detail: "\"acme/tools@v1\"",
5332 crlf: false,
5333 },
5334 InvalidCase {
5335 name: "list",
5336 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses:\n - acme/tools/.github/workflows/lint.yml@v1\n",
5337 job: "call-lint",
5338 detail: "a list",
5339 crlf: false,
5340 },
5341 InvalidCase {
5342 name: "mapping",
5343 yaml: "name: Caller\non: push\njobs:\n call-lint:\n secrets: inherit\n uses:\n path: acme/tools/.github/workflows/lint.yml@v1\n",
5344 job: "call-lint",
5345 detail: "a mapping",
5346 crlf: false,
5347 },
5348 InvalidCase {
5349 name: "flow-list",
5350 yaml: "on: push\njobs: {call-lint: {uses: [acme/tools/.github/workflows/lint.yml@v1]}}\n",
5351 job: "call-lint",
5352 detail: "a list",
5353 crlf: true,
5354 },
5355 InvalidCase {
5356 name: "flow-mapping",
5357 yaml: "on: push\njobs: {call-lint: {uses: {path: acme/tools/.github/workflows/lint.yml@v1}}}\n",
5358 job: "call-lint",
5359 detail: "a mapping",
5360 crlf: false,
5361 },
5362 InvalidCase {
5363 name: "number",
5364 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: 1\n",
5365 job: "call-lint",
5366 detail: "\"1\"",
5367 crlf: false,
5368 },
5369 InvalidCase {
5370 name: "bool",
5371 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: true\n",
5372 job: "call-lint",
5373 detail: "\"true\"",
5374 crlf: false,
5375 },
5376 InvalidCase {
5377 name: "parent-segment",
5378 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/../tools/.github/workflows/lint.yml@v1\n",
5379 job: "call-lint",
5380 detail: "\"acme/../tools/.github/workflows/lint.yml@v1\"",
5381 crlf: false,
5382 },
5383 InvalidCase {
5384 name: "absolute",
5385 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: /acme/tools/.github/workflows/lint.yml@v1\n",
5386 job: "call-lint",
5387 detail: "\"/acme/tools/.github/workflows/lint.yml@v1\"",
5388 crlf: false,
5389 },
5390 InvalidCase {
5391 name: "double-slash",
5392 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github//workflows/lint.yml@v1\n",
5393 job: "call-lint",
5394 detail: "\"acme/tools/.github//workflows/lint.yml@v1\"",
5395 crlf: false,
5396 },
5397 InvalidCase {
5398 name: "ref-dotdot",
5399 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/lint.yml@foo/../../etc\n",
5400 job: "call-lint",
5401 detail: "\"acme/tools/.github/workflows/lint.yml@foo/../../etc\"",
5402 crlf: false,
5403 },
5404 InvalidCase {
5405 name: "missing-file",
5406 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflows/@v1\n",
5407 job: "call-lint",
5408 detail: "\"acme/tools/.github/workflows/@v1\"",
5409 crlf: false,
5410 },
5411 InvalidCase {
5412 name: "singular-workflow",
5413 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: acme/tools/.github/workflow/lint.yml@v1\n",
5414 job: "call-lint",
5415 detail: "\"acme/tools/.github/workflow/lint.yml@v1\"",
5416 crlf: false,
5417 },
5418 InvalidCase {
5419 name: "unicode",
5420 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: \"acmé/tools/.github/workflows/lint.yml@v1\"\n",
5421 job: "call-lint",
5422 detail: "\"acmé/tools/.github/workflows/lint.yml@v1\"",
5423 crlf: false,
5424 },
5425 InvalidCase {
5426 name: "only-at",
5427 yaml: "name: Caller\non: push\njobs:\n call-lint:\n uses: \"@v1\"\n",
5428 job: "call-lint",
5429 detail: "\"@v1\"",
5430 crlf: false,
5431 },
5432 ];
5433 for case in cases {
5434 let (dir, runs) = b4b_dirs(&format!("bad-{}", case.name));
5435 write_workflow(&dir, "caller.yml", case.yaml, case.crlf, false);
5436 let caught = std::panic::catch_unwind(|| load_cached_pair(&dir, Some(&runs)));
5437 let (workflows, found) = caught.expect("invalid uses panicked");
5438 let job = job_named(&workflows, "caller.yml", case.job);
5439 assert!(job.uses.is_none(), "{}", case.name);
5440 let invalid = job.invalid_uses.as_ref().expect(case.name);
5441 assert_eq!(invalid.detail, case.detail, "{}", case.name);
5442 assert_eq!(invalid.line, uses_key_line(case.yaml), "{}", case.name);
5443 let matched: Vec<_> = found
5444 .iter()
5445 .filter(|item| {
5446 item.kind == FindingKind::InvalidUses && item.path.ends_with("caller.yml")
5447 })
5448 .collect();
5449 assert_eq!(matched.len(), 1, "{}: {found:?}", case.name);
5450 assert_eq!(matched[0].line, invalid.line, "{}", case.name);
5451 assert_eq!(matched[0].kind.code(), "F11");
5452 assert_eq!(
5453 matched[0].message,
5454 format!(
5455 "invalid reusable workflow uses: job {} uses {}",
5456 case.job, case.detail
5457 ),
5458 "{}",
5459 case.name
5460 );
5461 assert_no_limitation(&matched[0].message);
5462 assert!(
5463 of_kind(&found, FindingKind::MissingTimeout).is_empty(),
5464 "{}: {found:?}",
5465 case.name
5466 );
5467 let needle = format!(
5468 "F11 {}:{}: {}",
5469 crate::display::display_path(&matched[0].path),
5470 matched[0].line,
5471 matched[0].message
5472 );
5473 let _ = assert_costs_stay_on_caller(&found, "caller.yml", &needle);
5474 }
5475
5476 let long = format!("\"{}\"", "x".repeat(4000));
5477 let yaml = format!("name: Caller\non: push\njobs:\n call-lint:\n uses: {long}\n");
5478 let (dir, _) = b4b_dirs("bad-long");
5479 write_workflow(&dir, "caller.yml", &yaml, false, false);
5480 let caught = std::panic::catch_unwind(|| load_cached_pair(&dir, None));
5481 let (_, found) = caught.expect("long uses panicked");
5482 let matched = attributed(&found, "caller.yml", FindingKind::InvalidUses);
5483 assert_eq!(matched.len(), 1, "{found:?}");
5484 assert!(matched[0].message.contains(&"x".repeat(4000)));
5485 assert_no_limitation(&matched[0].message);
5486
5487 let nasty = [
5488 "\0\0\0",
5489 "jobs: [\n",
5490 "uses: &\n",
5491 "jobs:\n bad:\n uses: \"unterminated",
5492 "a: &a\n b: *a\n",
5493 ];
5494 for text in nasty {
5495 let parsed = std::panic::catch_unwind(|| scan_yaml(Path::new("odd.yml"), text));
5496 assert!(parsed.is_ok(), "parser panicked on {text:?}");
5497 if let Ok(Ok(workflow)) = parsed {
5498 let found = std::panic::catch_unwind(|| findings(&[workflow]));
5499 assert!(found.is_ok(), "findings panicked on {text:?}");
5500 }
5501 }
5502
5503 let (dir, _) = b4b_dirs("step-uses");
5504 write_workflow(
5505 &dir,
5506 "caller.yml",
5507 "name: Caller\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n timeout-minutes: 5\n steps:\n - uses: acme/tools/.github/workflows/lint.yml@v1\n - uses: actions/checkout@v4\n",
5508 false,
5509 false,
5510 );
5511 let (workflows, found) = load_cached_pair(&dir, None);
5512 let build = job_named(&workflows, "caller.yml", "build");
5513 assert!(build.uses.is_none());
5514 assert!(build.invalid_uses.is_none());
5515 assert!(
5516 of_kind(&found, FindingKind::InvalidUses).is_empty(),
5517 "{found:?}"
5518 );
5519 assert!(
5520 of_kind(&found, FindingKind::RemoteWorkflow).is_empty(),
5521 "{found:?}"
5522 );
5523
5524 let (dir, _) = b4b_dirs("last-key-wins");
5525 write_workflow(
5526 &dir,
5527 "caller.yml",
5528 "name: Caller\non: push\njobs:\n call-lint:\n uses: actions/checkout@v4\n uses: acme/tools/.github/workflows/lint.yml@v1\n",
5529 false,
5530 false,
5531 );
5532 let (workflows, found) = load_cached_pair(&dir, None);
5533 let call = job_named(&workflows, "caller.yml", "call-lint");
5534 assert_eq!(
5535 call.uses.as_ref().unwrap().spec,
5536 "acme/tools/.github/workflows/lint.yml@v1"
5537 );
5538 assert!(call.invalid_uses.is_none());
5539 assert_eq!(
5540 of_kind(&found, FindingKind::DuplicateKey).len(),
5541 1,
5542 "{found:?}"
5543 );
5544 let shown = attributed(&found, "caller.yml", FindingKind::RemoteWorkflow);
5545 assert_eq!(shown.len(), 1, "{found:?}");
5546 assert!(shown[0]
5547 .message
5548 .contains("acme/tools/.github/workflows/lint.yml@v1"));
5549 assert!(
5550 of_kind(&found, FindingKind::InvalidUses).is_empty(),
5551 "{found:?}"
5552 );
5553 }
5554
5555 fn b4d_root(name: &str) -> PathBuf {
5556 let root = std::env::temp_dir().join("ci-bill-b4d").join(name);
5557 let _ = fs::remove_dir_all(&root);
5558 fs::create_dir_all(root.join(".github").join("workflows")).unwrap();
5559 root
5560 }
5561
5562 fn write_at(path: &Path, text: &str, crlf_file: bool, bom: bool) {
5563 if let Some(parent) = path.parent() {
5564 fs::create_dir_all(parent).unwrap();
5565 }
5566 let mut body = if crlf_file {
5567 text.replace('\n', "\r\n")
5568 } else {
5569 text.to_string()
5570 };
5571 if bom {
5572 body.insert(0, '\u{feff}');
5573 }
5574 fs::write(path, body).unwrap();
5575 }
5576
5577 const BUILD_USES_LINT: &str = "\
5578name: Build
5579on: push
5580jobs:
5581 build:
5582 runs-on: macos-latest
5583 timeout-minutes: 10
5584 steps:
5585 - uses: ./.github/actions/lint
5586";
5587
5588 const COMPOSITE_FMT: &str = "\
5589name: Lint
5590description: Format the tree
5591runs:
5592 using: composite
5593 steps:
5594 - shell: bash
5595 run: cargo fmt --check
5596";
5597
5598 fn scan_repo(root: &Path) -> (Vec<Workflow>, Vec<Finding>) {
5599 let dir = root.join(".github").join("workflows");
5600 let workflows = scan_dir(&dir).expect("scan workflows");
5601 let found = findings(&workflows).expect("findings");
5602 (workflows, found)
5603 }
5604
5605 fn f1_on<'a>(found: &'a [Finding], suffix: &str) -> Vec<&'a Finding> {
5606 found
5607 .iter()
5608 .filter(|item| item.kind == FindingKind::LintOnRunner && item.path.ends_with(suffix))
5609 .collect()
5610 }
5611
5612 #[test]
5613 fn composite_macos_lint_uses_the_action_line() {
5614 let cases = [
5615 ("plain", "caller.yml", "action.yml", BUILD_USES_LINT, COMPOSITE_FMT, false, false, 7, "lint on macOS"),
5616 ("yaml-ext", "caller.yml", "action.yaml", BUILD_USES_LINT, COMPOSITE_FMT, false, false, 7, "lint on macOS"),
5617 ("crlf", "caller.yml", "action.yml", BUILD_USES_LINT, COMPOSITE_FMT, true, false, 7, "lint on macOS"),
5618 ("bom", "caller.yml", "action.yml", BUILD_USES_LINT, COMPOSITE_FMT, false, true, 7, "lint on macOS"),
5619 (
5620 "reordered",
5621 "caller.yml",
5622 "action.yml",
5623 "name: Build\non: push\njobs:\n build:\n timeout-minutes: 10\n steps:\n - name: Format\n uses: ./.github/actions/lint\n runs-on: macos-latest\n",
5624 "name: Lint\nruns:\n steps:\n - shell: bash\n run: cargo fmt --check\n using: composite\n",
5625 false,
5626 false,
5627 5,
5628 "lint on macOS",
5629 ),
5630 (
5631 "capitals",
5632 "caller.yml",
5633 "action.yml",
5634 "name: Build\non: push\njobs:\n build:\n runs-on: MacOS-latest\n timeout-minutes: 10\n steps:\n - USES: ./.github/actions/lint\n",
5635 "name: Lint\nRUNS:\n USING: Composite\n STEPS:\n - SHELL: bash\n RUN: cargo fmt --check\n",
5636 false,
5637 false,
5638 6,
5639 "lint on macOS",
5640 ),
5641 (
5642 "windows",
5643 "caller.yml",
5644 "action.yml",
5645 "name: Build\non: push\njobs:\n build:\n runs-on: windows-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n",
5646 COMPOSITE_FMT,
5647 false,
5648 false,
5649 7,
5650 "lint on Windows",
5651 ),
5652 (
5653 "windows-arm",
5654 "caller.yml",
5655 "action.yml",
5656 "name: Build\non: push\njobs:\n build:\n runs-on: Windows-11-arm\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n",
5657 COMPOSITE_FMT,
5658 false,
5659 false,
5660 7,
5661 "lint on Windows",
5662 ),
5663 (
5664 "macos-scalar",
5665 "caller.yml",
5666 "action.yml",
5667 "name: Build\non: push\njobs:\n build:\n runs-on: macOS\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n",
5668 COMPOSITE_FMT,
5669 false,
5670 false,
5671 7,
5672 "lint on macOS",
5673 ),
5674 (
5675 "both-os",
5676 "caller.yml",
5677 "action.yml",
5678 "name: Build\non: push\njobs:\n build:\n runs-on: [macos-latest, windows-latest]\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n",
5679 COMPOSITE_FMT,
5680 false,
5681 false,
5682 7,
5683 "lint on macOS",
5684 ),
5685 (
5686 "quoted",
5687 "caller.yml",
5688 "action.yml",
5689 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: \"./.github/actions/lint\"\n",
5690 COMPOSITE_FMT,
5691 false,
5692 false,
5693 7,
5694 "lint on macOS",
5695 ),
5696 (
5697 "single-quoted",
5698 "caller.yml",
5699 "action.yml",
5700 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: './.github/actions/lint'\n",
5701 COMPOSITE_FMT,
5702 false,
5703 false,
5704 7,
5705 "lint on macOS",
5706 ),
5707 (
5708 "backslash",
5709 "caller.yml",
5710 "action.yml",
5711 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github\\actions\\lint\n",
5712 COMPOSITE_FMT,
5713 false,
5714 false,
5715 7,
5716 "lint on macOS",
5717 ),
5718 (
5719 "dot-segments",
5720 "caller.yml",
5721 "action.yml",
5722 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/../actions/./lint\n",
5723 COMPOSITE_FMT,
5724 false,
5725 false,
5726 7,
5727 "lint on macOS",
5728 ),
5729 (
5730 "double-slash",
5731 "caller.yml",
5732 "action.yml",
5733 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github//actions/lint/\n",
5734 COMPOSITE_FMT,
5735 false,
5736 false,
5737 7,
5738 "lint on macOS",
5739 ),
5740 (
5741 "explicit-action-file",
5742 "caller.yml",
5743 "action.yml",
5744 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint/action.yml\n",
5745 COMPOSITE_FMT,
5746 false,
5747 false,
5748 7,
5749 "lint on macOS",
5750 ),
5751 (
5752 "with-inputs",
5753 "caller.yml",
5754 "action.yml",
5755 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n with:\n version: \"1\"\n",
5756 COMPOSITE_FMT,
5757 false,
5758 false,
5759 7,
5760 "lint on macOS",
5761 ),
5762 (
5763 "flow",
5764 "caller.yml",
5765 "action.yml",
5766 "on: push\njobs: {build: {runs-on: macos-latest, timeout-minutes: 10, steps: [{uses: ./.github/actions/lint}]}}\n",
5767 "name: Lint\nruns: {using: composite, steps: [{shell: bash, run: cargo fmt --check}]}\n",
5768 false,
5769 false,
5770 2,
5771 "lint on macOS",
5772 ),
5773 (
5774 "flow-crlf",
5775 "caller.yml",
5776 "action.yml",
5777 "on: push\njobs: {build: {runs-on: macos-latest, timeout-minutes: 10, steps: [{uses: ./.github/actions/lint}]}}\n",
5778 "name: Lint\nruns: {using: composite, steps: [{shell: bash, run: cargo fmt --check}]}\n",
5779 true,
5780 false,
5781 2,
5782 "lint on macOS",
5783 ),
5784 (
5785 "name-only",
5786 "caller.yml",
5787 "action.yml",
5788 BUILD_USES_LINT,
5789 "name: Lint\nruns:\n using: composite\n steps:\n - name: prettier\n shell: bash\n run: echo ok\n",
5790 false,
5791 false,
5792 5,
5793 "lint on macOS",
5794 ),
5795 (
5796 "multiline-run",
5797 "caller.yml",
5798 "action.yml",
5799 BUILD_USES_LINT,
5800 "name: Lint\nruns:\n using: composite\n steps:\n - shell: bash\n run: |\n cargo fmt --check\n cargo clippy -- -D warnings\n",
5801 false,
5802 false,
5803 6,
5804 "lint on macOS",
5805 ),
5806 (
5807 "run-wins-over-name",
5808 "caller.yml",
5809 "action.yml",
5810 BUILD_USES_LINT,
5811 "name: Lint\nruns:\n using: composite\n steps:\n - name: fmt\n run: cargo fmt --check\n shell: bash\n",
5812 false,
5813 false,
5814 6,
5815 "lint on macOS",
5816 ),
5817 ];
5818 for (name, caller, action_file, workflow, action, crlf_file, bom, line, message) in cases {
5819 let root = b4d_root(name);
5820 write_at(
5821 &root.join(".github").join("workflows").join(caller),
5822 workflow,
5823 crlf_file,
5824 false,
5825 );
5826 write_at(
5827 &root
5828 .join(".github")
5829 .join("actions")
5830 .join("lint")
5831 .join(action_file),
5832 action,
5833 crlf_file,
5834 bom,
5835 );
5836 let (workflows, found) = scan_repo(&root);
5837 let f1 = f1_on(&found, action_file);
5838 assert_eq!(
5839 f1.len(),
5840 if name == "both-os" { 2 } else { 1 },
5841 "{name}: {found:?}"
5842 );
5843 assert!(
5844 f1.iter()
5845 .any(|item| item.line == line && item.message == message),
5846 "{name}: {found:?}"
5847 );
5848 assert_eq!(
5849 f1[0].workflow_path.as_deref(),
5850 Some(".github/workflows/caller.yml"),
5851 "{name}"
5852 );
5853 assert_no_limitation(&f1[0].message);
5854 let rows = [usage(".github/workflows/caller.yml", "0.62", "0.062")];
5855 let (text, json) = report_for(&found, &rows);
5856 let section = section_after(&text, "example-org/app .github/workflows/caller.yml");
5857 assert!(
5858 section.contains(&format!("{action_file}:{line}: {message}")),
5859 "{name}: {text}"
5860 );
5861 assert!(!text.contains("Workflows without usage"), "{name}: {text}");
5862 assert_eq!(json["workflows"][0]["net"], "0.620000", "{name}");
5863 let listed = json["workflows"][0]["findings"].as_array().unwrap();
5864 assert!(
5865 listed.iter().any(|item| {
5866 item["id"] == "F1"
5867 && item["line"] == line
5868 && item["message"] == message
5869 && item["file"].as_str().unwrap().ends_with(action_file)
5870 }),
5871 "{name}: {listed:?}"
5872 );
5873 let build = job_named(&workflows, caller, "build");
5874 assert!(
5875 build.steps.iter().any(|step| step.local_action.is_some()),
5876 "{name}"
5877 );
5878 if name == "plain" {
5879 assert_eq!(build.steps[0].name, "./.github/actions/lint");
5880 assert_eq!(build.steps[0].line, 8);
5881 }
5882 }
5883 }
5884
5885 #[test]
5886 fn composite_action_file_name_variants() {
5887 let root = b4d_root("prefer-yml");
5888 let dir = root.join(".github").join("actions").join("lint");
5889 write_at(
5890 &root.join(".github/workflows/caller.yml"),
5891 BUILD_USES_LINT,
5892 false,
5893 false,
5894 );
5895 write_at(
5896 &dir.join("action.yml"),
5897 "name: Quiet\nruns:\n using: composite\n steps:\n - run: echo hi\n shell: bash\n",
5898 false,
5899 false,
5900 );
5901 write_at(&dir.join("action.yaml"), COMPOSITE_FMT, false, false);
5902 let (_, found) = scan_repo(&root);
5903 assert!(f1_on(&found, "action.yml").is_empty(), "{found:?}");
5904 assert!(f1_on(&found, "action.yaml").is_empty(), "{found:?}");
5905
5906 let root = b4d_root("capital-filename");
5907 write_at(
5908 &root.join(".github/workflows/caller.yml"),
5909 BUILD_USES_LINT,
5910 false,
5911 false,
5912 );
5913 write_at(
5914 &root.join(".github/actions/lint/Action.YML"),
5915 COMPOSITE_FMT,
5916 false,
5917 false,
5918 );
5919 let (_, found) = scan_repo(&root);
5920 let f1 = f1_on(&found, "Action.YML");
5921 assert_eq!(f1.len(), 1, "{found:?}");
5922 assert_eq!(f1[0].line, 7);
5923 assert_eq!(f1[0].message, "lint on macOS");
5924
5925 let root = b4d_root("repo-root");
5926 write_at(
5927 &root.join(".github/workflows/caller.yml"),
5928 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./\n",
5929 false,
5930 false,
5931 );
5932 write_at(&root.join("action.yml"), COMPOSITE_FMT, false, false);
5933 let (_, found) = scan_repo(&root);
5934 let f1 = f1_on(&found, "action.yml");
5935 assert_eq!(f1.len(), 1, "{found:?}");
5936 assert_eq!(f1[0].line, 7);
5937 assert!(
5938 f1[0].path.parent().unwrap() == root.as_path(),
5939 "{}",
5940 f1[0].path.display()
5941 );
5942 }
5943
5944 #[test]
5945 fn missing_local_action_is_a_finding() {
5946 let cases = [
5947 ("missing-dir", false, "caller.yml", BUILD_USES_LINT, 8, "./.github/actions/lint"),
5948 (
5949 "missing-dir-crlf",
5950 true,
5951 "caller.yml",
5952 BUILD_USES_LINT,
5953 8,
5954 "./.github/actions/lint",
5955 ),
5956 (
5957 "missing-dir-yaml",
5958 false,
5959 "caller.yaml",
5960 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./tools/lint\n",
5961 8,
5962 "./tools/lint",
5963 ),
5964 (
5965 "missing-quoted",
5966 false,
5967 "caller.yml",
5968 "name: Build\non: push\njobs:\n build:\n runs-on: MACOS-latest\n timeout-minutes: 10\n steps:\n - uses: \"./.github/actions/lint\"\n",
5969 8,
5970 "./.github/actions/lint",
5971 ),
5972 (
5973 "missing-flow",
5974 false,
5975 "caller.yml",
5976 "on: push\njobs: {build: {runs-on: macos-latest, timeout-minutes: 10, steps: [{uses: ./tools/lint}]}}\n",
5977 2,
5978 "./tools/lint",
5979 ),
5980 (
5981 "missing-backslash",
5982 false,
5983 "caller.yml",
5984 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./tools\\lint\n",
5985 8,
5986 "./tools\\lint",
5987 ),
5988 ];
5989 for (name, crlf_file, caller, workflow, line, spec) in cases {
5990 let root = b4d_root(name);
5991 write_at(
5992 &root.join(".github").join("workflows").join(caller),
5993 workflow,
5994 crlf_file,
5995 false,
5996 );
5997 let caught = std::panic::catch_unwind(|| scan_repo(&root));
5998 let (_, found) = caught.expect(name);
5999 let missing: Vec<_> = found
6000 .iter()
6001 .filter(|item| item.kind == FindingKind::ActionNotFound)
6002 .collect();
6003 assert_eq!(missing.len(), 1, "{name}: {found:?}");
6004 assert_eq!(missing[0].line, line, "{name}");
6005 assert_eq!(missing[0].kind.code(), "F12");
6006 assert_eq!(
6007 missing[0].message,
6008 format!("action not found: job build uses {spec}"),
6009 "{name}"
6010 );
6011 assert!(missing[0].path.ends_with(caller), "{name}");
6012 assert_no_limitation(&missing[0].message);
6013 assert!(f1_on(&found, "action.yml").is_empty(), "{name}");
6014 let (text, json) = report_for(&found, &[usage(&caller_key(caller), "0.06", "0.006")]);
6015 assert!(text.contains("F12 "), "{name}: {text}");
6016 assert!(text.contains(&missing[0].message), "{name}: {text}");
6017 assert!(!text.contains("Workflows without usage"), "{name}: {text}");
6018 let listed = json["workflows"][0]["findings"].as_array().unwrap();
6019 assert!(
6020 listed.iter().any(|item| item["id"] == "F12"),
6021 "{name}: {listed:?}"
6022 );
6023 }
6024
6025 let root = b4d_root("empty-dir");
6026 write_at(
6027 &root.join(".github/workflows/caller.yml"),
6028 BUILD_USES_LINT,
6029 false,
6030 false,
6031 );
6032 fs::create_dir_all(root.join(".github/actions/lint")).unwrap();
6033 let (_, found) = scan_repo(&root);
6034 let missing = of_kind(&found, FindingKind::ActionNotFound);
6035 assert_eq!(missing.len(), 1, "{found:?}");
6036 assert_eq!(missing[0].line, 8);
6037 assert!(missing[0].message.contains("./.github/actions/lint"));
6038
6039 let root = b4d_root("readme-only");
6040 write_at(
6041 &root.join(".github/workflows/caller.yml"),
6042 BUILD_USES_LINT,
6043 false,
6044 false,
6045 );
6046 write_at(
6047 &root.join(".github/actions/lint/README.md"),
6048 "not an action\n",
6049 false,
6050 false,
6051 );
6052 let (_, found) = scan_repo(&root);
6053 assert_eq!(
6054 of_kind(&found, FindingKind::ActionNotFound).len(),
6055 1,
6056 "{found:?}"
6057 );
6058
6059 let outside = scan_yaml(
6060 Path::new("fixture.yml"),
6061 "jobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 1\n steps:\n - uses: ./lint\n",
6062 )
6063 .unwrap();
6064 let found = std::panic::catch_unwind(|| findings(&[outside]));
6065 let found = found.expect("findings panicked").expect("findings");
6066 assert_eq!(
6067 of_kind(&found, FindingKind::ActionNotFound).len(),
6068 1,
6069 "{found:?}"
6070 );
6071 assert!(
6072 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6073 "{found:?}"
6074 );
6075 }
6076
6077 #[test]
6078 fn composite_without_lint_and_empty_fields_stay_quiet() {
6079 let bodies = [
6080 ("empty-file", ""),
6081 ("name-only", "name: Lint\n"),
6082 ("missing-runs", "name: Lint\ndescription: none\n"),
6083 ("missing-using", "name: Lint\nruns:\n steps:\n - run: cargo fmt --check\n"),
6084 ("empty-using", "name: Lint\nruns:\n using: \"\"\n steps:\n - run: cargo fmt --check\n"),
6085 ("using-list", "name: Lint\nruns:\n using: [composite]\n steps:\n - run: cargo fmt --check\n"),
6086 ("empty-steps", "name: Lint\nruns:\n using: composite\n steps: []\n"),
6087 ("missing-steps", "name: Lint\nruns:\n using: composite\n"),
6088 ("null-steps", "name: Lint\nruns:\n using: composite\n steps:\n"),
6089 ("empty-run", "name: Lint\nruns:\n using: composite\n steps:\n - run: \"\"\n shell: bash\n"),
6090 ("null-run", "name: Lint\nruns:\n using: composite\n steps:\n - run: ~\n name: \"\"\n"),
6091 ("checkout-step", "name: Lint\nruns:\n using: composite\n steps:\n - uses: actions/checkout@v4\n"),
6092 ("echo", "name: Lint\nruns:\n using: composite\n steps:\n - run: echo hi\n shell: bash\n"),
6093 ("spaced-using", "name: Lint\nruns:\n using: \" composite \"\n steps:\n - run: echo hi\n"),
6094 ];
6095 for (name, body) in bodies {
6096 let root = b4d_root(name);
6097 write_at(
6098 &root.join(".github/workflows/caller.yml"),
6099 BUILD_USES_LINT,
6100 false,
6101 false,
6102 );
6103 write_at(
6104 &root.join(".github/actions/lint/action.yml"),
6105 body,
6106 false,
6107 false,
6108 );
6109 let caught = std::panic::catch_unwind(|| scan_repo(&root));
6110 let (_, found) = caught.expect(name);
6111 assert!(
6112 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6113 "{name}: {found:?}"
6114 );
6115 assert!(
6116 of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6117 "{name}: {found:?}"
6118 );
6119 for item in &found {
6120 assert_no_limitation(&item.message);
6121 }
6122 }
6123
6124 let negatives = [
6125 ("ubuntu", "ubuntu-latest"),
6126 ("self-hosted", "[self-hosted, macos-latest]"),
6127 (
6128 "self-hosted-flow",
6129 "{group: large, labels: [self-hosted, macos]}",
6130 ),
6131 ];
6132 for (name, runner) in negatives {
6133 let root = b4d_root(name);
6134 let workflow = format!(
6135 "name: Build\non: push\njobs:\n build:\n runs-on: {runner}\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n"
6136 );
6137 write_at(
6138 &root.join(".github/workflows/caller.yml"),
6139 &workflow,
6140 false,
6141 false,
6142 );
6143 write_at(
6144 &root.join(".github/actions/lint/action.yml"),
6145 COMPOSITE_FMT,
6146 false,
6147 false,
6148 );
6149 let (_, found) = scan_repo(&root);
6150 assert!(
6151 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6152 "{name}: {found:?}"
6153 );
6154 }
6155
6156 let root = b4d_root("no-runner");
6157 write_at(
6158 &root.join(".github/workflows/caller.yml"),
6159 "name: Build\non: push\njobs:\n build:\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n",
6160 false,
6161 false,
6162 );
6163 write_at(
6164 &root.join(".github/actions/lint/action.yml"),
6165 COMPOSITE_FMT,
6166 false,
6167 false,
6168 );
6169 let (_, found) = scan_repo(&root);
6170 assert!(
6171 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6172 "{found:?}"
6173 );
6174 assert!(
6175 of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6176 "{found:?}"
6177 );
6178
6179 let root = b4d_root("remote-step");
6180 write_at(
6181 &root.join(".github/workflows/caller.yml"),
6182 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: actions/checkout@v4\n - uses: docker://alpine\n - uses: ./.github/workflows/lint.yml\n - uses: ./../outside\n",
6183 false,
6184 false,
6185 );
6186 let (_, found) = scan_repo(&root);
6187 assert!(
6188 of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6189 "{found:?}"
6190 );
6191 assert!(
6192 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6193 "{found:?}"
6194 );
6195 }
6196
6197 #[test]
6198 fn docker_and_node_actions_are_shown_not_unpacked() {
6199 let kinds = [
6200 "docker", "Docker", "node12", "node16", "node20", "NODE20", "node24", "node",
6201 ];
6202 for (index, using) in kinds.iter().enumerate() {
6203 let root = b4d_root(&format!("show-{index}"));
6204 write_at(
6205 &root.join(".github/workflows/caller.yml"),
6206 BUILD_USES_LINT,
6207 false,
6208 false,
6209 );
6210 let body = format!(
6211 "name: Tool\nruns:\n using: {using}\n image: Dockerfile\n main: index.js\n steps:\n - run: cargo fmt --check\n shell: bash\n - uses: ./.github/actions/inner\n"
6212 );
6213 write_at(
6214 &root.join(".github/actions/lint/action.yml"),
6215 &body,
6216 false,
6217 false,
6218 );
6219 write_at(
6220 &root.join(".github/actions/lint/Dockerfile"),
6221 "FROM alpine\nRUN cargo fmt --check\n",
6222 false,
6223 false,
6224 );
6225 write_at(
6226 &root.join(".github/actions/inner/action.yml"),
6227 COMPOSITE_FMT,
6228 false,
6229 false,
6230 );
6231 let (workflows, found) = scan_repo(&root);
6232 assert!(
6233 of_kind(&found, FindingKind::LintOnRunner).is_empty(),
6234 "{using}: {found:?}"
6235 );
6236 assert!(
6237 of_kind(&found, FindingKind::ActionNotFound).is_empty(),
6238 "{using}: {found:?}"
6239 );
6240 let step = &job_named(&workflows, "caller.yml", "build").steps[0];
6241 assert_eq!(step.name, "./.github/actions/lint");
6242 assert!(step.local_action.is_some(), "{using}");
6243 let rows = [usage(".github/workflows/caller.yml", "0.06", "0.006")];
6244 let ranking = rank(&rows, None).unwrap();
6245 let json: serde_json::Value = serde_json::from_str(
6246 render_json(
6247 &ranking.workflows,
6248 &ranking.bill,
6249 Some(&found),
6250 None,
6251 None,
6252 &workflows,
6253 )
6254 .trim_end(),
6255 )
6256 .unwrap();
6257 assert_eq!(
6258 json["workflows"][0]["steps"][0]["name"],
6259 "./.github/actions/lint"
6260 );
6261 assert_eq!(json["workflows"][0]["steps"][0]["line"], 8);
6262 assert!(json["workflows"][0]["findings"]
6263 .as_array()
6264 .unwrap()
6265 .is_empty());
6266 }
6267 }
6268
6269 #[test]
6270 fn nested_composite_lint_points_at_the_inner_action() {
6271 let root = b4d_root("nested");
6272 write_at(
6273 &root.join(".github/workflows/caller.yml"),
6274 BUILD_USES_LINT,
6275 false,
6276 false,
6277 );
6278 write_at(
6279 &root.join(".github/actions/lint/action.yml"),
6280 "name: Outer\nruns:\n using: composite\n steps:\n - uses: ./.github/actions/inner\n",
6281 false,
6282 false,
6283 );
6284 write_at(
6285 &root.join(".github/actions/inner/action.yml"),
6286 "name: Inner\nruns:\n using: composite\n steps:\n - run: cargo fmt --check\n shell: bash\n",
6287 false,
6288 false,
6289 );
6290 let (_, found) = scan_repo(&root);
6291 let f1 = f1_on(&found, "inner/action.yml");
6292 assert_eq!(f1.len(), 1, "{found:?}");
6293 assert_eq!(f1[0].line, 5);
6294 assert_eq!(f1[0].message, "lint on macOS");
6295 assert_eq!(
6296 f1[0].workflow_path.as_deref(),
6297 Some(".github/workflows/caller.yml")
6298 );
6299 let (text, _) = report_for(
6300 &found,
6301 &[usage(".github/workflows/caller.yml", "0.06", "0.006")],
6302 );
6303 assert!(text.contains("inner/action.yml:5: lint on macOS"), "{text}");
6305 assert!(!text.contains("Workflows without usage"), "{text}");
6306
6307 let root = b4d_root("nested-missing");
6308 write_at(
6309 &root.join(".github/workflows/caller.yml"),
6310 BUILD_USES_LINT,
6311 false,
6312 false,
6313 );
6314 write_at(
6315 &root.join(".github/actions/lint/action.yml"),
6316 "name: Outer\nruns:\n using: composite\n steps:\n - uses: ./tools/missing\n",
6317 false,
6318 false,
6319 );
6320 let (_, found) = scan_repo(&root);
6321 let missing = of_kind(&found, FindingKind::ActionNotFound);
6322 assert_eq!(missing.len(), 1, "{found:?}");
6323 assert!(
6324 missing[0].path.ends_with("lint/action.yml"),
6325 "{}",
6326 missing[0].path.display()
6327 );
6328 assert_eq!(missing[0].line, 5);
6329 assert_eq!(
6330 missing[0].message,
6331 "action not found: job build uses ./tools/missing"
6332 );
6333
6334 let root = b4d_root("cycle");
6335 write_at(
6336 &root.join(".github/workflows/caller.yml"),
6337 "name: Build\non: push\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/a\n",
6338 false,
6339 false,
6340 );
6341 write_at(
6342 &root.join(".github/actions/a/action.yml"),
6343 "name: A\nruns:\n using: composite\n steps:\n - uses: ./.github/actions/b\n",
6344 false,
6345 false,
6346 );
6347 write_at(
6348 &root.join(".github/actions/b/action.yml"),
6349 "name: B\nruns:\n using: composite\n steps:\n - run: echo hi\n shell: bash\n - uses: ./.github/actions/a\n",
6350 false,
6351 false,
6352 );
6353 let caught = std::panic::catch_unwind(|| scan_repo(&root));
6354 let (_, found) = caught.expect("cycle panicked");
6355 let cycles = of_kind(&found, FindingKind::ActionCycle);
6356 assert_eq!(cycles.len(), 1, "{found:?}");
6357 assert_eq!(cycles[0].kind.code(), "F13");
6358 assert!(
6359 cycles[0].path.ends_with("b/action.yml"),
6360 "{}",
6361 cycles[0].path.display()
6362 );
6363 assert_eq!(cycles[0].line, 7);
6364 assert_eq!(
6365 cycles[0].message,
6366 "composite action cycle: ./.github/actions/a -> ./.github/actions/b -> ./.github/actions/a"
6367 );
6368 assert_no_limitation(&cycles[0].message);
6369 }
6370
6371 #[test]
6372 fn invalid_action_yaml_is_an_error_without_the_body() {
6373 let root = b4d_root("bad-yaml");
6374 write_at(
6375 &root.join(".github/workflows/caller.yml"),
6376 BUILD_USES_LINT,
6377 false,
6378 false,
6379 );
6380 let token = "TOKEN_NOT_IN_ERRORS";
6381 write_at(
6382 &root.join(".github/actions/lint/action.yml"),
6383 &format!("runs: [\n{token}\n"),
6384 false,
6385 false,
6386 );
6387 let dir = root.join(".github/workflows");
6388 let workflows = scan_dir(&dir).unwrap();
6389 let err = findings(&workflows).expect_err("invalid action yaml");
6390 let text = err.to_string();
6391 assert!(text.contains("invalid YAML"), "{text}");
6392 assert!(!text.contains(token), "{text}");
6393 assert!(err.path.ends_with("action.yml"), "{}", err.path.display());
6394 }
6395
6396 #[test]
6397 fn composite_matrix_and_callee_keep_the_action_line() {
6398 let root = b4d_root("matrix");
6399 write_at(
6400 &root.join(".github/workflows/caller.yml"),
6401 "name: Build\non: push\njobs:\n build:\n runs-on: ${{ matrix.os }}\n timeout-minutes: 10\n strategy:\n matrix:\n os: [ubuntu-latest, macos-latest]\n steps:\n - uses: ./.github/actions/lint\n",
6402 false,
6403 false,
6404 );
6405 write_at(
6406 &root.join(".github/actions/lint/action.yml"),
6407 COMPOSITE_FMT,
6408 false,
6409 false,
6410 );
6411 let (_, found) = scan_repo(&root);
6412 let f1 = f1_on(&found, "action.yml");
6413 assert_eq!(f1.len(), 1, "{found:?}");
6414 assert_eq!(f1[0].line, 7);
6415 assert_eq!(
6416 f1[0].message,
6417 "lint on macOS (runs-on line 5, axis os line 9)"
6418 );
6419
6420 let root = b4d_root("two-steps");
6421 write_at(
6422 &root.join(".github/workflows/caller.yml"),
6423 BUILD_USES_LINT,
6424 false,
6425 false,
6426 );
6427 write_at(
6428 &root.join(".github/actions/lint/action.yml"),
6429 "name: Lint\nruns:\n using: composite\n steps:\n - run: cargo fmt --check\n shell: bash\n - name: eslint\n run: echo ok\n shell: bash\n",
6430 false,
6431 false,
6432 );
6433 let (_, found) = scan_repo(&root);
6434 let f1 = f1_on(&found, "action.yml");
6435 assert_eq!(f1.len(), 2, "{found:?}");
6436 assert!(f1.iter().any(|item| item.line == 5), "{found:?}");
6437 assert!(f1.iter().any(|item| item.line == 7), "{found:?}");
6438
6439 let root = b4d_root("callee");
6440 write_at(
6441 &root.join(".github/workflows/caller.yml"),
6442 "name: Caller\non: push\njobs:\n call:\n uses: ./.github/workflows/called.yml\n",
6443 false,
6444 false,
6445 );
6446 write_at(
6447 &root.join(".github/workflows/called.yml"),
6448 "name: Called\non: workflow_call\njobs:\n build:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n",
6449 false,
6450 false,
6451 );
6452 write_at(
6453 &root.join(".github/actions/lint/action.yml"),
6454 COMPOSITE_FMT,
6455 false,
6456 false,
6457 );
6458 let (_, found) = scan_repo(&root);
6459 let on_caller = attributed(&found, "caller.yml", FindingKind::LintOnRunner);
6460 assert_eq!(on_caller.len(), 1, "{found:?}");
6461 assert_eq!(on_caller[0].line, 7);
6462 assert!(on_caller[0].path.ends_with("action.yml"));
6463 assert_eq!(on_caller[0].message, "lint on macOS");
6464 let rows = [
6465 usage(".github/workflows/caller.yml", "0.06", "0.006"),
6466 usage(".github/workflows/called.yml", "1.00", "0.100"),
6467 ];
6468 let (text, _) = report_for(&found, &rows);
6469 let caller_at = text
6470 .find("example-org/app .github/workflows/caller.yml")
6471 .unwrap();
6472 assert!(
6473 text[caller_at..].contains("action.yml:7: lint on macOS"),
6474 "{text}"
6475 );
6476 assert!(text[..caller_at].contains("0.06") || text[caller_at..].contains("0.06"));
6477 }
6478
6479 #[test]
6480 fn job_lint_and_composite_lint_are_both_reported() {
6481 let root = b4d_root("both-signals");
6482 write_at(
6483 &root.join(".github/workflows/caller.yml"),
6484 "name: Build\non: push\njobs:\n lint:\n runs-on: macos-latest\n timeout-minutes: 10\n steps:\n - uses: ./.github/actions/lint\n",
6485 false,
6486 false,
6487 );
6488 write_at(
6489 &root.join(".github/actions/lint/action.yml"),
6490 COMPOSITE_FMT,
6491 false,
6492 false,
6493 );
6494 let (_, found) = scan_repo(&root);
6495 let f1 = of_kind(&found, FindingKind::LintOnRunner);
6496 assert_eq!(f1.len(), 2, "{found:?}");
6497 assert!(f1
6498 .iter()
6499 .any(|item| item.path.ends_with("caller.yml") && item.line == 5));
6500 assert!(f1
6501 .iter()
6502 .any(|item| item.path.ends_with("action.yml") && item.line == 7));
6503 }
6504}