| File: | lib/App/Project/Doctor/Check/GitHubActions.pm |
| Coverage: | 90.8% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Project::Doctor::Check::GitHubActions; | |||||
| 2 | ||||||
| 3 | # This check validates that GitHub Actions workflow files are present and | |||||
| 4 | # syntactically correct. It uses App::Workflow::Lint for the actual YAML | |||||
| 5 | # validation. Note: it does NOT own the "missing CI entirely" error -- | |||||
| 6 | # that belongs to Check::CI. When the workflow directory is absent this | |||||
| 7 | # check emits only an informational finding. | |||||
| 8 | ||||||
| 9 | 2 2 2 | 1559 2 24 | use strict; | |||
| 10 | 2 2 2 | 3 2 46 | use warnings; | |||
| 11 | 2 2 2 | 3 1 6 | use autodie qw(:all); | |||
| 12 | ||||||
| 13 | # Inherit the standard check interface from Check::Base. | |||||
| 14 | 2 2 2 | 4212 2 7 | use parent -norequire, 'App::Project::Doctor::Check::Base'; | |||
| 15 | ||||||
| 16 | # croak dies at the caller's location; carp warns there. | |||||
| 17 | 2 2 2 | 58 1 48 | use Carp qw(croak carp); | |||
| 18 | # File::Spec builds OS-portable paths for the fix closure. | |||||
| 19 | 2 2 2 | 2 2 17 | use File::Spec; | |||
| 20 | # Readonly makes constants truly immutable at runtime. | |||||
| 21 | 2 2 2 | 3 2 706 | use Readonly; | |||
| 22 | ||||||
| 23 | our $VERSION = '0.02'; | |||||
| 24 | ||||||
| 25 | # The directory under the repo root where workflow files live. | |||||
| 26 | Readonly::Scalar my $WORKFLOW_DIR => '.github/workflows'; | |||||
| 27 | ||||||
| 28 | # Short name used in Finding.check_name and the text-report column. | |||||
| 29 | 1 | 179 | sub name { 'GitHub Actions' } | |||
| 30 | # One-line description for --help and verbose output. | |||||
| 31 | 1 | 2 | sub description { 'Workflow files are present and lint cleanly.' } | |||
| 32 | # This check can offer a fix (generate a workflow) when no files exist. | |||||
| 33 | 1 | 2 | sub can_fix { 1 } | |||
| 34 | # Run after CI (20) but before Meta (30). | |||||
| 35 | 1 | 2 | sub order { 25 } | |||
| 36 | ||||||
| 37 | sub check { | |||||
| 38 | 9 | 11 | my ($self, $ctx) = @_; | |||
| 39 | # Guard: require a proper Context object with filesystem helpers. | |||||
| 40 | 9 | 12 | croak 'check requires an App::Project::Doctor::Context' unless ref $ctx; | |||
| 41 | ||||||
| 42 | 9 | 7 | my @findings; | |||
| 43 | ||||||
| 44 | # If the workflow directory doesn't exist at all, emit info and stop. | |||||
| 45 | # Check::CI already emits the error for a missing CI setup. | |||||
| 46 | 9 | 16 | unless ($ctx->has_file($WORKFLOW_DIR)) { | |||
| 47 | 2 | 5 | return _f( | |||
| 48 | severity => 'info', | |||||
| 49 | message => 'No .github/workflows/ -- skipping GitHub Actions validation.', | |||||
| 50 | ); | |||||
| 51 | } | |||||
| 52 | ||||||
| 53 | # The directory exists; find all YAML files inside it. | |||||
| 54 | 7 | 25 | my $workflow_files = $ctx->find_files($WORKFLOW_DIR, qr/\.ya?ml$/i); | |||
| 55 | ||||||
| 56 | # Directory present but empty of YAML: warn and offer to generate a default workflow. | |||||
| 57 | 7 7 | 15 9 | unless (@{$workflow_files}) { | |||
| 58 | 2 | 3 | return _f( | |||
| 59 | severity => 'warning', | |||||
| 60 | message => '.github/workflows/ exists but contains no YAML files.', | |||||
| 61 | fix => _fix_generate($ctx), | |||||
| 62 | ); | |||||
| 63 | } | |||||
| 64 | ||||||
| 65 | # Lint each workflow file and collect errors. | |||||
| 66 | 5 5 | 3 5 | for my $wf (@{$workflow_files}) { | |||
| 67 | 7 | 9 | my @errors = _lint_workflow($ctx->abs_path($wf)); | |||
| 68 | 7 | 16 | for my $err (@errors) { | |||
| 69 | # Each lint error becomes a separate Finding with file and optional line. | |||||
| 70 | push @findings, _f( | |||||
| 71 | severity => 'error', | |||||
| 72 | message => "Workflow '$wf': $err->{message}", | |||||
| 73 | file => $wf, | |||||
| 74 | 4 | 11 | defined $err->{line} ? (line => $err->{line}) : (), | |||
| 75 | ); | |||||
| 76 | } | |||||
| 77 | } | |||||
| 78 | ||||||
| 79 | # Only add a pass finding when no errors were collected. | |||||
| 80 | 5 | 6 | unless (@findings) { | |||
| 81 | push @findings, _f( | |||||
| 82 | severity => 'pass', | |||||
| 83 | 2 2 | 1 7 | message => sprintf('%d workflow file(s) validated OK.', scalar @{$workflow_files}), | |||
| 84 | ); | |||||
| 85 | } | |||||
| 86 | ||||||
| 87 | 5 | 13 | return @findings; | |||
| 88 | } | |||||
| 89 | ||||||
| 90 | # --------------------------------------------------------------------------- | |||||
| 91 | # Private helpers | |||||
| 92 | # --------------------------------------------------------------------------- | |||||
| 93 | ||||||
| 94 | # Purpose: Create a Finding with check_name pre-filled to 'GitHub Actions'. | |||||
| 95 | # Entry: %args is a valid Finding constructor argument list. | |||||
| 96 | # Exit: App::Project::Doctor::Finding object. | |||||
| 97 | # Side effects: None. | |||||
| 98 | sub _f { | |||||
| 99 | 10 | 21 | require App::Project::Doctor::Finding; | |||
| 100 | 10 | 34 | return App::Project::Doctor::Finding->new(check_name => 'GitHub Actions', @_); | |||
| 101 | } | |||||
| 102 | ||||||
| 103 | # Purpose: Run App::Workflow::Lint against a single workflow file and | |||||
| 104 | # normalise its output into a consistent list of error hashrefs. | |||||
| 105 | # Entry: $abs_path is the absolute path to the YAML file being linted. | |||||
| 106 | # Exit: List of hashrefs, each with 'message' (string) and optional 'line' (int). | |||||
| 107 | # Side effects: Loads App::Workflow::Lint if not already in memory. | |||||
| 108 | sub _lint_workflow { | |||||
| 109 | 4 | 7642 | my $abs_path = shift; | |||
| 110 | 4 | 282 | require App::Workflow::Lint; | |||
| 111 | # App::Workflow::Lint is instantiated fresh per call to avoid any state leakage. | |||||
| 112 | 4 | 29258 | my $linter = App::Workflow::Lint->new; | |||
| 113 | 4 | 17 | my @raw = $linter->lint($abs_path); | |||
| 114 | ||||||
| 115 | # Normalise: linter may return hashrefs OR plain strings. | |||||
| 116 | return map { | |||||
| 117 | 4 | 12 | ref $_ eq 'HASH' | |||
| 118 | ? { | |||||
| 119 | # Use the message key when present; fall back to a generic string. | |||||
| 120 | message => $_->{message} // '(unknown lint error)', | |||||
| 121 | # Only include 'line' when the linter actually provided one. | |||||
| 122 | 4 | 31 | (defined $_->{line} ? (line => $_->{line}) : ()), | |||
| 123 | } | |||||
| 124 | : { message => "$_" } # Plain string errors have no line number. | |||||
| 125 | } @raw; | |||||
| 126 | } | |||||
| 127 | ||||||
| 128 | # Purpose: Return a coderef that generates a GitHub Actions workflow file. | |||||
| 129 | # Entry: $ctx is the current App::Project::Doctor::Context. | |||||
| 130 | # Exit: Coderef ($ctx) -> void; creates .github/workflows/perl-ci.yml. | |||||
| 131 | # Side effects: Creates directories and files under $ctx->root when called. | |||||
| 132 | sub _fix_generate { | |||||
| 133 | 2 | 2 | my $ctx = shift; | |||
| 134 | # Return the fix as a closure; it captures $ctx but does not run yet. | |||||
| 135 | return sub { | |||||
| 136 | 1 | 2 | my $root = $ctx->root; | |||
| 137 | 1 | 4 | require App::GHGen::Generator; | |||
| 138 | # generate_workflow returns a YAML string or undef on failure. | |||||
| 139 | 1 | 2 | my $yaml = App::GHGen::Generator::generate_workflow('perl'); | |||
| 140 | 1 | 5 | return unless $yaml; # Nothing to write if generation failed. | |||
| 141 | # Build the target directory path in a cross-platform way. | |||||
| 142 | 0 | my $wf_dir = File::Spec->catdir($root, '.github', 'workflows'); | ||||
| 143 | 0 | require File::Path; | ||||
| 144 | # make_path creates .github/ and .github/workflows/ if they don't exist. | |||||
| 145 | 0 | File::Path::make_path($wf_dir); | ||||
| 146 | # Write the generated YAML to the standard workflow file name. | |||||
| 147 | 0 | open my $fh, '>', File::Spec->catfile($wf_dir, 'perl-ci.yml'); | ||||
| 148 | 0 0 | print {$fh} $yaml; | ||||
| 149 | 0 | close $fh; | ||||
| 150 | 2 | 5 | }; | |||
| 151 | } | |||||
| 152 | ||||||
| 153 | 1; | |||||
| 154 | ||||||