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