File Coverage

blib/lib/App/GHGen/Analyzer.pm
Criterion Covered Total %
statement 104 136 76.4
branch 31 72 43.0
condition 8 28 28.5
subroutine 14 17 82.3
pod 3 11 27.2
total 160 264 60.6


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