Coverage for lintro / tools / definitions / actionlint.py: 78%
73 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Actionlint tool definition.
3Actionlint is a static checker for GitHub Actions workflow files.
4It validates workflow syntax, checks for common issues, and helps
5maintain best practices in CI/CD workflows.
6"""
8from __future__ import annotations
10import subprocess # nosec B404 - used safely with shell disabled
11from dataclasses import dataclass
12from typing import Any
14import click
16from lintro._tool_versions import get_min_version
17from lintro.enums.doc_url_template import DocUrlTemplate
18from lintro.enums.tool_name import ToolName
19from lintro.enums.tool_type import ToolType
20from lintro.models.core.tool_result import ToolResult
21from lintro.parsers.actionlint.actionlint_parser import parse_actionlint_output
22from lintro.plugins.base import BaseToolPlugin
23from lintro.plugins.protocol import ToolDefinition
24from lintro.plugins.registry import register_tool
26# Constants for Actionlint configuration
27ACTIONLINT_DEFAULT_TIMEOUT: int = 30
28ACTIONLINT_DEFAULT_PRIORITY: int = 40
29ACTIONLINT_FILE_PATTERNS: list[str] = ["*.yml", "*.yaml"]
32@register_tool
33@dataclass
34class ActionlintPlugin(BaseToolPlugin):
35 """GitHub Actions workflow linter plugin.
37 This plugin integrates actionlint with Lintro for checking GitHub Actions
38 workflow files against common issues and best practices.
39 """
41 @property
42 def definition(self) -> ToolDefinition:
43 """Return the tool definition.
45 Returns:
46 ToolDefinition containing tool metadata.
47 """
48 return ToolDefinition(
49 name="actionlint",
50 description="Static checker for GitHub Actions workflows",
51 can_fix=False,
52 tool_type=ToolType.LINTER | ToolType.INFRASTRUCTURE,
53 file_patterns=ACTIONLINT_FILE_PATTERNS,
54 priority=ACTIONLINT_DEFAULT_PRIORITY,
55 conflicts_with=[],
56 native_configs=[],
57 version_command=["actionlint", "--version"],
58 min_version=get_min_version(ToolName.ACTIONLINT),
59 default_options={
60 "timeout": ACTIONLINT_DEFAULT_TIMEOUT,
61 },
62 default_timeout=ACTIONLINT_DEFAULT_TIMEOUT,
63 )
65 def set_options(
66 self,
67 **kwargs: Any,
68 ) -> None:
69 """Set Actionlint-specific options.
71 Args:
72 **kwargs: Other tool options.
73 """
74 super().set_options(**kwargs)
76 def _build_command(self) -> list[str]:
77 """Build the base actionlint command.
79 We intentionally avoid flags here for maximum portability across
80 platforms and actionlint versions. The tool's default text output
81 follows the conventional ``file:line:col: message [CODE]`` format,
82 which our parser handles directly without requiring a custom format
83 switch.
85 Returns:
86 The base command list for invoking actionlint.
87 """
88 return ["actionlint"]
90 def _process_single_file(
91 self,
92 file_path: str,
93 timeout: int,
94 results: dict[str, Any],
95 ) -> None:
96 """Process a single file with actionlint.
98 Args:
99 file_path: Path to the file to process.
100 timeout: Timeout in seconds.
101 results: Mutable dict to accumulate results.
102 """
103 cmd = self._build_command() + [file_path]
104 try:
105 success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
106 issues = parse_actionlint_output(output)
108 if not success:
109 results["all_success"] = False
110 if output and (issues or not success):
111 results["all_outputs"].append(output)
112 if issues:
113 results["all_issues"].extend(issues)
114 except subprocess.TimeoutExpired:
115 results["skipped_files"].append(file_path)
116 results["all_success"] = False
117 results["execution_failures"] += 1
118 except (OSError, ValueError, RuntimeError) as e: # pragma: no cover
119 results["all_success"] = False
120 results["all_outputs"].append(f"Error checking {file_path}: {e}")
121 results["execution_failures"] += 1
123 def doc_url(self, code: str) -> str | None:
124 """Return actionlint documentation URL.
126 Actionlint uses a single documentation page for all checks.
128 Args:
129 code: Actionlint check identifier (unused, single doc page).
131 Returns:
132 URL to the actionlint checks documentation, or None if code is empty.
133 """
134 if not code:
135 return None
136 return DocUrlTemplate.ACTIONLINT
138 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
139 """Check GitHub Actions workflow files with actionlint.
141 Args:
142 paths: List of file or directory paths to check.
143 options: Runtime options that override defaults.
145 Returns:
146 ToolResult with check results.
147 """
148 # Use shared preparation for version check, path validation, file discovery
149 ctx = self._prepare_execution(paths, options)
150 if ctx.should_skip:
151 return ctx.early_result # type: ignore[return-value]
153 # Restrict to GitHub Actions workflow location
154 workflow_files: list[str] = [
155 f for f in ctx.files if "/.github/workflows/" in f.replace("\\", "/")
156 ]
158 if not workflow_files:
159 return ToolResult(
160 name=self.definition.name,
161 success=True,
162 output="No GitHub workflow files found to check.",
163 issues_count=0,
164 )
166 # Accumulate results across all files
167 results: dict[str, Any] = {
168 "all_outputs": [],
169 "all_issues": [],
170 "all_success": True,
171 "skipped_files": [],
172 "execution_failures": 0,
173 }
175 # Show progress bar only when processing multiple files
176 if len(workflow_files) >= 2:
177 with click.progressbar(
178 workflow_files,
179 label="Processing files",
180 bar_template="%(label)s %(info)s",
181 ) as bar:
182 for file_path in bar:
183 self._process_single_file(file_path, ctx.timeout, results)
184 else:
185 for file_path in workflow_files:
186 self._process_single_file(file_path, ctx.timeout, results)
188 # Build combined output
189 combined_output = (
190 "\n".join(results["all_outputs"]) if results["all_outputs"] else None
191 )
192 if results["skipped_files"]:
193 timeout_msg = (
194 f"Skipped {len(results['skipped_files'])} file(s) due to timeout "
195 f"({ctx.timeout}s limit exceeded):"
196 )
197 for file in results["skipped_files"]:
198 timeout_msg += f"\n - {file}"
199 combined_output = (
200 f"{combined_output}\n\n{timeout_msg}"
201 if combined_output
202 else timeout_msg
203 )
205 non_timeout_failures = results["execution_failures"] - len(
206 results["skipped_files"],
207 )
208 if non_timeout_failures > 0:
209 failure_msg = (
210 f"Failed to process {non_timeout_failures} file(s) "
211 "due to execution errors"
212 )
213 combined_output = (
214 f"{combined_output}\n\n{failure_msg}"
215 if combined_output
216 else failure_msg
217 )
219 return ToolResult(
220 name=self.definition.name,
221 success=results["all_success"],
222 output=combined_output,
223 issues_count=len(results["all_issues"]),
224 issues=results["all_issues"],
225 )
227 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
228 """Actionlint cannot fix issues, only report them.
230 Args:
231 paths: List of file or directory paths to fix.
232 options: Tool-specific options.
234 Returns:
235 ToolResult: Never returns, always raises NotImplementedError.
237 Raises:
238 NotImplementedError: Actionlint does not support fixing issues.
239 """
240 raise NotImplementedError(
241 "Actionlint cannot automatically fix issues. Run 'lintro check' to see "
242 "issues.",
243 )