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
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
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
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
37sub 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.
98sub _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.
108sub _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.
132sub _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
1531;
154