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
202 changes: 157 additions & 45 deletions .github/scripts/check_commit_types.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
#!/usr/bin/env python3
"""Verify the commit-type allowlist stays in sync across two configs.
"""Verify the commit-type allowlist + subject-case rule stay in sync.

Seven prefixes are allowed on commits and PR titles: feat, fix, docs,
test, refactor, chore, release. Two places enforce that list today:
Two configs hand-encode the same conventional-commit policy:

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.
``amannn/action-semantic-pull-request`` step plus its ``subjectPattern``
— 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.
This script enforces sync on **two axes**:

- **Type allowlist** — the seven prefixes (feat, fix, docs, test,
refactor, chore, release). Mirrors the ``check_required_contexts.py``
pattern from #72.
- **Subject-case rule** — the negative-lookahead constraint that rejects
Title-Case subjects (``feat: Add thing`` → reject; ``feat: add thing``
/ ``feat: CI failure`` → accept). Added in #128 so commitizen rejects
Title Case at commit-msg time, not just at the CI gate.

Fails CI when either axis disagrees in either direction.

Usage (from repo root):

Expand Down Expand Up @@ -48,6 +56,19 @@
# extraction when a future type contained digits or hyphens.
_SCHEMA_ALTERNATION_RE = re.compile(r"\^\(([a-z0-9\-|]+)\)")

# Matches the subject-case constraint between `:\s` and the trailing `.+`
# in the commitizen schema_pattern. Tolerates three shapes seen across
# revisions:
# :\s — original #128 shape (single-space, susceptible to the
# `feat: Add thing` double-space bypass).
# :\s+ — naive widening (still backtracks on Title-Case input).
# :\s++ — possessive quantifier (#154); the schema we want long-term
# because it forbids the lookahead-bypass via backtracking.
# All three encode the same "after `:` then whitespace, then this lookahead"
# semantics; the regex captures the lookahead chunk regardless.
# Returns "" if no subject constraint is present (commitizen pre-#128 shape).
_SCHEMA_SUBJECT_RE = re.compile(r":\\s\+{0,2}(.*?)\.\+$")


def commitizen_types() -> set[str]:
"""Return the set of types allowed by the commitizen schema regex."""
Expand Down Expand Up @@ -83,28 +104,87 @@ def commitizen_types() -> set[str]:
return types


def commitizen_subject_pattern() -> str:
"""Extract the subject-case constraint from commitizen's schema_pattern.

The schema_pattern shape (post-#128):
^(feat|fix|...)(\\([\\w\\-]+\\))?!?:\\s(?![A-Z][a-z]).+

Returns the chunk between ``:\\s`` and the trailing ``.+`` — i.e. the
negative-lookahead constraint on the subject. Returns "" when no
subject constraint is present (commitizen pre-#128 shape).
"""
data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))
schema: str = (
data.get("tool", {})
.get("commitizen", {})
.get("customize", {})
.get("schema_pattern", "")
)
if not schema:
# Same error commitizen_types() raises — caller already enforces.
return ""
match = _SCHEMA_SUBJECT_RE.search(schema)
if not match:
return ""
return match.group(1)


def pr_title_types() -> set[str]:
"""Return the set of types declared in the pr-title workflow."""
return _pr_title_field("types", _parse_types) # type: ignore[return-value]


def pr_title_subject_pattern() -> str:
"""Return the subject-case constraint declared in the pr-title workflow.

Strips the leading ``^`` anchor and the trailing ``.+$`` from the
YAML ``subjectPattern`` field so the comparison with commitizen's
constraint is normalised. Returns "" when the field is absent.
"""
raw: str = _pr_title_field("subjectPattern", lambda v: v or "", required=False) # type: ignore[assignment]
if not raw:
return ""
pattern = re.sub(r"^\^", "", raw)
pattern = re.sub(r"\.\+\$$", "", pattern)
return pattern


