validate_workflows.py 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. #!/usr/bin/env python3
  2. import pathlib
  3. import sys
  4. import yaml
  5. WORKFLOW_DIR = pathlib.Path(".github/workflows")
  6. def find_workflow_files():
  7. if not WORKFLOW_DIR.exists():
  8. return []
  9. return sorted(
  10. list(WORKFLOW_DIR.glob("*.yml")) + list(WORKFLOW_DIR.glob("*.yaml"))
  11. )
  12. def check_jobs(data):
  13. errors = []
  14. jobs = data.get("jobs")
  15. if jobs is None:
  16. errors.append("missing top-level 'jobs' key")
  17. return errors
  18. if not isinstance(jobs, dict) or not jobs:
  19. errors.append("'jobs' must be a non-empty mapping")
  20. return errors
  21. for job_name, job in jobs.items():
  22. if not isinstance(job, dict):
  23. errors.append(f"job '{job_name}': must be a mapping")
  24. continue
  25. if "runs-on" not in job and "uses" not in job:
  26. errors.append(f"job '{job_name}': missing 'runs-on' (or 'uses' for reusable workflows)")
  27. steps = job.get("steps")
  28. if steps is None:
  29. if "uses" not in job:
  30. errors.append(f"job '{job_name}': missing 'steps'")
  31. continue
  32. if not isinstance(steps, list):
  33. errors.append(f"job '{job_name}': 'steps' must be a list")
  34. continue
  35. for index, step in enumerate(steps, start=1):
  36. if not isinstance(step, dict):
  37. errors.append(f"job '{job_name}', step {index}: must be a mapping")
  38. continue
  39. if "run" not in step and "uses" not in step:
  40. name = step.get("name", f"step {index}")
  41. errors.append(f"job '{job_name}', '{name}': missing 'run' or 'uses'")
  42. return errors
  43. def check_file(path):
  44. errors = []
  45. try:
  46. with path.open(encoding="utf-8") as handle:
  47. data = yaml.safe_load(handle)
  48. except yaml.YAMLError as error:
  49. mark = getattr(error, "problem_mark", None)
  50. if mark is not None:
  51. errors.append(f"YAML error at line {mark.line + 1}, column {mark.column + 1}: {error}")
  52. else:
  53. errors.append(f"YAML error: {error}")
  54. return errors
  55. if not isinstance(data, dict):
  56. errors.append("file does not parse to a mapping at the top level")
  57. return errors
  58. # PyYAML parses the unquoted key 'on' as boolean True in YAML 1.1.
  59. if "on" not in data and True not in data:
  60. errors.append("missing top-level 'on' trigger key")
  61. errors.extend(check_jobs(data))
  62. return errors
  63. def main():
  64. files = find_workflow_files()
  65. if not files:
  66. print(f"No workflow files found in {WORKFLOW_DIR}.")
  67. return 0
  68. status = 0
  69. for path in files:
  70. errors = check_file(path)
  71. if errors:
  72. status = 1
  73. print(f"Issues in: {path}")
  74. for error in errors:
  75. print(f" {error}")
  76. print(f"Checked {len(files)} workflow file(s).")
  77. return status
  78. if __name__ == "__main__":
  79. sys.exit(main())