| File: | blib/lib/App/GHGen/Fixer.pm |
| Coverage: | 47.3% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::GHGen::Fixer; | |||||
| 2 | ||||||
| 3 | 1 1 | 479 1 | use v5.36; | |||
| 4 | 1 1 1 | 1 1 8 | use strict; | |||
| 5 | 1 1 1 | 1 0 21 | use warnings; | |||
| 6 | 1 1 1 | 2 0 20 | use YAML::XS qw(LoadFile DumpFile); | |||
| 7 | 1 1 1 | 2 0 14 | use Path::Tiny; | |||
| 8 | ||||||
| 9 | 1 1 1 | 1 1 795 | use Exporter 'import'; | |||
| 10 | our @EXPORT_OK = qw( | |||||
| 11 | apply_fixes | |||||
| 12 | can_auto_fix | |||||
| 13 | fix_workflow | |||||
| 14 | ); | |||||
| 15 | ||||||
| 16 | our $VERSION = '0.05'; | |||||
| 17 | ||||||
| 18 - 34 | =head1 NAME App::GHGen::Fixer - Auto-fix workflow issues =head1 SYNOPSIS use App::GHGen::Fixer qw(apply_fixes); my $fixed = apply_fixes($workflow, \@issues); =head1 FUNCTIONS =head2 can_auto_fix($issue) Check if an issue can be automatically fixed. =cut | |||||
| 35 | ||||||
| 36 | 4 4 4 | 2 1 3 | sub can_auto_fix($issue) { | |||
| 37 | 4 | 5 | my %fixable = ( | |||
| 38 | 'performance' => 1, # Can add caching | |||||
| 39 | 'security' => 1, # Can update action versions and add permissions | |||||
| 40 | 'cost' => 1, # Can add concurrency, filters | |||||
| 41 | 'maintenance' => 1, # Can update runners | |||||
| 42 | ); | |||||
| 43 | ||||||
| 44 | 4 | 5 | return $fixable{$issue->{type}} // 0; | |||
| 45 | } | |||||
| 46 | ||||||
| 47 - 51 | =head2 apply_fixes($workflow, $issues) Apply automatic fixes to a workflow. Returns modified workflow hashref. =cut | |||||
| 52 | ||||||
| 53 | 2 2 2 2 | 2 2 1 1 | sub apply_fixes($workflow, $issues) { | |||
| 54 | 2 | 1 | my $modified = 0; | |||
| 55 | ||||||
| 56 | 2 | 2 | for my $issue (@$issues) { | |||
| 57 | 4 | 3 | next unless can_auto_fix($issue); | |||
| 58 | ||||||
| 59 | 4 | 21 | if ($issue->{type} eq 'performance' && $issue->{message} =~ /caching/) { | |||
| 60 | 1 | 1 | $modified += add_caching($workflow); | |||
| 61 | } | |||||
| 62 | elsif ($issue->{type} eq 'security' && $issue->{message} =~ /unpinned/) { | |||||
| 63 | 0 | 0 | $modified += fix_unpinned_actions($workflow); | |||
| 64 | } | |||||
| 65 | elsif ($issue->{type} eq 'security' && $issue->{message} =~ /permissions/) { | |||||
| 66 | 0 | 0 | $modified += add_permissions($workflow); | |||
| 67 | } | |||||
| 68 | elsif ($issue->{type} eq 'maintenance' && $issue->{message} =~ /outdated action/) { | |||||
| 69 | 0 | 0 | $modified += update_actions($workflow); | |||
| 70 | } | |||||
| 71 | elsif ($issue->{type} eq 'cost' && $issue->{message} =~ /concurrency/) { | |||||
| 72 | 1 | 1 | $modified += add_concurrency($workflow); | |||
| 73 | } | |||||
| 74 | elsif ($issue->{type} eq 'cost' && $issue->{message} =~ /triggers/) { | |||||
| 75 | 1 | 7 | $modified += add_trigger_filters($workflow); | |||
| 76 | } | |||||
| 77 | elsif ($issue->{type} eq 'maintenance' && $issue->{message} =~ /runner/) { | |||||
| 78 | 0 | 0 | $modified += update_runners($workflow); | |||
| 79 | } elsif ($issue->{type} eq 'performance' && $issue->{message} =~ /missing timeout-minutes/) { | |||||
| 80 | 1 | 3 | $modified += add_missing_timeout($workflow); | |||
| 81 | } | |||||
| 82 | } | |||||
| 83 | ||||||
| 84 | 2 | 2 | return $modified; | |||
| 85 | } | |||||
| 86 | ||||||
| 87 - 91 | =head2 fix_workflow($file, $issues) Fix a workflow file in place. Returns number of fixes applied. =cut | |||||
| 92 | ||||||
| 93 | 0 0 0 0 | 0 0 0 0 | sub fix_workflow($file, $issues) { | |||
| 94 | 0 | 0 | my $workflow = LoadFile($file); | |||
| 95 | 0 | 0 | my $fixes = apply_fixes($workflow, $issues); | |||
| 96 | ||||||
| 97 | 0 | 0 | if ($fixes > 0) { | |||
| 98 | 0 | 0 | DumpFile($file, $workflow); | |||
| 99 | } | |||||
| 100 | ||||||
| 101 | 0 | 0 | return $fixes; | |||
| 102 | } | |||||
| 103 | ||||||
| 104 | # Fix implementations | |||||
| 105 | ||||||
| 106 | 1 1 1 | 1 1 0 | sub add_caching($workflow) { | |||
| 107 | 1 | 1 | my $jobs = $workflow->{jobs} or return 0; | |||
| 108 | 1 | 1 | my $modified = 0; | |||
| 109 | ||||||
| 110 | 1 | 1 | for my $job (values %$jobs) { | |||
| 111 | 1 | 1 | my $steps = $job->{steps} or next; | |||
| 112 | ||||||
| 113 | # Check if already has caching | |||||
| 114 | 1 2 | 0 4 | my $has_cache = grep { $_->{uses} && $_->{uses} =~ /actions\/cache/ } @$steps; | |||
| 115 | 1 | 3 | next if $has_cache; | |||
| 116 | ||||||
| 117 | # Detect project type and add appropriate cache | |||||
| 118 | 1 | 1 | my $cache_step = detect_and_create_cache_step($steps); | |||
| 119 | 1 | 2 | next unless $cache_step; | |||
| 120 | ||||||
| 121 | # Insert cache step after checkout | |||||
| 122 | 0 | 0 | my $insert_at = 0; | |||
| 123 | 0 | 0 | for my $i (0 .. $#$steps) { | |||
| 124 | 0 | 0 | if ($steps->[$i]->{uses} && $steps->[$i]->{uses} =~ /actions\/checkout/) { | |||
| 125 | 0 | 0 | $insert_at = $i + 1; | |||
| 126 | 0 | 0 | last; | |||
| 127 | } | |||||
| 128 | } | |||||
| 129 | ||||||
| 130 | 0 | 0 | splice @$steps, $insert_at, 0, $cache_step; | |||
| 131 | 0 | 0 | $modified++; | |||
| 132 | } | |||||
| 133 | ||||||
| 134 | 1 | 0 | return $modified; | |||
| 135 | } | |||||
| 136 | ||||||
| 137 | 1 1 1 | 1 0 1 | sub detect_and_create_cache_step($steps) { | |||
| 138 | # Detect project type from steps | |||||
| 139 | 1 | 1 | for my $step (@$steps) { | |||
| 140 | 2 | 2 | my $run = $step->{run} // ''; | |||
| 141 | ||||||
| 142 | # Node.js | |||||
| 143 | 2 | 4 | if ($run =~ /npm (install|ci)/ || ($step->{uses} && $step->{uses} =~ /setup-node/)) { | |||
| 144 | return { | |||||
| 145 | 0 | 0 | name => 'Cache dependencies', | |||
| 146 | uses => 'actions/cache@v5', | |||||
| 147 | with => { | |||||
| 148 | path => '~/.npm', | |||||
| 149 | key => '${{ runner.os }}-node-${{ hashFiles(\'**/package-lock.json\') }}', | |||||
| 150 | 'restore-keys' => '${{ runner.os }}-node-', | |||||
| 151 | }, | |||||
| 152 | }; | |||||
| 153 | } | |||||
| 154 | ||||||
| 155 | # Python | |||||
| 156 | 2 | 3 | if ($run =~ /pip install/ || ($step->{uses} && $step->{uses} =~ /setup-python/)) { | |||
| 157 | return { | |||||
| 158 | 0 | 0 | name => 'Cache pip packages', | |||
| 159 | uses => 'actions/cache@v5', | |||||
| 160 | with => { | |||||
| 161 | path => '~/.cache/pip', | |||||
| 162 | key => '${{ runner.os }}-pip-${{ hashFiles(\'**/requirements.txt\') }}', | |||||
| 163 | 'restore-keys' => '${{ runner.os }}-pip-', | |||||
| 164 | }, | |||||
| 165 | }; | |||||
| 166 | } | |||||
| 167 | ||||||
| 168 | # Rust | |||||
| 169 | 2 | 2 | if ($run =~ /cargo (build|test)/) { | |||
| 170 | return { | |||||
| 171 | 0 | 0 | name => 'Cache cargo', | |||
| 172 | uses => 'actions/cache@v5', | |||||
| 173 | with => { | |||||
| 174 | path => "~/.cargo/bin/\n~/.cargo/registry/index/\n~/.cargo/registry/cache/\n~/.cargo/git/db/\ntarget/", | |||||
| 175 | key => '${{ runner.os }}-cargo-${{ hashFiles(\'**/Cargo.lock\') }}', | |||||
| 176 | }, | |||||
| 177 | }; | |||||
| 178 | } | |||||
| 179 | ||||||
| 180 | # Go | |||||
| 181 | 2 | 6 | if ($run =~ /go (build|test)/ || ($step->{uses} && $step->{uses} =~ /setup-go/)) { | |||
| 182 | return { | |||||
| 183 | 0 | 0 | name => 'Cache Go modules', | |||
| 184 | uses => 'actions/cache@v5', | |||||
| 185 | with => { | |||||
| 186 | path => '~/go/pkg/mod', | |||||
| 187 | key => '${{ runner.os }}-go-${{ hashFiles(\'**/go.sum\') }}', | |||||
| 188 | 'restore-keys' => '${{ runner.os }}-go-', | |||||
| 189 | }, | |||||
| 190 | }; | |||||
| 191 | } | |||||
| 192 | } | |||||
| 193 | ||||||
| 194 | 1 | 0 | return undef; | |||
| 195 | } | |||||
| 196 | ||||||
| 197 | 0 0 0 | 0 0 0 | sub fix_unpinned_actions($workflow) { | |||
| 198 | 0 | 0 | my $jobs = $workflow->{jobs} or return 0; | |||
| 199 | 0 | 0 | my $modified = 0; | |||
| 200 | ||||||
| 201 | 0 | 0 | for my $job (values %$jobs) { | |||
| 202 | 0 | 0 | my $steps = $job->{steps} or next; | |||
| 203 | 0 | 0 | for my $step (@$steps) { | |||
| 204 | 0 | 0 | next unless $step->{uses}; | |||
| 205 | ||||||
| 206 | 0 | 0 | if ($step->{uses} =~ /^(.+)\@(master|main)$/) { | |||
| 207 | 0 | 0 | my $action = $1; | |||
| 208 | # Map to appropriate version | |||||
| 209 | 0 | 0 | my $version = get_latest_version($action); | |||
| 210 | 0 | 0 | $step->{uses} = "$action\@$version"; | |||
| 211 | 0 | 0 | $modified++; | |||
| 212 | } | |||||
| 213 | } | |||||
| 214 | } | |||||
| 215 | ||||||
| 216 | 0 | 0 | return $modified; | |||
| 217 | } | |||||
| 218 | ||||||
| 219 | 0 0 0 | 0 0 0 | sub add_permissions($workflow) { | |||
| 220 | 0 | 0 | return 0 if $workflow->{permissions}; | |||
| 221 | ||||||
| 222 | 0 | 0 | $workflow->{permissions} = { contents => 'read' }; | |||
| 223 | 0 | 0 | return 1; | |||
| 224 | } | |||||
| 225 | ||||||
| 226 | 0 0 0 | 0 0 0 | sub update_actions($workflow) { | |||
| 227 | 0 | 0 | my $jobs = $workflow->{jobs} or return 0; | |||
| 228 | 0 | 0 | my $modified = 0; | |||
| 229 | ||||||
| 230 | 0 | 0 | my %updates = ( | |||
| 231 | 'actions/cache@v4' => 'actions/cache@v5', | |||||
| 232 | 'actions/cache@v3' => 'actions/cache@v5', | |||||
| 233 | 'actions/checkout@v5' => 'actions/checkout@v6', | |||||
| 234 | 'actions/checkout@v4' => 'actions/checkout@v6', | |||||
| 235 | 'actions/checkout@v3' => 'actions/checkout@v6', | |||||
| 236 | 'actions/setup-node@v3' => 'actions/setup-node@v4', | |||||
| 237 | 'actions/setup-python@v4' => 'actions/setup-python@v5', | |||||
| 238 | 'actions/setup-go@v4' => 'actions/setup-go@v5', | |||||
| 239 | ); | |||||
| 240 | ||||||
| 241 | 0 | 0 | for my $job (values %$jobs) { | |||
| 242 | 0 | 0 | my $steps = $job->{steps} or next; | |||
| 243 | 0 | 0 | for my $step (@$steps) { | |||
| 244 | 0 | 0 | next unless $step->{uses}; | |||
| 245 | ||||||
| 246 | 0 | 0 | for my $old (keys %updates) { | |||
| 247 | 0 | 0 | if ($step->{uses} =~ /^\Q$old\E/) { | |||
| 248 | 0 | 0 | $step->{uses} = $updates{$old}; | |||
| 249 | 0 | 0 | $modified++; | |||
| 250 | } | |||||
| 251 | } | |||||
| 252 | } | |||||
| 253 | } | |||||
| 254 | ||||||
| 255 | 0 | 0 | return $modified; | |||
| 256 | } | |||||
| 257 | ||||||
| 258 | 1 1 1 | 1 1 0 | sub add_concurrency($workflow) { | |||
| 259 | 1 | 1 | return 0 if $workflow->{concurrency}; | |||
| 260 | ||||||
| 261 | $workflow->{concurrency} = { | |||||
| 262 | 1 | 2 | group => '${{ github.workflow }}-${{ github.ref }}', | |||
| 263 | 'cancel-in-progress' => 'true', | |||||
| 264 | }; | |||||
| 265 | 1 | 3 | return 1; | |||
| 266 | } | |||||
| 267 | ||||||
| 268 | 1 1 1 | 1 0 1 | sub add_trigger_filters($workflow) { | |||
| 269 | 1 | 1 | my $on = $workflow->{on} or return 0; | |||
| 270 | 1 | 0 | my $modified = 0; | |||
| 271 | ||||||
| 272 | # If 'on' is just 'push', expand it | |||||
| 273 | 1 0 | 4 0 | if (ref $on eq 'ARRAY' && grep { $_ eq 'push' } @$on) { | |||
| 274 | $workflow->{on} = { | |||||
| 275 | 0 | 0 | push => { | |||
| 276 | branches => ['main', 'master'], | |||||
| 277 | }, | |||||
| 278 | pull_request => { | |||||
| 279 | branches => ['main', 'master'], | |||||
| 280 | }, | |||||
| 281 | }; | |||||
| 282 | 0 | 0 | $modified++; | |||
| 283 | } | |||||
| 284 | elsif (ref $on eq 'HASH' && $on->{push} && ref $on->{push} eq '') { | |||||
| 285 | # 'push' with no filters | |||||
| 286 | $on->{push} = { | |||||
| 287 | 0 | 0 | branches => ['main', 'master'], | |||
| 288 | }; | |||||
| 289 | 0 | 0 | $modified++; | |||
| 290 | } | |||||
| 291 | ||||||
| 292 | 1 | 1 | return $modified; | |||
| 293 | } | |||||
| 294 | ||||||
| 295 | 1 1 1 | 1 0 1 | sub add_missing_timeout($workflow) { | |||
| 296 | 1 | 1 | my $jobs = $workflow->{jobs} or return 0; | |||
| 297 | 1 | 0 | my $modified = 0; | |||
| 298 | ||||||
| 299 | 1 | 1 | for my $job_name (keys %$jobs) { | |||
| 300 | 1 | 1 | my $job = $jobs->{$job_name}; | |||
| 301 | ||||||
| 302 | # Skip if timeout already exists | |||||
| 303 | 1 | 3 | next if exists $job->{'timeout-minutes'}; | |||
| 304 | ||||||
| 305 | # Insert default timeout | |||||
| 306 | 1 | 1 | $job->{'timeout-minutes'} = 30; | |||
| 307 | 1 | 0 | $modified++; | |||
| 308 | } | |||||
| 309 | ||||||
| 310 | 1 | 1 | return $modified; | |||
| 311 | } | |||||
| 312 | ||||||
| 313 | 0 0 0 | sub update_runners($workflow) { | ||||
| 314 | 0 | my $jobs = $workflow->{jobs} or return 0; | ||||
| 315 | 0 | my $modified = 0; | ||||
| 316 | ||||||
| 317 | 0 | my %runner_updates = ( | ||||
| 318 | 'ubuntu-18.04' => 'ubuntu-latest', | |||||
| 319 | 'ubuntu-16.04' => 'ubuntu-latest', | |||||
| 320 | 'macos-10.15' => 'macos-latest', | |||||
| 321 | 'windows-2016' => 'windows-latest', | |||||
| 322 | ); | |||||
| 323 | ||||||
| 324 | 0 | for my $job (values %$jobs) { | ||||
| 325 | 0 | my $runs_on = $job->{'runs-on'} or next; | ||||
| 326 | ||||||
| 327 | 0 | if (exists $runner_updates{$runs_on}) { | ||||
| 328 | 0 | $job->{'runs-on'} = $runner_updates{$runs_on}; | ||||
| 329 | 0 | $modified++; | ||||
| 330 | } | |||||
| 331 | } | |||||
| 332 | ||||||
| 333 | 0 | return $modified; | ||||
| 334 | } | |||||
| 335 | ||||||
| 336 | 0 0 0 | sub get_latest_version($action) { | ||||
| 337 | 0 | my %versions = ( | ||||
| 338 | 'actions/checkout' => 'v6', | |||||
| 339 | 'actions/cache' => 'v5', | |||||
| 340 | 'actions/setup-node' => 'v4', | |||||
| 341 | 'actions/setup-python' => 'v5', | |||||
| 342 | 'actions/setup-go' => 'v5', | |||||
| 343 | 'actions/upload-artifact' => 'v4', | |||||
| 344 | 'actions/download-artifact' => 'v4', | |||||
| 345 | ); | |||||
| 346 | ||||||
| 347 | 0 | return $versions{$action} // 'v4'; # Default fallback | ||||
| 348 | } | |||||
| 349 | ||||||
| 350 - 364 | =head1 AUTHOR Nigel Horne E<lt>njh@nigelhorne.comE<gt> L<https://github.com/nigelhorne> =head1 COPYRIGHT AND LICENSE Copyright 2025-2026 Nigel Horne. Usage is subject to license terms. The license terms of this software are as follows: =cut | |||||
| 365 | ||||||
| 366 | 1; | |||||