def _parse_types(value: str) -> set[str]:
"""Parse the YAML ``types`` field (newline-separated string) into a set."""
types = {line.strip() for line in value.splitlines() if line.strip()}
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


def _pr_title_field(
name: str,
parse: object,
*,
required: bool = True,
) -> object:
"""Extract a single field from the action-semantic-pull-request step."""
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
value = step.get("with", {}).get(name)
if value is None:
if required:
msg = (
f"`with.{name}` not found in the "
"action-semantic-pull-request step. Update this "
"script if the action's input names changed."
)
raise ValueError(msg)
return ""
return parse(value) # type: ignore[operator]
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, "
Expand All @@ -114,38 +194,70 @@ def pr_title_types() -> set[str]:


def main() -> int:
cz = commitizen_types()
pr = pr_title_types()
cz_types = commitizen_types()
pr_types = pr_title_types()
cz_subject = commitizen_subject_pattern()
pr_subject = pr_title_subject_pattern()

failed = False

# 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:
if not cz_types or not pr_types:
print(
"::error::One or both extractors returned empty; sync check cannot "
"proceed. commitizen_types() empty: "
f"{not cz}; pr_title_types() empty: {not pr}."
f"proceed. commitizen_types() empty: {not cz_types}; "
f"pr_title_types() empty: {not pr_types}."
)
return 1

if cz == pr:
print(f"Commit types in sync ({len(cz)} types): {sorted(cz)}")
return 0
if cz_types == pr_types:
print(f"Commit types in sync ({len(cz_types)} types): {sorted(cz_types)}")
else:
failed = True
print(
"::error::[tool.commitizen].customize.schema_pattern and "
".github/workflows/pr-title.yml types are out of sync"
)
for name in sorted(cz_types - pr_types):
print(f"::error:: + in commitizen only: {name!r}")
for name in sorted(pr_types - cz_types):
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."
)

if cz_subject == pr_subject:
if cz_subject:
print(f"Subject-case constraint in sync: {cz_subject!r}")
else:
# Both empty — older shape, before #128's subject-case landed in
# commitizen. Don't fail here; the `Lint PR title` workflow remains
# the single layer if commitizen drops back. Surface as a warning.
print(
"::warning::Both commitizen and pr-title.yml have empty "
"subject-case constraints. Per docs/DEVELOPMENT.md the rule "
"should be enforced at both layers — re-add `(?![A-Z][a-z])` "
"to commitizen's schema_pattern after `:\\s`."
)
else:
failed = True
print(
"::error::commitizen schema_pattern subject-case constraint "
"and pr-title.yml `subjectPattern` are out of sync"
)
print(f"::error:: commitizen extracted: {cz_subject!r}")
print(f"::error:: pr-title.yml extracted: {pr_subject!r}")
print(
"\nFix: keep both regexes equivalent after stripping anchors. "
"Commitizen's chunk lives between `:\\s` and `.+` in "
"schema_pattern; pr-title.yml's lives in the `subjectPattern` "
"field stripped of `^` and `.+$`."
)

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
return 1 if failed else 0


if __name__ == "__main__":
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "harness-python-react"
version = "0.2.2"
version = "0.2.3"
description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend."
readme = "README.md"
requires-python = ">=3.14"
Expand Down Expand Up @@ -165,7 +165,7 @@ name = "cz_customize"

[tool.commitizen.customize]
schema = "<type>(<scope>): <subject>"
schema_pattern = '^(feat|fix|docs|test|refactor|chore|release)(\([\w\-]+\))?!?:\s.+'
schema_pattern = '^(feat|fix|docs|test|refactor|chore|release)(\([\w\-]+\))?!?:\s++(?![A-Z][a-z]).+'
bump_pattern = '^(feat|fix|refactor)'
bump_map = {feat = "MINOR", fix = "PATCH", refactor = "PATCH", chore = "PATCH", docs = "PATCH", test = "PATCH"}
example = "feat: add an example feature"
Expand Down
Loading
Loading