From 2edb5c6e6db4876cabe4bad89c5c88515ec20a2a Mon Sep 17 00:00:00 2001 From: "DESKTOP-691E36A\\Administrator" Date: Fri, 21 Aug 2026 16:33:32 -0600 Subject: [PATCH 1/2] Add registry CI guards (ADR 0002 phase 4) validate_registry checks paths, version pins, and CHANGELOG entries. PRs that bump contract version must update registry.yml and CHANGELOG.md. --- .github/workflows/ci.yml | 7 ++ README.md | 2 +- ...schema-registry-and-contract-versioning.md | 2 +- scripts/validate_registry.py | 39 ++++++ src/dqo/registry.py | 114 ++++++++++++++++++ tests/test_registry_validation.py | 56 +++++++++ 6 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 scripts/validate_registry.py create mode 100644 tests/test_registry_validation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 674619b..9a5d3e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,13 @@ jobs: env: AIRFLOW_HOME: /tmp/airflow + - name: Validate contract registry + run: python scripts/validate_registry.py + + - name: Check contract version bump discipline + if: github.event_name == 'pull_request' + run: python scripts/validate_registry.py --base ${{ github.event.pull_request.base.sha }} + - name: Run tests run: python -m pytest -q env: diff --git a/README.md b/README.md index 90905c3..9a97089 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Scheduling options are documented in [`docs/scheduling.md`](docs/scheduling.md). - [x] Contract registry catalog (`contracts/registry.yml`) — [ADR 0002](docs/adr/0002-schema-registry-and-contract-versioning.md) - [x] CLI resolves `--contract orders` via registry (phase 2) - [x] Run history stores `contract_version` metadata (phase 3) -- [ ] CI registry consistency guards (phase 4) +- [x] CI registry consistency guards (phase 4) ## Technology stack diff --git a/docs/adr/0002-schema-registry-and-contract-versioning.md b/docs/adr/0002-schema-registry-and-contract-versioning.md index 4aacf70..3fb4a70 100644 --- a/docs/adr/0002-schema-registry-and-contract-versioning.md +++ b/docs/adr/0002-schema-registry-and-contract-versioning.md @@ -112,7 +112,7 @@ When implemented: | **1** | Registry + docs | `contracts/registry.yml`, ADR 0002, README update | | **2** | CLI resolution | `--contract orders` resolves via registry; `--version` override | | **3** | History metadata | Persist `contract_version` in run history | ✓ | -| **4** | CI guards | Registry consistency tests; CHANGELOG requirement | +| **4** | CI guards | Registry consistency tests; CHANGELOG requirement | ✓ | | **5** | Pipeline pin (optional) | Config reference from `production-data-pipeline` | Phases 1–2 are the minimum viable registry story for portfolio reviewers. diff --git a/scripts/validate_registry.py b/scripts/validate_registry.py new file mode 100644 index 0000000..82d6a12 --- /dev/null +++ b/scripts/validate_registry.py @@ -0,0 +1,39 @@ +"""Validate contract registry consistency (ADR 0002 phase 4).""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.dqo.registry import check_version_bump_discipline, validate_registry + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate contracts/registry.yml consistency") + parser.add_argument( + "--base", + default=None, + help="Git ref for version-bump discipline check (e.g. origin/main)", + ) + args = parser.parse_args(argv) + + errors = validate_registry() + if args.base: + errors.extend(check_version_bump_discipline(args.base)) + + if errors: + for error in errors: + print(error, file=sys.stderr) + return 1 + + print("registry validation passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dqo/registry.py b/src/dqo/registry.py index d82f37d..eb94d44 100644 --- a/src/dqo/registry.py +++ b/src/dqo/registry.py @@ -2,11 +2,15 @@ from __future__ import annotations +import re +import subprocess from pathlib import Path from typing import Any import yaml +from src.dqo.contracts import load_contract + def load_registry(registry_path: Path) -> dict[str, Any]: if not registry_path.is_file(): @@ -56,3 +60,113 @@ def resolve_contract_path( raise FileNotFoundError(f"Contract file for '{contract_key}' not found: {resolved}") return resolved + + +def validate_registry( + *, + registry_path: Path = Path("contracts/registry.yml"), + contracts_dir: Path = Path("contracts"), + changelog_path: Path = Path("contracts/CHANGELOG.md"), +) -> list[str]: + """Return human-readable errors when registry, contracts, or changelog disagree.""" + errors: list[str] = [] + registry = load_registry(registry_path) + + if not changelog_path.is_file(): + errors.append(f"changelog not found: {changelog_path}") + changelog_text = "" + else: + changelog_text = changelog_path.read_text(encoding="utf-8") + + for name, entry in registry.items(): + if not isinstance(entry, dict): + errors.append(f"{name}: registry entry must be a mapping") + continue + + current = entry.get("current") + relative_path = entry.get("path") + if not isinstance(current, str): + errors.append(f"{name}: registry entry missing string 'current'") + continue + if not isinstance(relative_path, str): + errors.append(f"{name}: registry entry missing string 'path'") + continue + + contract_path = contracts_dir / relative_path + if not contract_path.is_file(): + errors.append(f"{name}: contract file not found at {contract_path}") + continue + + contract = load_contract(contract_path) + if contract.name != name: + errors.append( + f"{name}: registry key does not match contract name {contract.name!r}" + ) + if contract.version != current: + errors.append( + f"{name}: registry current {current!r} != contract version {contract.version!r}" + ) + + if f"## {name}" not in changelog_text: + errors.append(f"{name}: missing '## {name}' section in CHANGELOG") + continue + + section = changelog_text.split(f"## {name}", 1)[1] + next_heading = section.find("\n## ") + if next_heading != -1: + section = section[:next_heading] + if f"### {current}" not in section: + errors.append( + f"{name}: CHANGELOG missing '### {current}' entry for current version" + ) + + return errors + + +def check_version_bump_discipline(base_ref: str) -> list[str]: + """Fail when contract version changes without registry and CHANGELOG updates.""" + errors: list[str] = [] + changed = subprocess.run( + ["git", "diff", "--name-only", base_ref, "HEAD"], + capture_output=True, + text=True, + check=False, + ) + if changed.returncode != 0: + return [f"git diff failed: {changed.stderr.strip()}"] + + changed_files = {line.strip() for line in changed.stdout.splitlines() if line.strip()} + contract_files = [ + path + for path in changed_files + if path.startswith("contracts/") + and path.endswith(".yml") + and path != "contracts/registry.yml" + ] + if not contract_files: + return [] + + version_changed = False + for contract_file in contract_files: + patch = subprocess.run( + ["git", "diff", base_ref, "HEAD", "--", contract_file], + capture_output=True, + text=True, + check=False, + ) + if patch.returncode != 0: + errors.append(f"git diff failed for {contract_file}: {patch.stderr.strip()}") + continue + if re.search(r"^[-+]\s*version:", patch.stdout, re.MULTILINE): + version_changed = True + break + + if not version_changed: + return errors + + if "contracts/registry.yml" not in changed_files: + errors.append("contract version changed but contracts/registry.yml was not updated") + if "contracts/CHANGELOG.md" not in changed_files: + errors.append("contract version changed but contracts/CHANGELOG.md was not updated") + + return errors diff --git a/tests/test_registry_validation.py b/tests/test_registry_validation.py new file mode 100644 index 0000000..afd6d1e --- /dev/null +++ b/tests/test_registry_validation.py @@ -0,0 +1,56 @@ +from pathlib import Path + +import pytest + +from src.dqo.registry import validate_registry + + +def test_registry_paths_versions_and_changelog_align() -> None: + errors = validate_registry() + assert errors == [] + + +def test_validate_registry_detects_version_mismatch(tmp_path: Path) -> None: + contracts_dir = tmp_path / "contracts" + contracts_dir.mkdir() + registry_path = contracts_dir / "registry.yml" + changelog_path = contracts_dir / "CHANGELOG.md" + + registry_path.write_text( + """ +contracts: + orders: + current: "2.0" + path: orders.yml +""".strip(), + encoding="utf-8", + ) + (contracts_dir / "orders.yml").write_text( + """ +name: orders +version: "1.0" +description: test +columns: + order_id: + type: string + nullable: false +""".strip(), + encoding="utf-8", + ) + changelog_path.write_text( + """ +## orders + +### 1.0 (initial) +- test +""".strip(), + encoding="utf-8", + ) + + errors = validate_registry( + registry_path=registry_path, + contracts_dir=contracts_dir, + changelog_path=changelog_path, + ) + assert any("registry current '2.0'" in error for error in errors) + assert any("missing '### 2.0'" in error for error in errors) From 447638db8666908826f9e8eb80f42c95144bd09e Mon Sep 17 00:00:00 2001 From: "DESKTOP-691E36A\\Administrator" Date: Fri, 21 Aug 2026 16:35:22 -0600 Subject: [PATCH 2/2] fix(ci): fetch full history for registry version-bump check --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a5d3e4..81be2eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5