Files
metabuilder/frontends/nextjs/scripts/validate-workflows.py

205 lines
6.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Validate GitHub Actions workflows without requiring act to be installed.
This script checks:
- YAML syntax
- Required fields (name, on, jobs)
- Job structure
- Step structure
- Common issues and best practices
"""
import yaml
import sys
from pathlib import Path
from typing import Dict, List, Any
def validate_yaml_syntax(file_path: Path) -> tuple[bool, str]:
"""Validate YAML syntax of a workflow file."""
try:
with open(file_path, 'r') as f:
yaml.safe_load(f)
return True, ""
except yaml.YAMLError as e:
return False, str(e)
def validate_workflow_structure(file_path: Path, content: Dict[str, Any]) -> List[str]:
"""Validate the structure of a GitHub Actions workflow."""
issues = []
# Check required top-level fields
if 'name' not in content:
issues.append("Missing 'name' field")
# Note: 'on' is parsed as boolean True by YAML parser
if 'on' not in content and True not in content:
issues.append("Missing 'on' field (trigger events)")
if 'jobs' not in content:
issues.append("Missing 'jobs' field")
return issues # Can't continue without jobs
# Validate jobs
jobs = content.get('jobs', {})
if not isinstance(jobs, dict):
issues.append("'jobs' must be a dictionary")
return issues
if not jobs:
issues.append("No jobs defined")
return issues
# Check each job
for job_name, job_config in jobs.items():
if not isinstance(job_config, dict):
issues.append(f"Job '{job_name}' must be a dictionary")
continue
# Check for runs-on
if 'runs-on' not in job_config:
issues.append(f"Job '{job_name}' missing 'runs-on' field")
# Check for steps
if 'steps' not in job_config:
issues.append(f"Job '{job_name}' missing 'steps' field")
continue
steps = job_config.get('steps', [])
if not isinstance(steps, list):
issues.append(f"Job '{job_name}' steps must be a list")
continue
if not steps:
issues.append(f"Job '{job_name}' has no steps")
# Validate each step
for i, step in enumerate(steps):
if not isinstance(step, dict):
issues.append(f"Job '{job_name}' step {i+1} must be a dictionary")
continue
# Each step needs either 'uses' or 'run'
if 'uses' not in step and 'run' not in step:
step_name = step.get('name', f'step {i+1}')
issues.append(f"Job '{job_name}' step '{step_name}' must have either 'uses' or 'run'")
return issues
def check_common_issues(file_path: Path, content: Dict[str, Any]) -> List[str]:
"""Check for common issues and best practices."""
warnings = []
# Check for pinned action versions (security best practice)
jobs = content.get('jobs', {})
for job_name, job_config in jobs.items():
steps = job_config.get('steps', [])
for step in steps:
if 'uses' in step:
action = step['uses']
# Check if it's using @main, @master, @latest, or version tag without hash
if '@main' in action or '@master' in action or '@latest' in action:
warnings.append(
f"Job '{job_name}' uses unpinned action '{action}'. "
f"Consider pinning to a specific commit SHA for security."
)
# Check for working directory consistency
jobs = content.get('jobs', {})
working_dirs = set()
for job_name, job_config in jobs.items():
defaults = job_config.get('defaults', {})
run_config = defaults.get('run', {})
if 'working-directory' in run_config:
working_dirs.add(run_config['working-directory'])
if len(working_dirs) > 1:
warnings.append(
f"Multiple working directories used: {', '.join(working_dirs)}. "
f"Ensure this is intentional."
)
return warnings
def main():
"""Main validation function."""
project_root = Path(__file__).parent.parent.parent.parent
workflow_dir = project_root / '.github' / 'workflows'
if not workflow_dir.exists():
print(f"❌ Workflow directory not found: {workflow_dir}")
sys.exit(1)
print("🔍 GitHub Actions Workflow Validation")
print("=" * 50)
print()
all_files = list(workflow_dir.rglob('*.yml'))
if not all_files:
print("❌ No workflow files found")
sys.exit(1)
print(f"Found {len(all_files)} workflow file(s)")
print()
total_issues = 0
total_warnings = 0
for yml_file in sorted(all_files):
relative_path = yml_file.relative_to(project_root)
print(f"📄 Validating {relative_path}")
# Validate YAML syntax
is_valid, error = validate_yaml_syntax(yml_file)
if not is_valid:
print(f" ❌ YAML Syntax Error: {error}")
total_issues += 1
print()
continue
# Load content for structure validation
with open(yml_file, 'r') as f:
content = yaml.safe_load(f)
# Validate structure
issues = validate_workflow_structure(yml_file, content)
if issues:
print(f" ❌ Found {len(issues)} structural issue(s):")
for issue in issues:
print(f" - {issue}")
total_issues += len(issues)
else:
print(f" ✅ Structure valid")
# Check for common issues
warnings = check_common_issues(yml_file, content)
if warnings:
print(f" ⚠️ Found {len(warnings)} warning(s):")
for warning in warnings:
print(f" - {warning}")
total_warnings += len(warnings)
print()
# Summary
print("=" * 50)
print("📊 Summary:")
print(f" Total files checked: {len(all_files)}")
print(f" Total issues: {total_issues}")
print(f" Total warnings: {total_warnings}")
if total_issues > 0:
print()
print("❌ Workflow validation failed!")
sys.exit(1)
elif total_warnings > 0:
print()
print("⚠️ Workflow validation passed with warnings")
sys.exit(0)
else:
print()
print("✅ All workflows are valid!")
sys.exit(0)
if __name__ == '__main__':
main()