Skip to content

Commit 887c129

Browse files
authored
chore: CI meta-gates (branch-protection contexts sync, commit-types sync) (#10) (#47)
1 parent 4131d46 commit 887c129

8 files changed

Lines changed: 686 additions & 3 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Branch protection
2+
3+
JSON specifications for the protection rules on `main` and `develop`. Apply via the `branch-protection.yml` workflow (ticket #14) once it lands; until then, this directory is read-only documentation that the `Branch-protection contexts sync` CI job verifies against the actual workflow jobs.
4+
5+
The `contexts` array must list every required check by its workflow `name:` field. The `Branch-protection contexts sync` job (`.github/scripts/check_required_contexts.py`) fails CI when:
6+
7+
- a workflow job exists but is missing from the contexts array (lets a new check run without being required)
8+
- a context is listed but no workflow job has that display name (stale entries that silently stop blocking merges)
9+
10+
Update this file in the same PR that adds or renames a CI job. Workflows that should NOT be required (scheduled, tag-triggered, label-only) live in the `EXEMPT_WORKFLOWS` map at the top of `check_required_contexts.py`.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"required_status_checks": {
3+
"strict": false,
4+
"contexts": [
5+
"Lint & Format",
6+
"Type Check",
7+
"Unit tests",
8+
"Coverage",
9+
"Architecture (import-linter)",
10+
"Pre-commit",
11+
"Branch-protection contexts sync",
12+
"Commit-type sync",
13+
"Lint PR title (conventional commits)"
14+
]
15+
},
16+
"enforce_admins": false,
17+
"required_pull_request_reviews": {
18+
"dismiss_stale_reviews": true,
19+
"require_code_owner_reviews": true,
20+
"required_approving_review_count": 1,
21+
"require_last_push_approval": false
22+
},
23+
"restrictions": null,
24+
"allow_force_pushes": false,
25+
"allow_deletions": false,
26+
"block_creations": false,
27+
"required_conversation_resolution": true,
28+
"lock_branch": false,
29+
"allow_fork_syncing": false
30+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"required_status_checks": {
3+
"strict": true,
4+
"contexts": [
5+
"Lint & Format",
6+
"Type Check",
7+
"Unit tests",
8+
"Coverage",
9+
"Architecture (import-linter)",
10+
"Pre-commit",
11+
"Branch-protection contexts sync",
12+
"Commit-type sync",
13+
"Lint PR title (conventional commits)"
14+
]
15+
},
16+
"enforce_admins": false,
17+
"required_pull_request_reviews": {
18+
"dismiss_stale_reviews": true,
19+
"require_code_owner_reviews": true,
20+
"required_approving_review_count": 1,
21+
"require_last_push_approval": false
22+
},
23+
"restrictions": null,
24+
"allow_force_pushes": false,
25+
"allow_deletions": false,
26+
"block_creations": false,
27+
"required_conversation_resolution": true,
28+
"lock_branch": false,
29+
"allow_fork_syncing": false
30+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python3
2+
"""Verify the commit-type allowlist stays in sync across two configs.
3+
4+
Seven prefixes are allowed on commits and PR titles: feat, fix, docs,
5+
test, refactor, chore, release. Two places enforce that list today:
6+
7+
1. ``[tool.commitizen].customize.schema_pattern`` in ``pyproject.toml`` —
8+
the commitizen regex (commit-msg hook, local).
9+
2. ``.github/workflows/pr-title.yml`` ``types:`` input to the
10+
``amannn/action-semantic-pull-request`` step — the PR-title CI check.
11+
12+
Both are hand-maintained. Add a type in one, forget the other, and the
13+
two layers drift: commits fail locally but PR titles pass (or vice
14+
versa). ``docs/DEVELOPMENT.md`` explicitly warns these must stay in
15+
sync, but prose warnings drift too.
16+
17+
This script mirrors the ``check_required_contexts.py`` pattern from #72
18+
for this second drift class. Fails CI when the two sets disagree in
19+
either direction.
20+
21+
Usage (from repo root):
22+
23+
uv run python .github/scripts/check_commit_types.py
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import re
29+
import sys
30+
import tomllib
31+
from pathlib import Path
32+
33+
import yaml
34+
35+
PYPROJECT = Path("pyproject.toml")
36+
PR_TITLE_YML = Path(".github/workflows/pr-title.yml")
37+
38+
# Matches the first alternation group in the commitizen schema_pattern.
39+
# Schema example: ^(feat|fix|rc2|hot-fix|...)(\([\w\-]+\))?!?:\s.+
40+
# Captures: feat|fix|rc2|hot-fix|...
41+
#
42+
# Character class [a-z0-9\-|]+ allows:
43+
# - lowercase letters (standard types: feat, fix, docs, ...)
44+
# - digits (release-candidate patterns: rc2, v2, ...)
45+
# - hyphens (compound types: hot-fix, post-release, ...)
46+
# - the `|` separator
47+
# Widened from [a-z|]+ (#91): the tighter class would silently truncate
48+
# extraction when a future type contained digits or hyphens.
49+
_SCHEMA_ALTERNATION_RE = re.compile(r"\^\(([a-z0-9\-|]+)\)")
50+
51+
52+
def commitizen_types() -> set[str]:
53+
"""Return the set of types allowed by the commitizen schema regex."""
54+
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
55+
schema: str = (
56+
data.get("tool", {})
57+
.get("commitizen", {})
58+
.get("customize", {})
59+
.get("schema_pattern", "")
60+
)
61+
if not schema:
62+
msg = "[tool.commitizen].customize.schema_pattern not found in pyproject.toml"
63+
raise ValueError(msg)
64+
match = _SCHEMA_ALTERNATION_RE.search(schema)
65+
if not match:
66+
msg = (
67+
"Could not extract the type alternation group from "
68+
f"schema_pattern: {schema!r}. Expected it to start with "
69+
"'^(<type>|<type>|...)'."
70+
)
71+
raise ValueError(msg)
72+
types = {t for t in match.group(1).split("|") if t}
73+
# Defensive: a malformed pattern like "^(|feat)..." could produce an
74+
# empty type after split. If nothing survives the filter, raise rather
75+
# than return a silent-pass empty set that would trivially match an
76+
# empty set from the other extractor (#92).
77+
if not types:
78+
msg = (
79+
"Empty type alternation extracted from schema_pattern. "
80+
f"Check pyproject.toml: {schema!r}"
81+
)
82+
raise ValueError(msg)
83+
return types
84+
85+
86+
def pr_title_types() -> set[str]:
87+
"""Return the set of types declared in the pr-title workflow."""
88+
data = yaml.safe_load(PR_TITLE_YML.read_text(encoding="utf-8"))
89+
for job in data.get("jobs", {}).values():
90+
for step in job.get("steps", []):
91+
uses = step.get("uses", "")
92+
if "action-semantic-pull-request" in uses:
93+
types_block: str = step.get("with", {}).get("types", "")
94+
types = {
95+
line.strip() for line in types_block.splitlines() if line.strip()
96+
}
97+
# An empty or whitespace-only `types:` block would return an
98+
# empty set and trivially match an empty commitizen set —
99+
# masking a real config error. Fail loudly instead (#92).
100+
if not types:
101+
msg = (
102+
f"`types:` block in {PR_TITLE_YML} is empty or "
103+
"whitespace-only. Expected at least one commit type "
104+
"per line."
105+
)
106+
raise ValueError(msg)
107+
return types
108+
msg = (
109+
"Could not find an `amannn/action-semantic-pull-request` step in "
110+
f"{PR_TITLE_YML}. If the action was renamed or the file moved, "
111+
"update this script."
112+
)
113+
raise ValueError(msg)
114+
115+
116+
def main() -> int:
117+
cz = commitizen_types()
118+
pr = pr_title_types()
119+
120+
# Belt-and-braces safety net: both extractors raise on empty, but guard
121+
# against a future refactor that drops the raise (#92).
122+
if not cz or not pr:
123+
print(
124+
"::error::One or both extractors returned empty; sync check cannot "
125+
"proceed. commitizen_types() empty: "
126+
f"{not cz}; pr_title_types() empty: {not pr}."
127+
)
128+
return 1
129+
130+
if cz == pr:
131+
print(f"Commit types in sync ({len(cz)} types): {sorted(cz)}")
132+
return 0
133+
134+
print(
135+
"::error::[tool.commitizen].customize.schema_pattern and "
136+
".github/workflows/pr-title.yml types are out of sync"
137+
)
138+
for name in sorted(cz - pr):
139+
print(f"::error:: + in commitizen only: {name!r}")
140+
for name in sorted(pr - cz):
141+
print(f"::error:: - in pr-title.yml only: {name!r}")
142+
print(
143+
"\nFix: update both the schema_pattern in pyproject.toml AND "
144+
"the `types` list in .github/workflows/pr-title.yml so they "
145+
"contain the same type names. See docs/DEVELOPMENT.md#commit-messages "
146+
"for the current allowed list."
147+
)
148+
return 1
149+
150+
151+
if __name__ == "__main__":
152+
sys.exit(main())
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env python3
2+
"""Verify required-status-check drift between workflows and branch-protection.
3+
4+
Walks every workflow under .github/workflows/ (minus an EXEMPT list),
5+
collects each job's display name, and compares to the ``contexts`` arrays
6+
in .github/branch-protection/{main,develop}.json.
7+
8+
Fails CI when:
9+
- A job exists in a workflow but is missing from the contexts list
10+
(the drift class that lets a new check run without being required).
11+
- A context is listed but no workflow job has that display name
12+
(stale names that silently stop blocking merges).
13+
14+
Usage (from repo root):
15+
16+
uv run --with pyyaml python .github/scripts/check_required_contexts.py
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import sys
23+
from pathlib import Path
24+
25+
import yaml
26+
27+
WORKFLOWS_DIR = Path(".github/workflows")
28+
PROTECTION_DIR = Path(".github/branch-protection")
29+
30+
# Workflows whose jobs are intentionally not required status checks.
31+
# Keep the list short and explain each entry.
32+
EXEMPT_WORKFLOWS: dict[str, str] = {
33+
"codeql.yml": (
34+
"Placeholder — gated to workflow_dispatch pending GHAS subscription."
35+
),
36+
"branch-protection.yml": (
37+
"Runs only on main / schedule; never appears on PR check sets."
38+
),
39+
"eval-nightly.yml": (
40+
"Scheduled / workflow_dispatch only; runs against the configured LLM"
41+
" provider and never appears on PR check sets."
42+
),
43+
"artifact-cleanup.yml": (
44+
"Scheduled / workflow_dispatch only; never appears on PR check sets."
45+
),
46+
"release-drafter.yml": (
47+
"Runs on push to main + PR label events; drafts release notes,"
48+
" never appears on PR check sets."
49+
),
50+
"release.yml": (
51+
"Tag-triggered (v*.*.*); builds image, generates SBOM, publishes"
52+
" release. Never appears on PR check sets."
53+
),
54+
}
55+
56+
57+
def job_display_names(workflow_path: Path) -> list[str]:
58+
"""Return the display name for every job in a workflow file.
59+
60+
GitHub's status-check context is the job's ``name:`` field; when unset
61+
it falls back to the job key. Matrix jobs expand into one context per
62+
matrix dimension, but the base name is what the contexts array stores.
63+
"""
64+
data = yaml.safe_load(workflow_path.read_text())
65+
if not isinstance(data, dict) or "jobs" not in data:
66+
return []
67+
return [job.get("name") or key for key, job in data["jobs"].items()]
68+
69+
70+
def collect_actual_contexts() -> set[str]:
71+
"""Every job display name from every non-exempt workflow."""
72+
contexts: set[str] = set()
73+
for path in sorted(WORKFLOWS_DIR.glob("*.yml")):
74+
if path.name in EXEMPT_WORKFLOWS:
75+
continue
76+
contexts.update(job_display_names(path))
77+
return contexts
78+
79+
80+
def check_protection_file(path: Path, actual: set[str]) -> bool:
81+
"""Compare one branch-protection file's contexts array to actual jobs.
82+
83+
Returns True when they match exactly; logs GitHub-actions-style errors
84+
and returns False on any drift.
85+
"""
86+
data = json.loads(path.read_text())
87+
declared: set[str] = set(data["required_status_checks"]["contexts"])
88+
89+
missing = actual - declared
90+
extra = declared - actual
91+
92+
if not (missing or extra):
93+
return True
94+
95+
print(f"::error file={path}::drift detected in required_status_checks.contexts")
96+
for name in sorted(missing):
97+
print(f"::error file={path}:: + MISSING (job exists, not listed): {name!r}")
98+
for name in sorted(extra):
99+
print(f"::error file={path}:: - STALE (listed, no such job): {name!r}")
100+
return False
101+
102+
103+
def main() -> int:
104+
actual = collect_actual_contexts()
105+
if not actual:
106+
print("::error::No workflow jobs discovered — check WORKFLOWS_DIR path.")
107+
return 1
108+
109+
all_ok = True
110+
for json_path in sorted(PROTECTION_DIR.glob("*.json")):
111+
all_ok &= check_protection_file(json_path, actual)
112+
113+
if all_ok:
114+
print("Branch-protection contexts are in sync with workflow jobs.")
115+
print(
116+
f" {len(actual)} required job(s) across "
117+
f"{sum(1 for _ in PROTECTION_DIR.glob('*.json'))} branch(es)."
118+
)
119+
else:
120+
print(
121+
"\nFix: update .github/branch-protection/{main,develop}.json "
122+
"contexts arrays to match the current workflow jobs, or add the "
123+
"workflow to EXEMPT_WORKFLOWS in this script if intentionally "
124+
"non-required."
125+
)
126+
return 0 if all_ok else 1
127+
128+
129+
if __name__ == "__main__":
130+
sys.exit(main())

.github/workflows/ci.yml

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,36 @@ jobs:
9595
- run: uv sync --frozen --extra dev
9696
- run: uv run pre-commit run --all-files --show-diff-on-failure
9797

98+
branch-protection-sync:
99+
name: Branch-protection contexts sync
100+
runs-on: ubuntu-latest
101+
# Guards against the "new CI job silently not required" drift. Fails when
102+
# .github/branch-protection/*.json contexts arrays disagree with the
103+
# actual workflow jobs on disk.
104+
steps:
105+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
106+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
107+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
108+
with:
109+
python-version: "3.14"
110+
- run: uv sync --frozen --extra dev
111+
- run: uv run python .github/scripts/check_required_contexts.py
112+
113+
commit-type-sync:
114+
name: Commit-type sync
115+
runs-on: ubuntu-latest
116+
# Guards against [tool.commitizen].customize.schema_pattern in pyproject
117+
# drifting from the `types` list in .github/workflows/pr-title.yml.
118+
# Adding a type in one but not the other would mean commits pass locally
119+
# while PR titles fail in CI (or vice versa).
120+
steps:
121+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
122+
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
123+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
124+
with:
125+
python-version: "3.14"
126+
- run: uv sync --frozen --extra dev
127+
- run: uv run python .github/scripts/check_commit_types.py
128+
98129
# Frontend jobs (Frontend Build, Frontend Quality) are added by ticket #21
99-
# when frontend/package.json lands; keeping them out of this file avoids the
100-
# workflow-startup failure observed when `if: hashFiles(...)` guards a job
101-
# whose `cache-dependency-path` references a not-yet-existing file.
130+
# when frontend/package.json lands.

0 commit comments

Comments
 (0)