| File: | blib/lib/App/GHGen/Analyzer.pm |
| Coverage: | 95.9% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::GHGen::Analyzer; | |||||
| 2 | ||||||
| 3 | 8 8 | 587403 14 | use v5.36; | |||
| 4 | 8 8 8 | 13 5 159 | use warnings; | |||
| 5 | 8 8 8 | 14 2 66 | use strict; | |||
| 6 | ||||||
| 7 | 8 8 8 | 582 6833 143 | use YAML::XS qw(LoadFile); | |||
| 8 | 8 7 7 | 20 5 106 | use Path::Tiny; | |||
| 9 | 7 7 7 | 794 10 374 | use App::GHGen::Fixer qw(%ACTION_UPDATES); | |||
| 10 | ||||||
| 11 | 7 7 7 | 15 8 4304 | use Exporter 'import'; | |||
| 12 | our @EXPORT_OK = qw( | |||||
| 13 | analyze_workflow | |||||
| 14 | find_workflows | |||||
| 15 | get_cache_suggestion | |||||
| 16 | ); | |||||
| 17 | ||||||
| 18 | our $VERSION = '0.10'; | |||||
| 19 | ||||||
| 20 - 81 | =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 sorted list of L<Path::Tiny> objects.
=head3 Purpose
Discover every GitHub Actions workflow file under C<.github/workflows> in
the current working directory.
=head3 Arguments
None.
=head3 Returns
A sorted list of L<Path::Tiny> objects, one per C<.yml> or C<.yaml> file
found. Returns an empty list when the directory does not exist or is empty.
=head3 Side Effects
None. Performs read-only filesystem access.
=head3 Usage Example
use App::GHGen::Analyzer qw(find_workflows);
chdir '/path/to/repo';
my @wfs = find_workflows();
say $_->basename for @wfs;
=head3 API SPECIFICATION
=head4 Input
# No parameters.
=head4 Output
{ type => 'array', element => { isa => 'Path::Tiny' } }
=head3 FORMAL SPECIFICATION
FindWorkflows : â seq Path
dir â .github/workflows
FindWorkflows â¡
¬dir.exists ⨠¬dir.is_dir â â¨â©
| otherwise â sort(lex) { f â dir.children ⣠f.name =~ /\.ya?ml$/i }
=cut | |||||
| 82 | ||||||
| 83 | 5 5 | 78722 4 | sub find_workflows() { | |||
| 84 | 5 | 11 | my $workflows_dir = path('.github/workflows'); | |||
| 85 | ||||||
| 86 | 5 | 92 | return () unless $workflows_dir->exists && $workflows_dir->is_dir; | |||
| 87 | 3 | 54 | return sort $workflows_dir->children(qr/\.ya?ml$/i); | |||
| 88 | } | |||||
| 89 | ||||||
| 90 - 185 | =head2 analyze_workflow($workflow, $filename)
Analyze a workflow hash for issues. Returns array of issue hashes.
Each issue has: C<type>, C<severity>, C<message>, and an optional C<fix>.
=head3 Purpose
Inspect a parsed GitHub Actions workflow and return a list of detected
issues covering performance, security, cost, and maintenance concerns.
=head3 Arguments
=over 4
=item C<$workflow> (HashRef, required)
A hash reference representing the parsed workflow (e.g. from C<YAML::XS::LoadFile>).
Must contain at least a C<jobs> key whose value is a hash of job definitions.
=item C<$filename> (Str, required)
The filename of the workflow, used only as context in issue messages.
=back
=head3 Returns
A list (not an array reference) of issue hash references. Each issue has:
{
type => Str, # 'performance' | 'security' | 'cost' | 'maintenance'
severity => Str, # 'high' | 'medium' | 'low'
message => Str,
fix => Str, # optional: suggested YAML snippet
}
Returns an empty list when no issues are detected.
=head3 Side Effects
None. Pure function; does not modify its arguments.
=head3 Usage Example
use App::GHGen::Analyzer qw(analyze_workflow);
use YAML::XS qw(LoadFile);
my $wf = LoadFile('.github/workflows/ci.yml');
my @issues = analyze_workflow($wf, 'ci.yml');
for my $issue (@issues) {
say "$issue->{severity}: $issue->{message}";
}
=head3 API SPECIFICATION
=head4 Input
{
workflow => { type => 'hashref', required => 1 },
filename => { type => 'scalar', required => 1 },
}
=head4 Output
{
type => 'array',
element => {
type => 'hashref',
keys => {
type => { type => 'scalar' },
severity => { type => 'scalar' },
message => { type => 'scalar' },
fix => { type => 'scalar', optional => 1 },
},
},
}
=head3 FORMAL SPECIFICATION
Issue â { type: IssueType, severity: Severity, message: â¤*, fix?: â¤* }
IssueType â performance | security | cost | maintenance
Severity â high | medium | low
analyze_workflow : Workflow à â¤* â seq Issue
â w: Workflow, f: â¤*:
¬has_caching(w) â performanceâmedium â result(w,f)
find_unpinned_actions(w)â â
â securityâhigh â result(w,f)
find_outdated_actions(w)â â
â maintenanceâmedium â result(w,f)
has_broad_triggers(w) â costâmedium â result(w,f)
¬w.concurrency â costâlow â result(w,f)
has_outdated_runners(w) â maintenanceâlow â result(w,f)
â jâw.jobs: ¬j.timeout-minutes â performanceâlow â result(w,f)
=cut | |||||
| 186 | ||||||
| 187 | 37 37 37 37 | 226926 30 31 37 | sub analyze_workflow($workflow, $filename) { | |||
| 188 | 37 | 20 | my @issues; | |||
| 189 | ||||||
| 190 | # Check 1: Missing dependency caching | |||||
| 191 | 37 | 37 | unless (has_caching($workflow)) { | |||
| 192 | 22 | 25 | my $cache_suggestion = get_cache_suggestion($workflow); | |||
| 193 | 22 | 41 | push @issues, { | |||
| 194 | type => 'performance', | |||||
| 195 | severity => 'medium', | |||||
| 196 | message => 'No dependency caching found - increases build times and costs', | |||||
| 197 | fix => $cache_suggestion | |||||
| 198 | }; | |||||
| 199 | } | |||||
| 200 | ||||||
| 201 | # Check 2: Using unpinned action versions | |||||
| 202 | 37 | 36 | my @unpinned = find_unpinned_actions($workflow); | |||
| 203 | 37 | 30 | if (@unpinned) { | |||
| 204 | push @issues, { | |||||
| 205 | type => 'security', | |||||
| 206 | severity => 'high', | |||||
| 207 | message => 'Found ' . scalar(@unpinned) . " action(s) using \@master or \@main", | |||||
| 208 | fix => "Replace \@master/\@main with specific version tags:\n" . | |||||
| 209 | 11 13 13 | 33 28 29 | join("\n", map { " $_" } map { s/\@(master|main)$/\@v5/r } @unpinned[0..min(2, $#unpinned)]) | |||
| 210 | }; | |||||
| 211 | } | |||||
| 212 | ||||||
| 213 | # Check for outdated action versions | |||||
| 214 | 37 | 38 | my @outdated = find_outdated_actions($workflow); | |||
| 215 | 37 | 35 | if (@outdated) { | |||
| 216 | push @issues, { | |||||
| 217 | type => 'maintenance', | |||||
| 218 | severity => 'medium', | |||||
| 219 | message => "Found " . scalar(@outdated) . " outdated action(s)", | |||||
| 220 | fix => "Update to latest versions:\n" . | |||||
| 221 | 3 5 | 8 7 | join("\n", map { " $_" } @outdated[0..min(2, $#outdated)]) | |||
| 222 | }; | |||||
| 223 | } | |||||
| 224 | ||||||
| 225 | # Check 3: Overly broad triggers | |||||
| 226 | 37 | 26 | if (has_broad_triggers($workflow)) { | |||
| 227 | 12 | 20 | push @issues, { | |||
| 228 | type => 'cost', | |||||
| 229 | severity => 'medium', | |||||
| 230 | message => 'Workflow triggers on all pushes - consider path/branch filters', | |||||
| 231 | fix => "Add trigger filters:\n" . | |||||
| 232 | " on:\n" . | |||||
| 233 | " push:\n" . | |||||
| 234 | " branches: [main, develop]\n" . | |||||
| 235 | " paths:\n" . | |||||
| 236 | " - 'src/**'\n" . | |||||
| 237 | " - 'package.json'" | |||||
| 238 | }; | |||||
| 239 | } | |||||
| 240 | ||||||
| 241 | # Check 4: Missing concurrency controls | |||||
| 242 | 37 | 38 | unless ($workflow->{concurrency}) { | |||
| 243 | 18 | 26 | push @issues, { | |||
| 244 | type => 'cost', | |||||
| 245 | severity => 'low', | |||||
| 246 | message => 'No concurrency group - old runs continue when superseded', | |||||
| 247 | fix => "Add concurrency control:\n" . | |||||
| 248 | " concurrency:\n" . | |||||
| 249 | " group: \${{ github.workflow }}-\${{ github.ref }}\n" . | |||||
| 250 | " cancel-in-progress: true" | |||||
| 251 | }; | |||||
| 252 | } | |||||
| 253 | ||||||
| 254 | # Check 5: Outdated runner versions | |||||
| 255 | 37 | 28 | if (has_outdated_runners($workflow)) { | |||
| 256 | 8 | 12 | push @issues, { | |||
| 257 | type => 'maintenance', | |||||
| 258 | severity => 'low', | |||||
| 259 | message => 'Using older runner versions - consider updating', | |||||
| 260 | fix => 'Update to ubuntu-latest, macos-latest, or windows-latest' | |||||
| 261 | }; | |||||
| 262 | } | |||||
| 263 | ||||||
| 264 | # Check 6: Missing timeout-minutes | |||||
| 265 | 37 | 40 | my $jobs = $workflow->{jobs} // {}; | |||
| 266 | 37 | 29 | for my $job_name (keys %$jobs) { | |||
| 267 | 36 | 24 | my $job = $jobs->{$job_name}; | |||
| 268 | ||||||
| 269 | 36 | 34 | unless (exists $job->{'timeout-minutes'}) { | |||
| 270 | 18 | 31 | push @issues, { | |||
| 271 | type => 'performance', | |||||
| 272 | severity => 'low', | |||||
| 273 | message => "Job '$job_name' is missing timeout-minutes", | |||||
| 274 | fix => "Add:\n timeout-minutes: 30", | |||||
| 275 | }; | |||||
| 276 | } | |||||
| 277 | } | |||||
| 278 | ||||||
| 279 | 37 | 64 | return @issues; | |||
| 280 | } | |||||
| 281 | ||||||
| 282 - 338 | =head2 get_cache_suggestion($workflow)
Generate a caching suggestion based on detected project type.
=head3 Purpose
Inspect a workflow for known package-manager commands and return a ready-to-paste
C<actions/cache> YAML snippet that matches the detected ecosystem.
=head3 Arguments
=over 4
=item C<$workflow> (HashRef, required)
A parsed workflow hash. The function inspects each job's C<steps[].run>
commands to detect C<npm>, C<pip>, C<cargo>, or C<bundle>.
=back
=head3 Returns
A non-empty string. When the ecosystem is recognised the string is an
C<actions/cache> YAML step snippet; otherwise a generic guidance message.
=head3 Side Effects
None. Pure function.
=head3 Usage Example
my $suggestion = get_cache_suggestion($workflow);
say $suggestion;
=head3 API SPECIFICATION
=head4 Input
{ workflow => { type => 'hashref', required => 1 } }
=head4 Output
{ type => 'scalar' }
=head3 FORMAL SPECIFICATION
Ecosystem â npm | pip | cargo | bundler | unknown
get_cache_suggestion : Workflow â â¤*
ecosystem(w) = npm â result contains ~/.npm cache path
ecosystem(w) = pip â result contains ~/.cache/pip path
ecosystem(w) = cargo â result contains ~/.cargo path
ecosystem(w) = bundler â result contains vendor/bundle path
ecosystem(w) = unknown â result is a generic guidance message
=cut | |||||
| 339 | ||||||
| 340 | 37 37 37 | 9990 24 24 | sub get_cache_suggestion($workflow) { | |||
| 341 | 37 | 51 | my $detected_type = detect_project_type({ jobs => $workflow->{jobs} }); | |||
| 342 | ||||||
| 343 | 37 | 88 | my %cache_configs = ( | |||
| 344 | npm => "- uses: actions/cache\@v5\n" . | |||||
| 345 | " with:\n" . | |||||
| 346 | " path: ~/.npm\n" . | |||||
| 347 | " key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }}\n" . | |||||
| 348 | " restore-keys: |\n" . | |||||
| 349 | " \${{ runner.os }}-node-", | |||||
| 350 | ||||||
| 351 | pip => "- uses: actions/cache\@v5\n" . | |||||
| 352 | " with:\n" . | |||||
| 353 | " path: ~/.cache/pip\n" . | |||||
| 354 | " key: \${{ runner.os }}-pip-\${{ hashFiles('**/requirements.txt') }}\n" . | |||||
| 355 | " restore-keys: |\n" . | |||||
| 356 | " \${{ runner.os }}-pip-", | |||||
| 357 | ||||||
| 358 | cargo => "- uses: actions/cache\@v5\n" . | |||||
| 359 | " with:\n" . | |||||
| 360 | " path: |\n" . | |||||
| 361 | " ~/.cargo/bin/\n" . | |||||
| 362 | " ~/.cargo/registry/index/\n" . | |||||
| 363 | " ~/.cargo/registry/cache/\n" . | |||||
| 364 | " target/\n" . | |||||
| 365 | " key: \${{ runner.os }}-cargo-\${{ hashFiles('**/Cargo.lock') }}", | |||||
| 366 | ||||||
| 367 | bundler => "- uses: actions/cache\@v5\n" . | |||||
| 368 | " with:\n" . | |||||
| 369 | " path: vendor/bundle\n" . | |||||
| 370 | " key: \${{ runner.os }}-gems-\${{ hashFiles('**/Gemfile.lock') }}\n" . | |||||
| 371 | " restore-keys: |\n" . | |||||
| 372 | " \${{ runner.os }}-gems-", | |||||
| 373 | ); | |||||
| 374 | ||||||
| 375 | 37 | 94 | return $cache_configs{$detected_type} // | |||
| 376 | "Add caching based on your dependency manager:\n" . | |||||
| 377 | " See: https://docs.github.com/en/actions/using-workflows/caching-dependencies"; | |||||
| 378 | } | |||||
| 379 | ||||||
| 380 | # Helper functions | |||||
| 381 | ||||||
| 382 | 37 37 37 | 66926 22 19 | sub has_caching($workflow) { | |||
| 383 | 37 | 47 | my $jobs = $workflow->{jobs} or return 0; | |||
| 384 | ||||||
| 385 | 36 | 42 | for my $job (values %$jobs) { | |||
| 386 | 35 | 30 | my $steps = $job->{steps} or next; | |||
| 387 | 35 | 29 | for my $step (@$steps) { | |||
| 388 | 53 | 109 | return 1 if $step->{uses} && $step->{uses} =~ /actions\/cache/; | |||
| 389 | } | |||||
| 390 | } | |||||
| 391 | 20 | 22 | return 0; | |||
| 392 | } | |||||
| 393 | ||||||
| 394 | 36 36 36 | 2108 17 23 | sub find_unpinned_actions($workflow) { | |||
| 395 | 36 | 20 | my @unpinned; | |||
| 396 | 36 | 60 | my $jobs = $workflow->{jobs} or return @unpinned; | |||
| 397 | ||||||
| 398 | 36 | 30 | for my $job (values %$jobs) { | |||
| 399 | 35 | 32 | my $steps = $job->{steps} or next; | |||
| 400 | 35 | 22 | for my $step (@$steps) { | |||
| 401 | 74 | 65 | next unless $step->{uses}; | |||
| 402 | 47 | 56 | if ($step->{uses} =~ /\@(?:master|main)$/) { | |||
| 403 | 16 | 19 | push @unpinned, $step->{uses}; | |||
| 404 | } | |||||
| 405 | } | |||||
| 406 | } | |||||
| 407 | 36 | 39 | return @unpinned; | |||
| 408 | } | |||||
| 409 | ||||||
| 410 | 38 38 38 | 1561 17 30 | sub has_broad_triggers($workflow) { | |||
| 411 | 38 | 35 | my $on = $workflow->{on}; | |||
| 412 | 38 | 33 | return 0 unless $on; | |||
| 413 | ||||||
| 414 | # Check if push trigger has no path or branch filters | |||||
| 415 | 33 | 61 | if (ref $on eq 'HASH' && $on->{push}) { | |||
| 416 | 21 | 28 | my $push = $on->{push}; | |||
| 417 | 21 | 70 | return 1 if ref $push eq '' || (!$push->{paths} && !$push->{branches}); | |||
| 418 | } | |||||
| 419 | ||||||
| 420 | # Simple array of triggers including 'push' | |||||
| 421 | 28 14 | 37 18 | if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) { | |||
| 422 | 9 | 12 | return 1; | |||
| 423 | } | |||||
| 424 | ||||||
| 425 | 19 | 20 | return 0; | |||
| 426 | } | |||||
| 427 | ||||||
| 428 | 44 44 44 | 4025 20 23 | sub has_outdated_runners($workflow) { | |||
| 429 | 44 | 34 | my $jobs = $workflow->{jobs} or return 0; | |||
| 430 | ||||||
| 431 | 44 | 42 | for my $job (values %$jobs) { | |||
| 432 | 43 | 37 | my $runs_on = $job->{'runs-on'} or next; | |||
| 433 | 39 | 89 | return 1 if $runs_on =~ /\A(?:ubuntu-(?:18|16)\.04|macos-10\.15)\z/; | |||
| 434 | } | |||||
| 435 | 32 | 25 | return 0; | |||
| 436 | } | |||||
| 437 | ||||||
| 438 | 42 42 42 | 1259 24 31 | sub detect_project_type($workflow) { | |||
| 439 | 42 | 48 | return 'unknown' unless ref $workflow eq 'HASH'; | |||
| 440 | ||||||
| 441 | 42 | 45 | my $jobs = $workflow->{jobs} or return 'unknown'; | |||
| 442 | ||||||
| 443 | 40 | 38 | for my $job (values %$jobs) { | |||
| 444 | 38 | 36 | my $steps = $job->{steps} or next; | |||
| 445 | 38 | 29 | for my $step (@$steps) { | |||
| 446 | 36 | 38 | my $run = $step->{run} // ''; | |||
| 447 | 36 | 64 | return 'npm' if $run =~ /npm (?:install|ci)/; | |||
| 448 | 26 | 31 | return 'pip' if $run =~ /pip install/; | |||
| 449 | 22 | 26 | return 'cargo' if $run =~ /cargo (?:build|test)/; | |||
| 450 | 19 | 23 | return 'bundler' if $run =~ /bundle install/; | |||
| 451 | } | |||||
| 452 | } | |||||
| 453 | 19 | 16 | return 'unknown'; | |||
| 454 | } | |||||
| 455 | ||||||
| 456 | 18 18 18 18 | 2557 11 11 12 | sub min($a, $b) { | |||
| 457 | 18 | 30 | return $a < $b ? $a : $b; | |||
| 458 | } | |||||
| 459 | ||||||
| 460 - 500 | =head2 find_outdated_actions($workflow)
Return a list of human-readable upgrade notices for any action whose version
string is a key in C<App::GHGen::Fixer::ACTION_UPDATES>.
=head3 Purpose
Detect C<uses:> entries that reference a known-outdated action version.
The detection set is derived directly from C<%ACTION_UPDATES> in
C<App::GHGen::Fixer>, so it is always in sync with what C<update_actions>
can fix: every action flagged here can be auto-fixed, and every action the
Fixer knows about is flagged here.
=head3 Arguments
=over 4
=item C<$workflow> (HashRef, required)
A parsed workflow hash.
=back
=head3 Returns
A list of strings of the form C<"old/action\@vN E<rarr> new/action\@vM">,
one per outdated step found. Returns an empty list when none are detected.
=head3 Side Effects
None. Pure function.
=head3 FORMAL SPECIFICATION
find_outdated_actions : Workflow â seq â¤*
outdated â { "$old â $new" ⣠job â w.jobs, step â job.steps,
old â dom(%ACTION_UPDATES), step.uses =~ /^\Qold\E/ }
result â seq(outdated)
=cut | |||||
| 501 | ||||||
| 502 | 37 37 37 | 4106 23 14 | sub find_outdated_actions($workflow) { | |||
| 503 | 37 | 28 | my @outdated; | |||
| 504 | 37 | 35 | my $jobs = $workflow->{jobs} or return @outdated; | |||
| 505 | ||||||
| 506 | # Premise: %ACTION_UPDATES (from Fixer) is the single source of truth. | |||||
| 507 | # Conclusion: every action this function flags can also be fixed by update_actions. | |||||
| 508 | 37 | 46 | for my $job (values %$jobs) { | |||
| 509 | 36 | 34 | my $steps = $job->{steps} or next; | |||
| 510 | 36 | 24 | for my $step (@$steps) { | |||
| 511 | 83 | 94 | next unless $step->{uses}; | |||
| 512 | 56 | 30 | my $uses = $step->{uses}; | |||
| 513 | 56 | 60 | for my $old (keys %ACTION_UPDATES) { | |||
| 514 | 504 | 1444 | if ($uses =~ /^\Q$old\E/) { | |||
| 515 | 17 | 23 | push @outdated, "$old â $ACTION_UPDATES{$old}"; | |||
| 516 | } | |||||
| 517 | } | |||||
| 518 | } | |||||
| 519 | } | |||||
| 520 | ||||||
| 521 | 37 | 31 | return @outdated; | |||
| 522 | } | |||||
| 523 | ||||||
| 524 - 536 | =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 | |||||
| 537 | ||||||
| 538 | 1; | |||||