Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,17 @@ repos:
args: ["--fix"]
files: '\.(py|pyi)$'

# TODO Phase 4: Add workflow-specific validation
# Workflow YAML validation for templates
- repo: local
hooks:
- id: validate-workflow-templates
name: Validate workflow templates
entry: python3 scripts/validate_workflow_yaml.py
language: system
files: '^templates/consumer-repo/.github/workflows/.*\.yml$'
pass_filenames: true

# TODO Phase 4: Add actionlint
# - repo: https://github.com/rhysd/actionlint
# rev: v1.6.26
# hooks:
Expand Down
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,30 @@ Secrets use **lowercase** in `workflow_call` definitions but reference org secre
4. **Run pre-sync validation** to ensure files pass consumer lint rules
5. **Sync to ALL consumer repos** to maintain consistency

## ⚠️ CRITICAL: Template Changes (READ THIS!)

**If you modify `templates/consumer-repo/` YOU WILL SYNC TO ALL CONSUMER REPOS.**

Before editing any template file:

```bash
# 1. Validate YAML syntax and style
python3 scripts/validate_workflow_yaml.py templates/consumer-repo/.github/workflows/*.yml

# 2. Check against repo standards (line-length = 100)
ruff check templates/consumer-repo/

# 3. Dry-run the sync to see impact
gh workflow run maint-68-sync-consumer-repos.yml -f dry_run=true
```

**Template changes will trigger PRs in 4+ consumer repos. One mistake = 4+ failing CI runs.**

Repo standards (from pyproject.toml):
- Line length: **100 characters**
- Format: black, ruff, isort
- All templates must pass validation before commit

## Quick Commands

```bash
Expand Down
158 changes: 158 additions & 0 deletions scripts/validate_workflow_yaml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Validate GitHub Actions workflow YAML files.

This script checks workflow files for common syntax errors and issues
that may not be caught by basic YAML parsers but cause failures in GitHub Actions.
"""

import argparse
import sys
from pathlib import Path

try:
import yaml
except ImportError:
print("ERROR: PyYAML is required. Install with: pip install PyYAML")
sys.exit(1)


def check_line_length(file_path: Path, max_length: int = 150) -> list[tuple[int, str]]:
"""Check for lines that exceed maximum length (may cause wrapping issues)."""
issues = []
with open(file_path) as f:
for line_num, line in enumerate(f, 1):
if len(line.rstrip()) > max_length:
issues.append((line_num, f"Line exceeds {max_length} characters"))
return issues


def check_runs_on_placement(file_path: Path) -> list[tuple[int, str]]:
"""Check that 'runs-on' is properly placed on its own line."""
issues = []
with open(file_path) as f:
for line_num, line in enumerate(f, 1):
stripped = line.strip()
if "runs-on:" in stripped:
# Check if there's content before 'runs-on:' on the same line
before_runs_on = line.split("runs-on:")[0].strip()
if before_runs_on and not before_runs_on.endswith("#"):
issues.append(
(
line_num,
"runs-on should be on its own line (found text before it)",
)
)
return issues


def check_yaml_syntax(file_path: Path) -> list[tuple[int, str]]:
"""Validate basic YAML syntax."""
issues = []
try:
with open(file_path) as f:
yaml.safe_load(f)
except yaml.YAMLError as e:
line_num = getattr(e, "problem_mark", None)
if line_num:
issues.append((line_num.line + 1, f"YAML syntax error: {e.problem}"))
else:
issues.append((0, f"YAML syntax error: {str(e)}"))
return issues


def check_multiline_conditions(file_path: Path) -> list[tuple[int, str]]:
"""Check for complex conditions that should use multiline format."""
issues = []
with open(file_path) as f:
lines = f.readlines()
for line_num, line in enumerate(lines, 1):
stripped = line.strip()
# Only flag if line exceeds repo standard (100 chars) OR continues improperly
if stripped.startswith("if:") and len(stripped) > 100:
# Check if it's using multiline format
if not stripped.endswith("|") and not stripped.endswith(">"):
issues.append(
(
line_num,
"Very long 'if' condition should use multiline format (| or >)",
)
)
# Check if next line looks like continuation without proper multiline syntax
elif stripped.startswith("if:") and line_num < len(lines):
next_line = lines[line_num].strip() if line_num < len(lines) else ""
# Check if 'runs-on:' appears mid-line (indicates malformed wrapping)
if next_line and "runs-on:" in next_line and not next_line.startswith("runs-on:"):
issues.append(
(
line_num + 1,
"Found 'runs-on:' not at start of line - possible malformed multiline 'if'",
)
)
return issues


def validate_workflow(file_path: Path, verbose: bool = False) -> bool:
"""Validate a workflow file and return True if valid."""
all_issues = []

# Run all checks
all_issues.extend([(line, f"YAML: {msg}") for line, msg in check_yaml_syntax(file_path)])
all_issues.extend([(line, f"Length: {msg}") for line, msg in check_line_length(file_path)])
all_issues.extend(
[(line, f"Placement: {msg}") for line, msg in check_runs_on_placement(file_path)]
)
all_issues.extend(
[(line, f"Format: {msg}") for line, msg in check_multiline_conditions(file_path)]
)

if all_issues:
print(f"\n❌ {file_path.name}: Found {len(all_issues)} issue(s)")
for line_num, message in sorted(all_issues):
if line_num > 0:
print(f" Line {line_num}: {message}")
else:
print(f" {message}")
return False
else:
if verbose:
print(f"✓ {file_path.name}: Valid")
return True


def main():
parser = argparse.ArgumentParser(description="Validate GitHub Actions workflow YAML files")
parser.add_argument(
"files",
nargs="+",
type=Path,
help="Workflow files to validate",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Show validation results for all files",
)
args = parser.parse_args()

all_valid = True
for file_path in args.files:
if not file_path.exists():
print(f"❌ {file_path}: File not found")
all_valid = False
continue

if not validate_workflow(file_path, args.verbose):
all_valid = False

if all_valid:
print(f"\n✓ All {len(args.files)} workflow file(s) validated successfully")
sys.exit(0)
else:
print("\n❌ Validation failed")
sys.exit(1)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ jobs:
# Gate for agent_bridge mode: only proceed if issue has agent:* or agents:* label
check_labels:
needs: route
if: needs.route.outputs.should_run_bridge == 'true' && (github.event_name != 'issues' || contains(toJson(github.event.issue.labels.*.name), 'agent:') || contains(toJson(github.event.issue.labels.*.name), 'agents:'))
if: |
needs.route.outputs.should_run_bridge == 'true' &&
(github.event_name != 'issues' ||
contains(toJson(github.event.issue.labels.*.name), 'agent:') ||
contains(toJson(github.event.issue.labels.*.name), 'agents:'))
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
Expand Down
Loading