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
10 changes: 10 additions & 0 deletions .github/branch-protection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Branch protection

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.

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:

- a workflow job exists but is missing from the contexts array (lets a new check run without being required)
- a context is listed but no workflow job has that display name (stale entries that silently stop blocking merges)

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`.
30 changes: 30 additions & 0 deletions .github/branch-protection/develop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"required_status_checks": {
"strict": false,
"contexts": [
"Lint & Format",
"Type Check",
"Unit tests",
"Coverage",
"Architecture (import-linter)",
"Pre-commit",
"Branch-protection contexts sync",
"Commit-type sync",
"Lint PR title (conventional commits)"
]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": true,
"required_approving_review_count": 1,
"require_last_push_approval": false
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"block_creations": false,
"required_conversation_resolution": true,
"lock_branch": false,
"allow_fork_syncing": false
}
30 changes: 30 additions & 0 deletions .github/branch-protection/main.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"required_status_checks": {
"strict": true,
"contexts": [
"Lint & Format",
"Type Check",
"Unit tests",
"Coverage",
"Architecture (import-linter)",
"Pre-commit",
"Branch-protection contexts sync",
"Commit-type sync",
"Lint PR title (conventional commits)"
]
},
"enforce_admins": false,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": true,
"required_approving_review_count": 1,
"require_last_push_approval": false
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"block_creations": false,
"required_conversation_resolution": true,
"lock_branch": false,
"allow_fork_syncing": false
}
152 changes: 152 additions & 0 deletions .github/scripts/check_commit_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Verify the commit-type allowlist stays in sync across two configs.

Seven prefixes are allowed on commits and PR titles: feat, fix, docs,
test, refactor, chore, release. Two places enforce that list today:

1. ``[tool.commitizen].customize.schema_pattern`` in ``pyproject.toml`` —
the commitizen regex (commit-msg hook, local).
2. ``.github/workflows/pr-title.yml`` ``types:`` input to the
``amannn/action-semantic-pull-request`` step — the PR-title CI check.

Both are hand-maintained. Add a type in one, forget the other, and the
two layers drift: commits fail locally but PR titles pass (or vice
versa). ``docs/DEVELOPMENT.md`` explicitly warns these must stay in
sync, but prose warnings drift too.

This script mirrors the ``check_required_contexts.py`` pattern from #72
for this second drift class. Fails CI when the two sets disagree in
either direction.

Usage (from repo root):

uv run python .github/scripts/check_commit_types.py
"""

from __future__ import annotations

import re
import sys
import tomllib
from pathlib import Path

import yaml

PYPROJECT = Path("pyproject.toml")
PR_TITLE_YML = Path(".github/workflows/pr-title.yml")

# Matches the first alternation group in the commitizen schema_pattern.
# Schema example: ^(feat|fix|rc2|hot-fix|...)(\([\w\-]+\))?!?:\s.+
# Captures: feat|fix|rc2|hot-fix|...
#
# Character class [a-z0-9\-|]+ allows:
# - lowercase letters (standard types: feat, fix, docs, ...)
# - digits (release-candidate patterns: rc2, v2, ...)
# - hyphens (compound types: hot-fix, post-release, ...)
# - the `|` separator
# Widened from [a-z|]+ (#91): the tighter class would silently truncate
# extraction when a future type contained digits or hyphens.
_SCHEMA_ALTERNATION_RE = re.compile(r"\^\(([a-z0-9\-|]+)\)")


def commitizen_types() -> set[str]:
"""Return the set of types allowed by the commitizen schema regex."""
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
schema: str = (
data.get("tool", {})
.get("commitizen", {})
.get("customize", {})
.get("schema_pattern", "")
)
if not schema:
msg = "[tool.commitizen].customize.schema_pattern not found in pyproject.toml"
raise ValueError(msg)
match = _SCHEMA_ALTERNATION_RE.search(schema)
if not match:
msg = (
"Could not extract the type alternation group from "
f"schema_pattern: {schema!r}. Expected it to start with "
"'^(<type>|<type>|...)'."
)
raise ValueError(msg)
types = {t for t in match.group(1).split("|") if t}
# Defensive: a malformed pattern like "^(|feat)..." could produce an
# empty type after split. If nothing survives the filter, raise rather
# than return a silent-pass empty set that would trivially match an
# empty set from the other extractor (#92).
if not types:
msg = (
"Empty type alternation extracted from schema_pattern. "
f"Check pyproject.toml: {schema!r}"
)
raise ValueError(msg)
return types


def pr_title_types() -> set[str]:
"""Return the set of types declared in the pr-title workflow."""
data = yaml.safe_load(PR_TITLE_YML.read_text(encoding="utf-8"))
for job in data.get("jobs", {}).values():
for step in job.get("steps", []):
uses = step.get("uses", "")
if "action-semantic-pull-request" in uses:
types_block: str = step.get("with", {}).get("types", "")
types = {
line.strip() for line in types_block.splitlines() if line.strip()
}
# An empty or whitespace-only `types:` block would return an
# empty set and trivially match an empty commitizen set —
# masking a real config error. Fail loudly instead (#92).
if not types:
msg = (
f"`types:` block in {PR_TITLE_YML} is empty or "
"whitespace-only. Expected at least one commit type "
"per line."
)
raise ValueError(msg)
return types
msg = (
"Could not find an `amannn/action-semantic-pull-request` step in "
f"{PR_TITLE_YML}. If the action was renamed or the file moved, "
"update this script."
)
raise ValueError(msg)


def main() -> int:
cz = commitizen_types()
pr = pr_title_types()

# Belt-and-braces safety net: both extractors raise on empty, but guard
# against a future refactor that drops the raise (#92).
if not cz or not pr:
print(
"::error::One or both extractors returned empty; sync check cannot "
"proceed. commitizen_types() empty: "
f"{not cz}; pr_title_types() empty: {not pr}."
)
return 1

if cz == pr:
print(f"Commit types in sync ({len(cz)} types): {sorted(cz)}")
return 0

print(
"::error::[tool.commitizen].customize.schema_pattern and "
".github/workflows/pr-title.yml types are out of sync"
)
for name in sorted(cz - pr):
print(f"::error:: + in commitizen only: {name!r}")
for name in sorted(pr - cz):
print(f"::error:: - in pr-title.yml only: {name!r}")
print(
"\nFix: update both the schema_pattern in pyproject.toml AND "
"the `types` list in .github/workflows/pr-title.yml so they "
"contain the same type names. See docs/DEVELOPMENT.md#commit-messages "
"for the current allowed list."
)
return 1


if __name__ == "__main__":
sys.exit(main())
130 changes: 130 additions & 0 deletions .github/scripts/check_required_contexts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,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())
35 changes: 32 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,36 @@ jobs:
- run: uv sync --frozen --extra dev
- run: uv run pre-commit run --all-files --show-diff-on-failure

branch-protection-sync:
name: Branch-protection contexts sync
runs-on: ubuntu-latest
# Guards against the "new CI job silently not required" drift. Fails when
# .github/branch-protection/*.json contexts arrays disagree with the
# actual workflow jobs on disk.
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- run: uv sync --frozen --extra dev
- run: uv run python .github/scripts/check_required_contexts.py

commit-type-sync:
name: Commit-type sync
runs-on: ubuntu-latest
# Guards against [tool.commitizen].customize.schema_pattern in pyproject
# drifting from the `types` list in .github/workflows/pr-title.yml.
# Adding a type in one but not the other would mean commits pass locally
# while PR titles fail in CI (or vice versa).
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- run: uv sync --frozen --extra dev
- run: uv run python .github/scripts/check_commit_types.py

# Frontend jobs (Frontend Build, Frontend Quality) are added by ticket #21
# when frontend/package.json lands; keeping them out of this file avoids the
# workflow-startup failure observed when `if: hashFiles(...)` guards a job
# whose `cache-dependency-path` references a not-yet-existing file.
# when frontend/package.json lands.
Loading
Loading