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

1"""Actionlint tool definition. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import subprocess # nosec B404 - used safely with shell disabled 

11from dataclasses import dataclass 

12from typing import Any 

13 

14import click 

15 

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 

25 

26# Constants for Actionlint configuration 

27ACTIONLINT_DEFAULT_TIMEOUT: int = 30 

28ACTIONLINT_DEFAULT_PRIORITY: int = 40 

29ACTIONLINT_FILE_PATTERNS: list[str] = ["*.yml", "*.yaml"] 

30 

31 

32@register_tool 

33@dataclass 

34class ActionlintPlugin(BaseToolPlugin): 

35 """GitHub Actions workflow linter plugin. 

36 

37 This plugin integrates actionlint with Lintro for checking GitHub Actions 

38 workflow files against common issues and best practices. 

39 """ 

40 

41 @property 

42 def definition(self) -> ToolDefinition: 

43 """Return the tool definition. 

44 

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 ) 

64 

65 def set_options( 

66 self, 

67 **kwargs: Any, 

68 ) -> None: 

69 """Set Actionlint-specific options. 

70 

71 Args: 

72 **kwargs: Other tool options. 

73 """ 

74 super().set_options(**kwargs) 

75 

76 def _build_command(self) -> list[str]: 

77 """Build the base actionlint command. 

78 

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. 

84 

85 Returns: 

86 The base command list for invoking actionlint. 

87 """ 

88 return ["actionlint"] 

89 

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. 

97 

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) 

107 

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 

122 

123 def doc_url(self, code: str) -> str | None: 

124 """Return actionlint documentation URL. 

125 

126 Actionlint uses a single documentation page for all checks. 

127 

128 Args: 

129 code: Actionlint check identifier (unused, single doc page). 

130 

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 

137 

138 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

139 """Check GitHub Actions workflow files with actionlint. 

140 

141 Args: 

142 paths: List of file or directory paths to check. 

143 options: Runtime options that override defaults. 

144 

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] 

152 

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 ] 

157 

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 ) 

165 

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 } 

174 

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) 

187 

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 ) 

204 

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 ) 

218 

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 ) 

226 

227 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

228 """Actionlint cannot fix issues, only report them. 

229 

230 Args: 

231 paths: List of file or directory paths to fix. 

232 options: Tool-specific options. 

233 

234 Returns: 

235 ToolResult: Never returns, always raises NotImplementedError. 

236 

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 )