| File: | blib/lib/App/GHGen/Analyzer.pm |
| Coverage: | 62.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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'; | |||
| 11 | our @EXPORT_OK = qw( | |||||
| 12 | analyze_workflow | |||||
| 13 | find_workflows | |||||
| 14 | get_cache_suggestion | |||||
| 15 | ); | |||||
| 16 | ||||||
| 17 | our $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 | ||||||
| 317 | 1; | |||||