File Coverage

blib/lib/App/GHGen/Analyzer.pm
Criterion Covered Total %
statement 112 142 78.8
branch 40 76 52.6
condition 13 30 43.3
subroutine 14 17 82.3
pod 3 11 27.2
total 182 276 65.9


line stmt bran cond sub pod time code
1             package App::GHGen::Analyzer;
2              
3 2     2   454485 use v5.36;
  2         17  
4 2     2   14 use warnings;
  2         3  
  2         146  
5 2     2   12 use strict;
  2         3  
  2         64  
6              
7 2     2   1163 use YAML::XS qw(LoadFile);
  2         7866  
  2         193  
8 2     2   18 use Path::Tiny;
  2         5  
  2         118  
9              
10 2     2   12 use Exporter 'import';
  2         5  
  2         5314  
11             our @EXPORT_OK = qw(
12             analyze_workflow
13             find_workflows
14             get_cache_suggestion
15             );
16              
17             our $VERSION = '0.05';
18              
19             =head1 NAME
20              
21             App::GHGen::Analyzer - Analyze GitHub Actions workflows
22              
23             =head1 SYNOPSIS
24              
25             use App::GHGen::Analyzer qw(analyze_workflow);
26              
27             my @issues = analyze_workflow($workflow_hashref, 'ci.yml');
28              
29             =head1 FUNCTIONS
30              
31             =head2 find_workflows()
32              
33             Find all workflow files in .github/workflows directory.
34             Returns a list of Path::Tiny objects.
35              
36             =cut
37              
38 0     0 1 0 sub find_workflows() {
  0         0  
39 0         0 my $workflows_dir = path('.github/workflows');
40              
41 0 0 0     0 return () unless $workflows_dir->exists && $workflows_dir->is_dir;
42 0         0 return sort $workflows_dir->children(qr/\.ya?ml$/i);
43             }
44              
45             =head2 analyze_workflow($workflow, $filename)
46              
47             Analyze a workflow hash for issues. Returns array of issue hashes.
48              
49             Each issue has: type, severity, message, fix (optional)
50              
51             =cut
52              
53 4     4 1 16810 sub analyze_workflow($workflow, $filename) {
  4         10  
  4         9  
  4         7  
54 4         7 my @issues;
55              
56             # Check 1: Missing dependency caching
57 4 100       14 unless (has_caching($workflow)) {
58 3         11 my $cache_suggestion = get_cache_suggestion($workflow);
59 3         19 push @issues, {
60             type => 'performance',
61             severity => 'medium',
62             message => 'No dependency caching found - increases build times and costs',
63             fix => $cache_suggestion
64             };
65             }
66              
67             # Check 2: Using unpinned action versions
68 4         14 my @unpinned = find_unpinned_actions($workflow);
69 4 50       19 if (@unpinned) {
70             push @issues, {
71             type => 'security',
72             severity => 'high',
73             message => 'Found ' . scalar(@unpinned) . " action(s) using \@master or \@main",
74             fix => "Replace \@master/\@main with specific version tags:\n" .
75 0         0 join("\n", map { " $_" } map { s/\@(master|main)$/\@v5/r } @unpinned[0..min(2, $#unpinned)])
  0         0  
  0         0  
76             };
77             }
78              
79             # Check for outdated action versions
80 4         14 my @outdated = find_outdated_actions($workflow);
81 4 50       10 if (@outdated) {
82             push @issues, {
83             type => 'maintenance',
84             severity => 'medium',
85             message => "Found " . scalar(@outdated) . " outdated action(s)",
86             fix => "Update to latest versions:\n" .
87 0         0 join("\n", map { " $_" } @outdated[0..min(2, $#outdated)])
  0         0  
88             };
89             }
90              
91             # Check 3: Overly broad triggers
92 4 100       11 if (has_broad_triggers($workflow)) {
93 3         17 push @issues, {
94             type => 'cost',
95             severity => 'medium',
96             message => 'Workflow triggers on all pushes - consider path/branch filters',
97             fix => "Add trigger filters:\n" .
98             " on:\n" .
99             " push:\n" .
100             " branches: [main, develop]\n" .
101             " paths:\n" .
102             " - 'src/**'\n" .
103             " - 'package.json'"
104             };
105             }
106              
107             # Check 4: Missing concurrency controls
108 4 100       20 unless ($workflow->{concurrency}) {
109 3         11 push @issues, {
110             type => 'cost',
111             severity => 'low',
112             message => 'No concurrency group - old runs continue when superseded',
113             fix => "Add concurrency control:\n" .
114             " concurrency:\n" .
115             " group: \${{ github.workflow }}-\${{ github.ref }}\n" .
116             " cancel-in-progress: true"
117             };
118             }
119              
120             # Check 5: Outdated runner versions
121 4 50       10 if (has_outdated_runners($workflow)) {
122 0         0 push @issues, {
123             type => 'maintenance',
124             severity => 'low',
125             message => 'Using older runner versions - consider updating',
126             fix => 'Update to ubuntu-latest, macos-latest, or windows-latest'
127             };
128             }
129              
130             # Check 6: Missing timeout-minutes
131 4   50     22 my $jobs = $workflow->{jobs} // {};
132 4         11 for my $job_name (keys %$jobs) {
133 4         7 my $job = $jobs->{$job_name};
134              
135 4 100       14 unless (exists $job->{'timeout-minutes'}) {
136 3         31 push @issues, {
137             type => 'performance',
138             severity => 'low',
139             message => "Job '$job_name' is missing timeout-minutes",
140             fix => "Add:\n timeout-minutes: 30",
141             };
142             }
143             }
144              
145 4         21 return @issues;
146             }
147              
148             =head2 get_cache_suggestion($workflow)
149              
150             Generate a caching suggestion based on detected project type.
151              
152             =cut
153              
154 3     3 1 5 sub get_cache_suggestion($workflow) {
  3         6  
  3         4  
155 3         14 my $detected_type = detect_project_type({ jobs => $workflow->{jobs} });
156              
157 3         22 my %cache_configs = (
158             npm => "- uses: actions/cache\@v5\n" .
159             " with:\n" .
160             " path: ~/.npm\n" .
161             " key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}\n" .
162             " restore-keys: |\n" .
163             " \${{ runner.os }}-node-",
164              
165             pip => "- uses: actions/cache\@v5\n" .
166             " with:\n" .
167             " path: ~/.cache/pip\n" .
168             " key: \${{ runner.os }}-pip-\${{ hashFiles('**/requirements.txt') }}\n" .
169             " restore-keys: |\n" .
170             " \${{ runner.os }}-pip-",
171              
172             cargo => "- uses: actions/cache\@v5\n" .
173             " with:\n" .
174             " path: |\n" .
175             " ~/.cargo/bin/\n" .
176             " ~/.cargo/registry/index/\n" .
177             " ~/.cargo/registry/cache/\n" .
178             " target/\n" .
179             " key: \${{ runner.os }}-cargo-\${{ hashFiles('**/Cargo.lock') }}",
180              
181             bundler => "- uses: actions/cache\@v5\n" .
182             " with:\n" .
183             " path: vendor/bundle\n" .
184             " key: \${{ runner.os }}-gems-\${{ hashFiles('**/Gemfile.lock') }}\n" .
185             " restore-keys: |\n" .
186             " \${{ runner.os }}-gems-",
187             );
188              
189 3   50     20 return $cache_configs{$detected_type} //
190             "Add caching based on your dependency manager:\n" .
191             " See: https://docs.github.com/en/actions/using-workflows/caching-dependencies";
192             }
193              
194             # Helper functions
195              
196 4     4 0 8 sub has_caching($workflow) {
  4         28  
  4         8  
197 4 50       18 my $jobs = $workflow->{jobs} or return 0;
198              
199 4         15 for my $job (values %$jobs) {
200 4 50       14 my $steps = $job->{steps} or next;
201 4         9 for my $step (@$steps) {
202 8 100 100     51 return 1 if $step->{uses} && $step->{uses} =~ /actions\/cache/;
203             }
204             }
205 3         10 return 0;
206             }
207              
208 4     4 0 15 sub find_unpinned_actions($workflow) {
  4         8  
  4         6  
209 4         7 my @unpinned;
210 4 50       15 my $jobs = $workflow->{jobs} or return @unpinned;
211              
212 4         11 for my $job (values %$jobs) {
213 4 50       23 my $steps = $job->{steps} or next;
214 4         10 for my $step (@$steps) {
215 9 100       26 next unless $step->{uses};
216 5 50       19 if ($step->{uses} =~ /\@(master|main)$/) {
217 0         0 push @unpinned, $step->{uses};
218             }
219             }
220             }
221 4         9 return @unpinned;
222             }
223              
224 4     4 0 9 sub has_broad_triggers($workflow) {
  4         6  
  4         8  
225 4         9 my $on = $workflow->{on};
226 4 50       74 return 0 unless $on;
227              
228             # Check if push trigger has no path or branch filters
229 4 50 33     48 if (ref $on eq 'HASH' && $on->{push}) {
230 4         10 my $push = $on->{push};
231 4 100 66     40 return 1 if ref $push eq '' || (!$push->{paths} && !$push->{branches});
      66        
232             }
233              
234             # Simple array of triggers including 'push'
235 1 50 33     6 if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) {
  0         0  
236 0         0 return 1;
237             }
238              
239 1         3 return 0;
240             }
241              
242 4     4 0 9 sub has_outdated_runners($workflow) {
  4         6  
  4         7  
243 4 50       13 my $jobs = $workflow->{jobs} or return 0;
244              
245 4         27 for my $job (values %$jobs) {
246 4 50       16 my $runs_on = $job->{'runs-on'} or next;
247 4 50       26 return 1 if $runs_on =~ /ubuntu-18\.04|ubuntu-16\.04|macos-10\.15/;
248             }
249 4         13 return 0;
250             }
251              
252 3     3 0 5 sub detect_project_type($workflow) {
  3         6  
  3         5  
253 3 50       13 return 'unknown' unless ref $workflow eq 'HASH';
254              
255 3 50       10 my $jobs = $workflow->{jobs} or return 'unknown';
256              
257 3         9 for my $job (values %$jobs) {
258 3 50       10 my $steps = $job->{steps} or next;
259 3         6 for my $step (@$steps) {
260 6   100     26 my $run = $step->{run} // '';
261 6 50       22 return 'npm' if $run =~ /npm (install|ci)/;
262 6 50       16 return 'pip' if $run =~ /pip install/;
263 6 50       14 return 'cargo' if $run =~ /cargo (build|test)/;
264 6 50       49 return 'bundler' if $run =~ /bundle install/;
265             }
266             }
267 3         9 return 'unknown';
268             }
269              
270 0     0 0 0 sub min($a, $b) {
  0         0  
  0         0  
  0         0  
271 0 0       0 return $a < $b ? $a : $b;
272             }
273              
274 4     4 0 7 sub find_outdated_actions($workflow) {
  4         7  
  4         7  
275 4         7 my @outdated;
276 4 50       13 my $jobs = $workflow->{jobs} or return @outdated;
277              
278             # Known outdated versions
279 4         34 my %updates = (
280             'actions/cache@v4' => 'actions/cache@v5',
281             'actions/cache@v3' => 'actions/cache@v5',
282             'actions/checkout@v5' => 'actions/checkout@v6',
283             'actions/checkout@v4' => 'actions/checkout@v6',
284             'actions/checkout@v3' => 'actions/checkout@v6',
285             'actions/setup-node@v3' => 'actions/setup-node@v4',
286             'actions/setup-python@v4' => 'actions/setup-python@v5',
287             'actions/setup-go@v4' => 'actions/setup-go@v5',
288             );
289              
290 4         10 for my $job (values %$jobs) {
291 4 50       24 my $steps = $job->{steps} or next;
292 4         8 for my $step (@$steps) {
293 9 100       28 next unless $step->{uses};
294 5         11 my $uses = $step->{uses};
295              
296 5         16 for my $old (keys %updates) {
297 40 50       480 if ($uses =~ /^\Q$old\E/) {
298 0         0 push @outdated, "$old → $updates{$old}";
299             }
300             }
301             }
302             }
303              
304 4         22 return @outdated;
305             }
306              
307 0     0 0   sub has_deployment_steps($workflow) {
  0            
  0            
308 0 0         my $jobs = $workflow->{jobs} or return 0;
309              
310 0           for my $job (values %$jobs) {
311 0 0         my $steps = $job->{steps} or next;
312 0           for my $step (@$steps) {
313             # Check for deployment-related actions
314 0 0 0       return 1 if $step->{uses} && $step->{uses} =~ /deploy|publish|release/i;
315 0 0 0       return 1 if $step->{run} && $step->{run} =~ /git push|npm publish/;
316             }
317             }
318              
319 0           return 0;
320             }
321              
322             =head1 AUTHOR
323              
324             Nigel Horne Enjh@nigelhorne.comE
325              
326             L
327              
328             =head1 LICENCE
329              
330             Copyright 2025-2026 Nigel Horne.
331              
332             Usage is subject to license terms.
333              
334             =cut
335              
336             1;