File Coverage

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

linestmtbrancondsubtimecode
1package App::GHGen::Analyzer;
2
3
1
1
449
1
use v5.36;
4
1
1
1
2
1
17
use warnings;
5
1
1
1
2
0
15
use strict;
6
7
1
1
1
163
1172
26
use YAML::XS qw(LoadFile);
8
1
1
1
2
1
16
use Path::Tiny;
9
10
1
1
1
1
0
771
use Exporter 'import';
11our @EXPORT_OK = qw(
12        analyze_workflow
13        find_workflows
14        get_cache_suggestion
15);
16
17our $VERSION = '0.03';
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
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 - 50
=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
51
52
1
1
1
1
551
1
1
0
sub analyze_workflow($workflow, $filename) {
53
1
1
        my @issues;
54
55    # Check 1: Missing dependency caching
56
1
1
    unless (has_caching($workflow)) {
57
1
1
        my $cache_suggestion = get_cache_suggestion($workflow);
58
1
2
        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
1
    my @unpinned = find_unpinned_actions($workflow);
68
1
1
    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
0
0
0
0
                   join("\n", map { "       $_" } map { s/\@(master|main)$/\@v5/r } @unpinned[0..min(2, $#unpinned)])
75        };
76    }
77
78    # Check for outdated action versions
79
1
1
    my @outdated = find_outdated_actions($workflow);
80
1
1
    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
0
0
                   join("\n", map { "       $_" } @outdated[0..min(2, $#outdated)])
87        };
88    }
89
90    # Check 3: Overly broad triggers
91
1
1
    if (has_broad_triggers($workflow)) {
92
1
2
        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
1
    unless ($workflow->{concurrency}) {
108
1
1
        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
1
    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
2
    return @issues;
130}
131
132 - 136
=head2 get_cache_suggestion($workflow)

Generate a caching suggestion based on detected project type.

=cut
137
138
1
1
1
1
0
1
sub get_cache_suggestion($workflow) {
139
1
0
        my $detected_type = detect_project_type($workflow);
140
141
1
6
    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
3
    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
1
1
1
0
sub has_caching($workflow) {
181
1
2
        my $jobs = $workflow->{jobs} or return 0;
182
183
1
1
    for my $job (values %$jobs) {
184
1
1
        my $steps = $job->{steps} or next;
185
1
1
        for my $step (@$steps) {
186
2
4
            return 1 if $step->{uses} && $step->{uses} =~ /actions\/cache/;
187        }
188    }
189
1
2
    return 0;
190}
191
192
1
1
1
1
1
0
sub find_unpinned_actions($workflow) {
193
1
1
        my @unpinned;
194
1
2
        my $jobs = $workflow->{jobs} or return @unpinned;
195
196
1
0
    for my $job (values %$jobs) {
197
1
1
        my $steps = $job->{steps} or next;
198
1
1
        for my $step (@$steps) {
199
2
2
            next unless $step->{uses};
200
1
1
            if ($step->{uses} =~ /\@(master|main)$/) {
201
0
0
                push @unpinned, $step->{uses};
202            }
203        }
204    }
205
1
1
    return @unpinned;
206}
207
208
1
1
1
1
0
1
sub has_broad_triggers($workflow) {
209
1
1
        my $on = $workflow->{on};
210
1
1
        return 0 unless $on;
211
212    # Check if push trigger has no path or branch filters
213
1
4
    if (ref $on eq 'HASH' && $on->{push}) {
214
1
14
        my $push = $on->{push};
215
1
5
        return 1 if ref $push eq '' || (!$push->{paths} && !$push->{branches});
216    }
217
218    # Simple array of triggers including 'push'
219
0
0
0
0
    if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) {
220
0
0
        return 1;
221    }
222
223
0
0
    return 0;
224}
225
226
1
1
1
1
1
0
sub has_outdated_runners($workflow) {
227
1
1
        my $jobs = $workflow->{jobs} or return 0;
228
229
1
1
    for my $job (values %$jobs) {
230
1
1
        my $runs_on = $job->{'runs-on'} or next;
231
1
2
        return 1 if $runs_on =~ /ubuntu-18\.04|ubuntu-16\.04|macos-10\.15/;
232    }
233
1
1
    return 0;
234}
235
236
1
1
1
1
1
0
sub detect_project_type($workflow) {
237
1
1
        my $jobs = $workflow->{jobs} or return 'unknown';
238
239
1
1
    for my $job (values %$jobs) {
240
1
1
        my $steps = $job->{steps} or next;
241
1
1
        for my $step (@$steps) {
242
2
3
            my $run = $step->{run} // '';
243
2
2
            return 'npm' if $run =~ /npm (install|ci)/;
244
2
2
            return 'pip' if $run =~ /pip install/;
245
2
1
            return 'cargo' if $run =~ /cargo (build|test)/;
246
2
2
            return 'bundler' if $run =~ /bundle install/;
247        }
248    }
249
1
1
    return 'unknown';
250}
251
252
0
0
0
0
0
0
0
0
sub min($a, $b) {
253
0
0
    return $a < $b ? $a : $b;
254}
255
256
1
1
1
1
0
1
sub find_outdated_actions($workflow) {
257
1
3
        my @outdated;
258
1
1
        my $jobs = $workflow->{jobs} or return @outdated;
259
260    # Known outdated versions
261
1
5
    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
0
    for my $job (values %$jobs) {
273
1
1
        my $steps = $job->{steps} or next;
274
1
1
        for my $step (@$steps) {
275
2
2
            next unless $step->{uses};
276
1
1
            my $uses = $step->{uses};
277
278
1
1
            for my $old (keys %updates) {
279
8
29
                if ($uses =~ /^\Q$old\E/) {
280
0
0
                    push @outdated, "$old → $updates{$old}";
281                }
282            }
283        }
284    }
285
286
1
2
    return @outdated;
287}
288
289
0
0
0
sub has_deployment_steps($workflow) {
290
0
        my $jobs = $workflow->{jobs} or return 0;
291
292
0
    for my $job (values %$jobs) {
293
0
        my $steps = $job->{steps} or next;
294
0
        for my $step (@$steps) {
295            # Check for deployment-related actions
296
0
            return 1 if $step->{uses} && $step->{uses} =~ /deploy|publish|release/i;
297
0
            return 1 if $step->{run} && $step->{run} =~ /git push|npm publish/;
298        }
299    }
300
301
0
        return 0;
302}
303
304 - 315
=head1 AUTHOR

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

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

=head1 LICENSE

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut
316
3171;