File Coverage

File:blib/lib/App/GHGen/Analyzer.pm
Coverage:67.5%

linestmtbrancondsubtimecode
1package App::GHGen::Analyzer;
2
3
2
2
149863
3
use v5.36;
4
2
2
2
4
3
35
use warnings;
5
2
2
2
4
2
18
use strict;
6
7
2
2
2
328
2337
51
use YAML::XS qw(LoadFile);
8
2
2
2
4
0
32
use Path::Tiny;
9
10
2
2
2
2
2
1655
use Exporter 'import';
11our @EXPORT_OK = qw(
12        analyze_workflow
13        find_workflows
14        get_cache_suggestion
15);
16
17our $VERSION = '0.05';
18
19 - 36
=head1 NAME

App::GHGen::Analyzer - Analyze GitHub Actions workflows

=head1 SYNOPSIS

    use App::GHGen::Analyzer qw(analyze_workflow);

    my @issues = analyze_workflow($workflow_hashref, 'ci.yml');

=head1 FUNCTIONS

=head2 find_workflows()

Find all workflow files in .github/workflows directory.
Returns a list of Path::Tiny objects.

=cut
37
38
0
0
0
0
sub find_workflows() {
39
0
0
        my $workflows_dir = path('.github/workflows');
40
41
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 - 51
=head2 analyze_workflow($workflow, $filename)

Analyze a workflow hash for issues. Returns array of issue hashes.

Each issue has: type, severity, message, fix (optional)

=cut
52
53
4
4
4
4
3997
2
3
3
sub analyze_workflow($workflow, $filename) {
54
4
1
        my @issues;
55
56    # Check 1: Missing dependency caching
57
4
5
    unless (has_caching($workflow)) {
58
3
3
        my $cache_suggestion = get_cache_suggestion($workflow);
59
3
6
        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
3
    my @unpinned = find_unpinned_actions($workflow);
69
4
6
    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
0
0
0
0
                   join("\n", map { "       $_" } map { s/\@(master|main)$/\@v5/r } @unpinned[0..min(2, $#unpinned)])
76        };
77    }
78
79    # Check for outdated action versions
80
4
3
    my @outdated = find_outdated_actions($workflow);
81
4
3
    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
0
0
                   join("\n", map { "       $_" } @outdated[0..min(2, $#outdated)])
88        };
89    }
90
91    # Check 3: Overly broad triggers
92
4
3
    if (has_broad_triggers($workflow)) {
93
3
4
        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
3
    unless ($workflow->{concurrency}) {
109
3
4
        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
3
    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
3
my $jobs = $workflow->{jobs} // {};
132
4
3
for my $job_name (keys %$jobs) {
133
4
3
    my $job = $jobs->{$job_name};
134
135
4
4
    unless (exists $job->{'timeout-minutes'}) {
136
3
6
        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
13
        return @issues;
146}
147
148 - 152
=head2 get_cache_suggestion($workflow)

Generate a caching suggestion based on detected project type.

=cut
153
154
3
3
3
2
1
2
sub get_cache_suggestion($workflow) {
155
3
3
        my $detected_type = detect_project_type({ jobs => $workflow->{jobs} });
156
157
3
8
    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
5
    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
4
2
4
3
sub has_caching($workflow) {
197
4
4
        my $jobs = $workflow->{jobs} or return 0;
198
199
4
5
    for my $job (values %$jobs) {
200
4
3
        my $steps = $job->{steps} or next;
201
4
2
        for my $step (@$steps) {
202
8
17
            return 1 if $step->{uses} && $step->{uses} =~ /actions\/cache/;
203        }
204    }
205
3
2
    return 0;
206}
207
208
4
4
4
3
1
4
sub find_unpinned_actions($workflow) {
209
4
0
        my @unpinned;
210
4
4
        my $jobs = $workflow->{jobs} or return @unpinned;
211
212
4
4
    for my $job (values %$jobs) {
213
4
2
        my $steps = $job->{steps} or next;
214
4
5
        for my $step (@$steps) {
215
9
8
            next unless $step->{uses};
216
5
9
            if ($step->{uses} =~ /\@(master|main)$/) {
217
0
0
                push @unpinned, $step->{uses};
218            }
219        }
220    }
221
4
3
    return @unpinned;
222}
223
224
4
4
4
2
2
2
sub has_broad_triggers($workflow) {
225
4
3
        my $on = $workflow->{on};
226
4
19
        return 0 unless $on;
227
228    # Check if push trigger has no path or branch filters
229
4
7
    if (ref $on eq 'HASH' && $on->{push}) {
230
4
2
        my $push = $on->{push};
231
4
14
        return 1 if ref $push eq '' || (!$push->{paths} && !$push->{branches});
232    }
233
234    # Simple array of triggers including 'push'
235
1
0
2
0
    if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) {
236
0
0
        return 1;
237    }
238
239
1
1
    return 0;
240}
241
242
4
4
4
2
3
1
sub has_outdated_runners($workflow) {
243
4
3
        my $jobs = $workflow->{jobs} or return 0;
244
245
4
4
    for my $job (values %$jobs) {
246
4
4
        my $runs_on = $job->{'runs-on'} or next;
247
4
7
        return 1 if $runs_on =~ /ubuntu-18\.04|ubuntu-16\.04|macos-10\.15/;
248    }
249
4
4
    return 0;
250}
251
252
3
3
3
2
1
2
sub detect_project_type($workflow) {
253
3
4
        return 'unknown' unless ref $workflow eq 'HASH';
254
255
3
2
        my $jobs = $workflow->{jobs} or return 'unknown';
256
257
3
3
    for my $job (values %$jobs) {
258
3
3
        my $steps = $job->{steps} or next;
259
3
5
        for my $step (@$steps) {
260
6
13
            my $run = $step->{run} // '';
261
6
7
            return 'npm' if $run =~ /npm (install|ci)/;
262
6
4
            return 'pip' if $run =~ /pip install/;
263
6
4
            return 'cargo' if $run =~ /cargo (build|test)/;
264
6
5
            return 'bundler' if $run =~ /bundle install/;
265        }
266    }
267
3
2
    return 'unknown';
268}
269
270
0
0
0
0
0
0
0
0
sub min($a, $b) {
271
0
0
    return $a < $b ? $a : $b;
272}
273
274
4
4
4
3
1
5
sub find_outdated_actions($workflow) {
275
4
3
        my @outdated;
276
4
3
        my $jobs = $workflow->{jobs} or return @outdated;
277
278    # Known outdated versions
279
4
8
    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
4
    for my $job (values %$jobs) {
291
4
2
        my $steps = $job->{steps} or next;
292
4
2
        for my $step (@$steps) {
293
9
8
            next unless $step->{uses};
294
5
2
            my $uses = $step->{uses};
295
296
5
7
            for my $old (keys %updates) {
297
40
141
                if ($uses =~ /^\Q$old\E/) {
298
0
0
                    push @outdated, "$old → $updates{$old}";
299                }
300            }
301        }
302    }
303
304
4
8
    return @outdated;
305}
306
307
0
0
0
sub has_deployment_steps($workflow) {
308
0
        my $jobs = $workflow->{jobs} or return 0;
309
310
0
    for my $job (values %$jobs) {
311
0
        my $steps = $job->{steps} or next;
312
0
        for my $step (@$steps) {
313            # Check for deployment-related actions
314
0
            return 1 if $step->{uses} && $step->{uses} =~ /deploy|publish|release/i;
315
0
            return 1 if $step->{run} && $step->{run} =~ /git push|npm publish/;
316        }
317    }
318
319
0
        return 0;
320}
321
322 - 334
=head1 AUTHOR

Nigel Horne E<lt>njh@nigelhorne.comE<gt>

L<https://github.com/nigelhorne>

=head1 LICENCE

Copyright 2025-2026 Nigel Horne.

Usage is subject to license terms.

=cut
335
3361;