|
|
@@ -0,0 +1,105 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+
|
|
|
+import pathlib
|
|
|
+import sys
|
|
|
+
|
|
|
+import yaml
|
|
|
+
|
|
|
+WORKFLOW_DIR = pathlib.Path(".github/workflows")
|
|
|
+
|
|
|
+
|
|
|
+def find_workflow_files():
|
|
|
+ if not WORKFLOW_DIR.exists():
|
|
|
+ return []
|
|
|
+ return sorted(
|
|
|
+ list(WORKFLOW_DIR.glob("*.yml")) + list(WORKFLOW_DIR.glob("*.yaml"))
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def check_jobs(data):
|
|
|
+ errors = []
|
|
|
+ jobs = data.get("jobs")
|
|
|
+
|
|
|
+ if jobs is None:
|
|
|
+ errors.append("missing top-level 'jobs' key")
|
|
|
+ return errors
|
|
|
+
|
|
|
+ if not isinstance(jobs, dict) or not jobs:
|
|
|
+ errors.append("'jobs' must be a non-empty mapping")
|
|
|
+ return errors
|
|
|
+
|
|
|
+ for job_name, job in jobs.items():
|
|
|
+ if not isinstance(job, dict):
|
|
|
+ errors.append(f"job '{job_name}': must be a mapping")
|
|
|
+ continue
|
|
|
+
|
|
|
+ if "runs-on" not in job and "uses" not in job:
|
|
|
+ errors.append(f"job '{job_name}': missing 'runs-on' (or 'uses' for reusable workflows)")
|
|
|
+
|
|
|
+ steps = job.get("steps")
|
|
|
+ if steps is None:
|
|
|
+ if "uses" not in job:
|
|
|
+ errors.append(f"job '{job_name}': missing 'steps'")
|
|
|
+ continue
|
|
|
+
|
|
|
+ if not isinstance(steps, list):
|
|
|
+ errors.append(f"job '{job_name}': 'steps' must be a list")
|
|
|
+ continue
|
|
|
+
|
|
|
+ for index, step in enumerate(steps, start=1):
|
|
|
+ if not isinstance(step, dict):
|
|
|
+ errors.append(f"job '{job_name}', step {index}: must be a mapping")
|
|
|
+ continue
|
|
|
+ if "run" not in step and "uses" not in step:
|
|
|
+ name = step.get("name", f"step {index}")
|
|
|
+ errors.append(f"job '{job_name}', '{name}': missing 'run' or 'uses'")
|
|
|
+
|
|
|
+ return errors
|
|
|
+
|
|
|
+
|
|
|
+def check_file(path):
|
|
|
+ errors = []
|
|
|
+ try:
|
|
|
+ with path.open(encoding="utf-8") as handle:
|
|
|
+ data = yaml.safe_load(handle)
|
|
|
+ except yaml.YAMLError as error:
|
|
|
+ mark = getattr(error, "problem_mark", None)
|
|
|
+ if mark is not None:
|
|
|
+ errors.append(f"YAML error at line {mark.line + 1}, column {mark.column + 1}: {error}")
|
|
|
+ else:
|
|
|
+ errors.append(f"YAML error: {error}")
|
|
|
+ return errors
|
|
|
+
|
|
|
+ if not isinstance(data, dict):
|
|
|
+ errors.append("file does not parse to a mapping at the top level")
|
|
|
+ return errors
|
|
|
+
|
|
|
+ # PyYAML parses the unquoted key 'on' as boolean True in YAML 1.1.
|
|
|
+ if "on" not in data and True not in data:
|
|
|
+ errors.append("missing top-level 'on' trigger key")
|
|
|
+
|
|
|
+ errors.extend(check_jobs(data))
|
|
|
+ return errors
|
|
|
+
|
|
|
+
|
|
|
+def main():
|
|
|
+ files = find_workflow_files()
|
|
|
+ if not files:
|
|
|
+ print(f"No workflow files found in {WORKFLOW_DIR}.")
|
|
|
+ return 0
|
|
|
+
|
|
|
+ status = 0
|
|
|
+ for path in files:
|
|
|
+ errors = check_file(path)
|
|
|
+ if errors:
|
|
|
+ status = 1
|
|
|
+ print(f"Issues in: {path}")
|
|
|
+ for error in errors:
|
|
|
+ print(f" {error}")
|
|
|
+
|
|
|
+ print(f"Checked {len(files)} workflow file(s).")
|
|
|
+ return status
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ sys.exit(main())
|