-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck_required_contexts.py
More file actions
130 lines (104 loc) · 4.36 KB
/
Copy pathcheck_required_contexts.py
File metadata and controls
130 lines (104 loc) · 4.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#!/usr/bin/env python3
"""Verify required-status-check drift between workflows and branch-protection.
Walks every workflow under .github/workflows/ (minus an EXEMPT list),
collects each job's display name, and compares to the ``contexts`` arrays
in .github/branch-protection/{main,develop}.json.
Fails CI when:
- A job exists in a workflow but is missing from the contexts list
(the drift class that lets a new check run without being required).
- A context is listed but no workflow job has that display name
(stale names that silently stop blocking merges).
Usage (from repo root):
uv run --with pyyaml python .github/scripts/check_required_contexts.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import yaml
WORKFLOWS_DIR = Path(".github/workflows")
PROTECTION_DIR = Path(".github/branch-protection")
# Workflows whose jobs are intentionally not required status checks.
# Keep the list short and explain each entry.
EXEMPT_WORKFLOWS: dict[str, str] = {
"codeql.yml": (
"Placeholder — gated to workflow_dispatch pending GHAS subscription."
),
"branch-protection.yml": (
"Runs only on main / schedule; never appears on PR check sets."
),
"eval-nightly.yml": (
"Scheduled / workflow_dispatch only; runs against the configured LLM"
" provider and never appears on PR check sets."
),
"artifact-cleanup.yml": (
"Scheduled / workflow_dispatch only; never appears on PR check sets."
),
"release-drafter.yml": (
"Runs on push to main + PR label events; drafts release notes,"
" never appears on PR check sets."
),
"release.yml": (
"Tag-triggered (v*.*.*); builds image, generates SBOM, publishes"
" release. Never appears on PR check sets."
),
}
def job_display_names(workflow_path: Path) -> list[str]:
"""Return the display name for every job in a workflow file.
GitHub's status-check context is the job's ``name:`` field; when unset
it falls back to the job key. Matrix jobs expand into one context per
matrix dimension, but the base name is what the contexts array stores.
"""
data = yaml.safe_load(workflow_path.read_text())
if not isinstance(data, dict) or "jobs" not in data:
return []
return [job.get("name") or key for key, job in data["jobs"].items()]
def collect_actual_contexts() -> set[str]:
"""Every job display name from every non-exempt workflow."""
contexts: set[str] = set()
for path in sorted(WORKFLOWS_DIR.glob("*.yml")):
if path.name in EXEMPT_WORKFLOWS:
continue
contexts.update(job_display_names(path))
return contexts
def check_protection_file(path: Path, actual: set[str]) -> bool:
"""Compare one branch-protection file's contexts array to actual jobs.
Returns True when they match exactly; logs GitHub-actions-style errors
and returns False on any drift.
"""
data = json.loads(path.read_text())
declared: set[str] = set(data["required_status_checks"]["contexts"])
missing = actual - declared
extra = declared - actual
if not (missing or extra):
return True
print(f"::error file={path}::drift detected in required_status_checks.contexts")
for name in sorted(missing):
print(f"::error file={path}:: + MISSING (job exists, not listed): {name!r}")
for name in sorted(extra):
print(f"::error file={path}:: - STALE (listed, no such job): {name!r}")
return False
def main() -> int:
actual = collect_actual_contexts()
if not actual:
print("::error::No workflow jobs discovered — check WORKFLOWS_DIR path.")
return 1
all_ok = True
for json_path in sorted(PROTECTION_DIR.glob("*.json")):
all_ok &= check_protection_file(json_path, actual)
if all_ok:
print("Branch-protection contexts are in sync with workflow jobs.")
print(
f" {len(actual)} required job(s) across "
f"{sum(1 for _ in PROTECTION_DIR.glob('*.json'))} branch(es)."
)
else:
print(
"\nFix: update .github/branch-protection/{main,develop}.json "
"contexts arrays to match the current workflow jobs, or add the "
"workflow to EXEMPT_WORKFLOWS in this script if intentionally "
"non-required."
)
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())