File Coverage

File:lib/App/Project/Doctor/Check/GitHubActions.pm
Coverage:90.8%

linestmtbrancondsubtimecode
1package 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
1791
3
32
use strict;
10
2
2
2
4
2
57
use warnings;
11
2
2
2
4
2
8
use autodie qw(:all);
12
13# Inherit the standard check interface from Check::Base.
14
2
2
2
4448
1
9
use parent -norequire, 'App::Project::Doctor::Check::Base';
15
16# croak dies at the caller's location; carp warns there.
17
2
2
2
60
0
51
use Carp qw(croak carp);
18# File::Spec builds OS-portable paths for the fix closure.
19
2
2
2
4
1
20
use File::Spec;
20# Readonly makes constants truly immutable at runtime.
21
2
2
2
38
2
747
use Readonly;
22
23our $VERSION = '0.02';
24
25# The directory under the repo root where workflow files live.
26Readonly::Scalar my $WORKFLOW_DIR => '.github/workflows';
27
28# Short name used in Finding.check_name and the text-report column.
29
1
199
sub name        { 'GitHub Actions' }
30# One-line description for --help and verbose output.
31
1
3
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
37sub check {
38
9
11
        my ($self, $ctx) = @_;
39        # Guard: require a proper Context object with filesystem helpers.
40
9
13
        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
18
        unless ($ctx->has_file($WORKFLOW_DIR)) {
47
2
4
                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
22
        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
8
        unless (@{$workflow_files}) {
58
2
4
                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
5
6
        for my $wf (@{$workflow_files}) {
67
7
9
                my @errors = _lint_workflow($ctx->abs_path($wf));
68
7
15
                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
10
                                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
24
5
                        message  => sprintf('%d workflow file(s) validated OK.', scalar @{$workflow_files}),
84                );
85        }
86
87
5
12
        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.
98sub _f {
99
10
23
        require App::Project::Doctor::Finding;
100
10
33
        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.
108sub _lint_workflow {
109
5
38994
        my $abs_path = shift;
110
5
12
        require App::Workflow::Lint;
111        # App::Workflow::Lint is instantiated fresh per call to avoid any state leakage.
112
5
9
        my $linter = App::Workflow::Lint->new;
113        # The public API is check_file(), not lint() -- see App::Workflow::Lint docs.
114
5
14
        my @raw    = $linter->check_file($abs_path);
115
116        # Normalise: linter may return hashrefs OR plain strings.
117        return map {
118
5
14
                ref $_ eq 'HASH'
119                        ? {
120                                # Use the message key when present; fall back to a generic string.
121                                message => $_->{message} // '(unknown lint error)',
122                                # Only include 'line' when the linter actually provided one.
123
4
32
                                (defined $_->{line} ? (line => $_->{line}) : ()),
124                          }
125                        : { message => "$_" }    # Plain string errors have no line number.
126        } @raw;
127}
128
129# Purpose:    Return a coderef that generates a GitHub Actions workflow file.
130# Entry:      $ctx is the current App::Project::Doctor::Context.
131# Exit:       Coderef ($ctx) -> void; creates .github/workflows/perl-ci.yml.
132# Side effects: Creates directories and files under $ctx->root when called.
133sub _fix_generate {
134
2
2
        my $ctx = shift;
135        # Return the fix as a closure; it captures $ctx but does not run yet.
136        return sub {
137
1
3
                my $root = $ctx->root;
138
1
5
                require App::GHGen::Generator;
139                # generate_workflow returns a YAML string or undef on failure.
140
1
2
                my $yaml = App::GHGen::Generator::generate_workflow('perl');
141
1
5
                return unless $yaml;    # Nothing to write if generation failed.
142                # Build the target directory path in a cross-platform way.
143
0
                my $wf_dir = File::Spec->catdir($root, '.github', 'workflows');
144
0
                require File::Path;
145                # make_path creates .github/ and .github/workflows/ if they don't exist.
146
0
                File::Path::make_path($wf_dir);
147                # Write the generated YAML to the standard workflow file name.
148
0
                open my $fh, '>', File::Spec->catfile($wf_dir, 'perl-ci.yml');
149
0
0
                print {$fh} $yaml;
150
0
                close $fh;
151
2
5
        };
152}
153
1541;
155