Source code for validate_actions.rules.action_version

"""Validates version specifications in workflow action 'uses:' fields."""
import re
from typing import Generator, List, Optional, Tuple

import requests

from validate_actions.domain_model.ast import ExecAction
from validate_actions.globals.problems import Problem, ProblemLevel
from validate_actions.rules.rule import Rule


[docs] class ActionVersion(Rule): """Validates the version specifications in workflow action 'uses:' fields. This rule checks GitHub Actions workflow steps that reference external actions via the 'uses:' field. It validates version specifications and checks for outdated versions. Key validations: - Warns when actions don't specify version tags - Detects outdated action versions (supports semantic versioning and commit SHAs) - Provides auto-fix capabilities for version updates """ NAME = "action-version" # ==================== # MAIN VALIDATION METHODS # ====================
[docs] def check(self) -> Generator[Problem, None, None]: """Validates all actions in the workflow for version issues. Iterates through all workflow jobs and their steps, collecting ExecAction instances (steps that use the 'uses:' field) and validates them for version specifications. Yields: Problem: Problems found during validation including version warnings and outdated version issues. """ actions = [] for job in self.workflow.jobs_.values(): steps = job.steps_ for step in steps: if isinstance(step.exec, ExecAction): actions.append(step.exec) return self._check_single_action(actions)
def _check_single_action( self, actions: List[ExecAction], ) -> Generator[Problem, None, None]: """Validates each action individually for version issues. Processes each ExecAction to check version specifications. Args: actions: List of ExecAction instances to validate. Yields: Problem: Problems found including version warnings and outdated versions. """ for action in actions: yield from self._not_using_version_spec(action) yield from self._is_outdated_version(action) # ==================== # VERSION VALIDATION METHODS # ==================== def _not_using_version_spec(self, action: ExecAction) -> Generator[Problem, None, None]: """Checks if an action specifies a version using '@version'. GitHub Actions best practices recommend pinning actions to specific versions rather than using default branches. This method warns when no version is specified and can auto-fix by adding the latest available version. Args: action: The ExecAction to validate for version specification. Yields: Problem: Warning if no version is specified, with optional auto-fix. """ slug = action.uses_.string if "@" not in slug or not slug.split("@", 1)[1]: # Check if there's no version spec # Check if (1) there is no '@' or (2) if the part after '@' is empty latest_version = self._get_current_action_version(action) version_suggestion = f"@{latest_version}" if latest_version else "@version" problem = Problem( action.pos, ProblemLevel.WAR, f"Using specific version of {slug} is recommended. " f"Consider using {slug}{version_suggestion}", self.NAME, ) problem = self._fix_not_using_version_spec(action, slug, problem) yield problem def _is_outdated_version(self, action: ExecAction) -> Generator[Problem, None, None]: """ Checks if an action is using an outdated version and generates warnings. Handles full versions (v4.2.1), partial versions (v4), and commit SHAs. Uses GitHub Actions semantics where 'v4' resolves to latest 'v4.x.x'. Args: action (ExecAction): The action to validate. Yields: Problem: Warning if version is outdated, with auto-fix support. """ slug = action.uses_.string # Skip actions without version specs - handled by not_using_version_spec if "@" not in slug: return action_slug, version_spec = slug.rsplit("@", 1) # Skip empty version specs if not version_spec: return try: # Get the current latest version for this action current_latest = self._get_current_action_version(action) if not current_latest: # Can't check if we can't fetch action metadata (e.g., private repo) return # Parse the current latest version current_parsed = self._parse_semantic_version(current_latest) if not current_parsed or None in current_parsed: # Current version is not a valid semantic version return # Convert to complete tuple for comparison current_tuple = self._ensure_complete_version_tuple(current_parsed) # Handle different version spec types if self._is_commit_sha(version_spec): # Handle commit SHA by finding its corresponding version yield from self._handle_commit_sha_version( action, action_slug, version_spec, current_latest, current_tuple ) else: # Handle semantic version (partial or full) yield from self._handle_semantic_version( action, action_slug, version_spec, current_latest, current_tuple ) except (requests.RequestException, ValueError, TypeError, IndexError): # Graceful handling of expected errors during version checking # Network issues, parsing errors, or malformed version data return # ==================== # UTILITY METHODS # ==================== def _get_current_action_version(self, action: ExecAction) -> Optional[str]: """Retrieves the latest version tag for an action from its metadata. Args: action: The ExecAction containing metadata with version information. Returns: The name of the latest version tag, or None if no version data available. """ if ( action.metadata is not None and action.metadata.version_tags is not None and isinstance(action.metadata.version_tags, list) and len(action.metadata.version_tags) > 0 ): return action.metadata.version_tags[0].get("name") return None def _parse_semantic_version( self, version_str: str ) -> Optional[Tuple[int, Optional[int], Optional[int]]]: """Parse semantic version string into tuple with explicit None for missing components. This function parses exactly what's provided without making assumptions. For GitHub Actions version resolution, use resolve_version_to_latest(). Examples: "v4.2.1" -> (4, 2, 1) # Full version "v4.2" -> (4, 2, None) # Minor specified, patch missing "v4" -> (4, None, None) # Only major specified "invalid" -> None # Parse error WARNING: Do not assume None means 0! Use resolve_version_to_latest() for GitHub Actions semantics where "v4" means "latest v4.x.x". """ if not version_str: return None # Remove 'v' prefix if present version_str = version_str.lstrip("v") # Split on dots and validate parts = version_str.split(".") if len(parts) > 3: return None try: # Parse only the parts that were explicitly provided major = int(parts[0]) if len(parts) > 0 else None minor = int(parts[1]) if len(parts) > 1 else None patch = int(parts[2]) if len(parts) > 2 else None # Must have at least major version if major is None: return None return (major, minor, patch) except (ValueError, IndexError): return None def _ensure_complete_version_tuple( self, parsed_version: Tuple[int, Optional[int], Optional[int]] ) -> Tuple[int, int, int]: """Converts a parsed version tuple to a complete tuple with no None values. Args: parsed_version: A version tuple that may contain None values. Returns: A complete version tuple with 0 substituted for None values. Raises: ValueError: If the major version component is None. """ major, minor, patch = parsed_version if major is None: raise ValueError("Major version cannot be None") return (major, minor or 0, patch or 0) def _is_commit_sha(self, version_str: str) -> bool: """Check if a version string is a commit SHA. A commit SHA is a hex string of at least 7 characters. """ if not version_str or len(version_str) < 7: return False # Check if all characters are hexadecimal return re.match(r"^[a-f0-9]+$", version_str.lower()) is not None def _compare_semantic_versions( self, current: Tuple[int, int, int], used: Tuple[int, int, int] ) -> Optional[str]: """Compare two FULLY RESOLVED semantic version tuples. WARNING: Both versions must be fully resolved (no None components). Use resolve_version_to_latest() first for partial versions like "v4". Args: current: The current/latest version tuple (must be complete) used: The version being used (must be complete) Returns: "major" if major version is outdated "minor" if minor version is outdated "patch" if patch version is outdated None if used version is current or newer """ current_major, current_minor, current_patch = current used_major, used_minor, used_patch = used # Validate that we have complete versions if None in [ current_major, current_minor, current_patch, used_major, used_minor, used_patch, ]: raise ValueError( "Cannot compare partial versions. Use resolve_version_to_latest() first." ) # Check if used version is newer or equal if (used_major, used_minor, used_patch) >= (current_major, current_minor, current_patch): return None # Check outdated level if used_major < current_major: return "major" elif used_minor < current_minor: return "minor" elif used_patch < current_patch: return "patch" return None def _resolve_version_to_latest( self, action: ExecAction, partial_version: str ) -> Optional[str]: """Resolves partial version like 'v4' to latest matching version like 'v4.2.2'. This handles the GitHub Actions semantic where "v4" means "latest v4.x.x". Uses the action's metadata to find all available version tags and returns the highest semantic version that matches the partial specification. Args: action: ExecAction containing metadata with available version tags. partial_version: Partial version specification like 'v4' or 'v4.2'. Returns: Latest matching version string or None if not found. """ tags = action.metadata.version_tags if action.metadata else None if not tags or len(tags) == 0: return None # Parse the partial version partial_parsed = self._parse_semantic_version(partial_version) if not partial_parsed: return None partial_major, partial_minor, partial_patch = partial_parsed # Find all tags that match the partial version pattern matching_versions = [] for tag in tags: tag_name = tag.get("name", "") tag_parsed = self._parse_semantic_version(tag_name) if not tag_parsed: continue tag_major, tag_minor, tag_patch = tag_parsed # Skip if any components are None (tag is also partial) if tag_major is None: continue # Match based on how many components were specified in partial_version if partial_minor is None: # e.g., "v4" - match any v4.x.x if tag_major == partial_major and tag_minor is not None and tag_patch is not None: matching_versions.append(((tag_major, tag_minor, tag_patch), tag_name)) elif partial_patch is None: # e.g., "v4.2" - match any v4.2.x if ( tag_major == partial_major and tag_minor == partial_minor and tag_patch is not None ): matching_versions.append(((tag_major, tag_minor, tag_patch), tag_name)) else: # Full version - return exact match if ( tag_major == partial_major and tag_minor == partial_minor and tag_patch == partial_patch ): return tag_name # Return the highest version among matches if matching_versions: matching_versions.sort(reverse=True, key=lambda x: x[0]) # Sort by version tuple return matching_versions[0][1] # Return tag name return None # ==================== # VERSION HANDLING METHODS # ==================== def _handle_commit_sha_version( self, action: ExecAction, action_slug: str, commit_sha: str, current_latest: str, current_tuple: Tuple[int, int, int], ) -> Generator[Problem, None, None]: """Handle version checking for commit SHA specifications.""" # Get all tags to find which version this commit corresponds to tags = action.metadata.version_tags if action.metadata else None if not tags or len(tags) == 0: return # Find the tag that matches this commit SHA commit_version = None # Ensure minimum meaningful SHA length for matching if len(commit_sha) < 7: return for tag in tags: tag_commit = tag.get("commit", {}).get("sha", "") # Only match if the tag's commit starts with our SHA (prefix match) # Require at least 7 characters for confident matching if tag_commit and tag_commit.startswith(commit_sha): commit_version = tag.get("name") break if not commit_version: # Unknown commit, generate warning with generic message problem = Problem( action.pos, ProblemLevel.WAR, f"Action {action_slug} uses commit SHA which may be outdated. " f"Current latest version is {current_latest}. Consider using versioned tags.", self.NAME, ) problem = self._fix_commit_sha_version( action, action_slug, commit_sha, current_latest, problem ) yield problem return # Parse the commit's corresponding version commit_parsed = self._parse_semantic_version(commit_version) if not commit_parsed or None in commit_parsed: return # Convert to complete tuple for comparison commit_tuple = self._ensure_complete_version_tuple(commit_parsed) # Compare versions outdated_level = self._compare_semantic_versions(current_tuple, commit_tuple) if outdated_level: problem = Problem( action.pos, ProblemLevel.WAR, f"Action {action_slug} uses commit SHA " f"(corresponds to {commit_version}) which is {outdated_level} " f"version outdated. Current latest is {current_latest}.", self.NAME, ) problem = self._fix_outdated_commit_sha_version( action, action_slug, commit_sha, current_latest, problem ) yield problem def _handle_semantic_version( self, action: ExecAction, action_slug: str, version_spec: str, current_latest: str, current_tuple: Tuple[int, int, int], ) -> Generator[Problem, None, None]: """Handle version checking for semantic version specifications.""" # Parse the used version spec used_parsed = self._parse_semantic_version(version_spec) if not used_parsed: # Invalid version format, skip return # Check if this is a partial version that needs resolution if None in used_parsed: # Resolve partial version (e.g., v4 -> v4.2.2) resolved_version = self._resolve_version_to_latest(action, version_spec) if not resolved_version: # Version spec cannot be resolved - this is a problem! # E.g., actions/cache@v2 when only v3+ exists problem = Problem( action.pos, ProblemLevel.WAR, f"Action {action_slug} uses outdated {version_spec} which " f"cannot be resolved to any available version. " f"Current latest is {current_latest}.", self.NAME, ) problem = self._fix_unresolvable_version( action, action_slug, version_spec, current_latest, problem ) yield problem return # Parse the resolved version resolved_parsed = self._parse_semantic_version(resolved_version) if not resolved_parsed or None in resolved_parsed: return # Convert to complete tuple for comparison resolved_tuple = self._ensure_complete_version_tuple(resolved_parsed) # For partial versions, compare the resolved version outdated_level = self._compare_semantic_versions(current_tuple, resolved_tuple) if outdated_level: problem = Problem( action.pos, ProblemLevel.WAR, f"Action {action_slug} uses {version_spec} " f"(resolves to {resolved_version}) which is {outdated_level} " f"version outdated. Current latest is {current_latest}.", self.NAME, ) problem = self._fix_outdated_partial_version( action, action_slug, version_spec, current_latest, problem ) yield problem else: # Full version specification - validate it's complete if None in used_parsed: # This shouldn't happen for full versions, but safety check return # Convert to complete tuple for comparison full_tuple = self._ensure_complete_version_tuple(used_parsed) # Compare versions outdated_level = self._compare_semantic_versions(current_tuple, full_tuple) if outdated_level: problem = Problem( action.pos, ProblemLevel.WAR, f"Action {action_slug} uses {version_spec} which is " f"{outdated_level} version outdated. Current latest is {current_latest}.", self.NAME, ) problem = self._fix_outdated_full_version( action, action_slug, version_spec, current_latest, problem ) yield problem # ==================== # FIXING METHODS # ==================== def _fix_not_using_version_spec( self, action: ExecAction, slug: str, problem: Problem ) -> Problem: """Fix missing version specification by adding latest version.""" version = self._get_current_action_version(action) if version: new_slug = f"{slug}@{version}" problem = self.fixer.edit_yaml_at_position( action.uses_.pos.idx, slug, new_slug, problem, f"Fixed '{slug}' to include version to '{new_slug}'", ) action.uses_.string = f"{slug}@{version}" return problem def _fix_commit_sha_version( self, action: ExecAction, action_slug: str, commit_sha: str, current_latest: str, problem: Problem, ) -> Problem: """Fix commit SHA version by updating to latest version.""" problem = self.fixer.edit_yaml_at_position( action.uses_.pos.idx + len(action_slug) + 1, # +1 for '@' commit_sha, current_latest, problem, f"Updated commit SHA to latest version {current_latest}", ) action.uses_.string = f"{action_slug}@{current_latest}" return problem def _fix_outdated_commit_sha_version( self, action: ExecAction, action_slug: str, commit_sha: str, current_latest: str, problem: Problem, ) -> Problem: """Fix outdated commit SHA version by updating to latest version.""" problem = self.fixer.edit_yaml_at_position( action.uses_.pos.idx + len(action_slug) + 1, # +1 for '@' commit_sha, current_latest, problem, f"Updated outdated commit SHA to latest version {current_latest}", ) action.uses_.string = f"{action_slug}@{current_latest}" return problem def _fix_unresolvable_version( self, action: ExecAction, action_slug: str, version_spec: str, current_latest: str, problem: Problem, ) -> Problem: """Fix unresolvable version by updating to latest version.""" problem = self.fixer.edit_yaml_at_position( action.uses_.pos.idx + len(action_slug) + 1, # +1 for '@' version_spec, current_latest, problem, f"Fixed unresolvable version {version_spec} to latest {current_latest}", ) action.uses_.string = f"{action_slug}@{current_latest}" return problem def _fix_outdated_partial_version( self, action: ExecAction, action_slug: str, version_spec: str, current_latest: str, problem: Problem, ) -> Problem: """Fix outdated partial version by updating to latest version.""" problem = self.fixer.edit_yaml_at_position( action.uses_.pos.idx + len(action_slug) + 1, # +1 for '@' version_spec, current_latest, problem, f"Fixed outdated version {version_spec} to latest {current_latest}", ) action.uses_.string = f"{action_slug}@{current_latest}" return problem def _fix_outdated_full_version( self, action: ExecAction, action_slug: str, version_spec: str, current_latest: str, problem: Problem, ) -> Problem: """Fix outdated full version by updating to latest version.""" problem = self.fixer.edit_yaml_at_position( action.uses_.pos.idx + len(action_slug) + 1, # +1 for '@' version_spec, current_latest, problem, f"Fixed outdated version {version_spec} to latest {current_latest}", ) action.uses_.string = f"{action_slug}@{current_latest}" return problem