diff --git a/.github/branch-protection/README.md b/.github/branch-protection/README.md new file mode 100644 index 0000000..49d379c --- /dev/null +++ b/.github/branch-protection/README.md @@ -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`. diff --git a/.github/branch-protection/develop.json b/.github/branch-protection/develop.json new file mode 100644 index 0000000..25ac197 --- /dev/null +++ b/.github/branch-protection/develop.json @@ -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 +} diff --git a/.github/branch-protection/main.json b/.github/branch-protection/main.json new file mode 100644 index 0000000..9d7db2c --- /dev/null +++ b/.github/branch-protection/main.json @@ -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 +} diff --git a/.github/scripts/check_commit_types.py b/.github/scripts/check_commit_types.py new file mode 100644 index 0000000..c3d9ec9 --- /dev/null +++ b/.github/scripts/check_commit_types.py @@ -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 " + "'^(||...)'." + ) + 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()) diff --git a/.github/scripts/check_required_contexts.py b/.github/scripts/check_required_contexts.py new file mode 100644 index 0000000..518d946 --- /dev/null +++ b/.github/scripts/check_required_contexts.py @@ -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()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5699b23..3af145f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..ad0e147 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,49 @@ +name: PR title + +on: + pull_request: + # Re-run on title edits + initial open + every push so the state + # reflects the current title, not the one at PR creation. + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +# Closes the gap left by commitizen: the commit-msg hook only runs locally. +# Since this repo squash-merges every PR (see memory +# project_solo_owner_admin_merge), the merge commit's subject is the PR +# title — so enforcing conventional format on PR titles enforces it on +# main/develop history, regardless of merge method. +jobs: + lint-pr-title: + name: Lint PR title (conventional commits) + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Same seven types as [tool.commitizen] in pyproject.toml. + # `release` is project-specific for develop -> main release PRs. + types: | + feat + fix + docs + test + refactor + chore + release + requireScope: false + # Reject Title Case prose (`Add thing`, `Fix bug`) but allow + # all-caps initialisms (`CI failure`, `SDK upgrade`, `API notes`) + # and normal lowercase starts (`add thing`). The pattern matches + # which unambiguously signals Title Case; + # and are fine. + subjectPattern: '^(?![A-Z][a-z]).+$' + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + starts with Title Case (a capitalised word as in prose). Use + lowercase (`add the thing`, `fix the bug`) or an all-caps + initialism (`CI failure`, `SDK upgrade`, `API contract`). See + docs/DEVELOPMENT.md#commit-messages for the convention. diff --git a/tests/test_check_commit_types.py b/tests/test_check_commit_types.py new file mode 100644 index 0000000..f6adc58 --- /dev/null +++ b/tests/test_check_commit_types.py @@ -0,0 +1,253 @@ +"""Tests for ``.github/scripts/check_commit_types.py``. + +The script runs as the ``Commit-type sync`` CI job and guards against drift +between commitizen's ``schema_pattern`` and pr-title.yml's ``types`` list. +These tests cover the two improvements landed in #91 and #92: + +- #91: the alternation regex accepts digits and hyphens in type names + (forward-proof against future types like ``rc2`` or ``post-release``). +- #92: both extractors raise on empty input rather than returning + ``set()``, which would silently trip the ``cz == pr`` check when both + configs are broken. + +Tests live under ``tests/`` so they run in the regular pytest suite and +contribute to coverage. The script is loaded via ``importlib`` because +``.github/scripts`` isn't on the Python path. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +if TYPE_CHECKING: + from types import ModuleType + + +def _load_script() -> ModuleType: + """Load the standalone script as an importable module.""" + script_path = ( + Path(__file__).parent.parent / ".github" / "scripts" / "check_commit_types.py" + ) + spec = importlib.util.spec_from_file_location("check_commit_types", script_path) + assert spec is not None, f"Could not create spec for {script_path}" + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +cct = _load_script() + + +# --------------------------------------------------------------------------- +# #91 — regex widening +# --------------------------------------------------------------------------- + + +class TestSchemaAlternationRegex: + """The widened character class accepts digits and hyphens in type names.""" + + @pytest.mark.parametrize( + "schema,expected", + [ + # Current production schema — must still work after widening. + ( + r"^(feat|fix|docs|test|refactor|chore|release)(\([\w\-]+\))?!?:\s.+", + {"feat", "fix", "docs", "test", "refactor", "chore", "release"}, + ), + # Types with digits (e.g. future release-candidate prefix). + ( + r"^(feat|rc2|v2hotfix)(\([\w\-]+\))?!?:\s.+", + {"feat", "rc2", "v2hotfix"}, + ), + # Types with hyphens (compound prefixes). + ( + r"^(fix|hot-fix|post-release)(\([\w\-]+\))?!?:\s.+", + {"fix", "hot-fix", "post-release"}, + ), + # Mixed: digits and hyphens together. + ( + r"^(feat|rc2|post-release|hot-fix-v3)(\([\w\-]+\))?!?:\s.+", + {"feat", "rc2", "post-release", "hot-fix-v3"}, + ), + # Single type — edge of the alternation range. + ( + r"^(feat)(\([\w\-]+\))?!?:\s.+", + {"feat"}, + ), + ], + ) + def test_extraction(self, schema: str, expected: set[str]) -> None: + match = cct._SCHEMA_ALTERNATION_RE.search(schema) + assert match is not None, f"regex failed to match: {schema!r}" + actual = {t for t in match.group(1).split("|") if t} + assert actual == expected + + def test_rejects_uppercase_types(self) -> None: + # The regex only matches lowercase + digit + hyphen. An uppercase + # type in the schema would fall through; we'd never mistake it for + # a valid alternation. (Commitizen schemas use lowercase by + # convention.) + schema = r"^(FEAT|FIX)(\([\w\-]+\))?!?:\s.+" + match = cct._SCHEMA_ALTERNATION_RE.search(schema) + assert match is None + + +# --------------------------------------------------------------------------- +# #92 — empty-set guards +# --------------------------------------------------------------------------- + + +class TestCommitizenTypesEmptyGuard: + """commitizen_types() must raise, not return an empty set.""" + + def test_raises_when_schema_pattern_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[tool.commitizen.customize]\nexample = "no schema_pattern here"\n', + encoding="utf-8", + ) + monkeypatch.setattr(cct, "PYPROJECT", pyproject) + with pytest.raises(ValueError, match="schema_pattern not found"): + cct.commitizen_types() + + def test_raises_when_schema_pattern_unparseable( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[tool.commitizen.customize]\nschema_pattern = "no group here"\n', + encoding="utf-8", + ) + monkeypatch.setattr(cct, "PYPROJECT", pyproject) + with pytest.raises(ValueError, match="Could not extract"): + cct.commitizen_types() + + def test_raises_when_alternation_group_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # `^(|)` — a malformed pattern whose group matches the regex but + # produces only empty strings after split+filter. #92 guards this. + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + "[tool.commitizen.customize]\n" + 'schema_pattern = "^(|)(\\\\(...\\\\))?!?:\\\\s.+"\n', + encoding="utf-8", + ) + monkeypatch.setattr(cct, "PYPROJECT", pyproject) + with pytest.raises(ValueError, match="Empty type alternation"): + cct.commitizen_types() + + +class TestPrTitleTypesEmptyGuard: + """pr_title_types() must raise, not return an empty set.""" + + def test_raises_when_action_step_missing( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + workflow = tmp_path / "pr-title.yml" + workflow.write_text( + "name: PR title\njobs:\n foo:\n steps:\n - run: echo hi\n", + encoding="utf-8", + ) + monkeypatch.setattr(cct, "PR_TITLE_YML", workflow) + with pytest.raises(ValueError, match="Could not find"): + cct.pr_title_types() + + def test_raises_when_types_block_empty( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + workflow = tmp_path / "pr-title.yml" + workflow.write_text( + "name: PR title\n" + "jobs:\n" + " lint:\n" + " steps:\n" + " - uses: amannn/action-semantic-pull-request@abc123\n" + " with:\n" + ' types: ""\n', + encoding="utf-8", + ) + monkeypatch.setattr(cct, "PR_TITLE_YML", workflow) + with pytest.raises(ValueError, match="empty or whitespace-only"): + cct.pr_title_types() + + def test_raises_when_types_block_whitespace_only( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + workflow = tmp_path / "pr-title.yml" + workflow.write_text( + "name: PR title\n" + "jobs:\n" + " lint:\n" + " steps:\n" + " - uses: amannn/action-semantic-pull-request@abc123\n" + " with:\n" + " types: |\n" + " \n" + " \n", + encoding="utf-8", + ) + monkeypatch.setattr(cct, "PR_TITLE_YML", workflow) + with pytest.raises(ValueError, match="empty or whitespace-only"): + cct.pr_title_types() + + def test_parses_valid_types_block( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Positive path: confirm the happy case still works after the guard. + workflow = tmp_path / "pr-title.yml" + workflow.write_text( + "name: PR title\n" + "jobs:\n" + " lint:\n" + " steps:\n" + " - uses: amannn/action-semantic-pull-request@abc123\n" + " with:\n" + " types: |\n" + " feat\n" + " fix\n" + " chore\n", + encoding="utf-8", + ) + monkeypatch.setattr(cct, "PR_TITLE_YML", workflow) + assert cct.pr_title_types() == {"feat", "fix", "chore"} + + +# --------------------------------------------------------------------------- +# main() belt-and-braces safety net (#92) +# --------------------------------------------------------------------------- + + +class TestMainEmptySafetyNet: + """Even if a future refactor strips the raises, main() catches empties.""" + + def test_exits_nonzero_when_commitizen_returns_empty( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with ( + patch.object(cct, "commitizen_types", return_value=set()), + patch.object(cct, "pr_title_types", return_value={"feat"}), + ): + assert cct.main() == 1 + captured = capsys.readouterr() + assert "cannot proceed" in captured.out + assert "commitizen_types() empty: True" in captured.out + + def test_exits_nonzero_when_pr_title_returns_empty( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with ( + patch.object(cct, "commitizen_types", return_value={"feat"}), + patch.object(cct, "pr_title_types", return_value=set()), + ): + assert cct.main() == 1 + captured = capsys.readouterr() + assert "pr_title_types() empty: True" in captured.out