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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,6 +52,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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0002-schema-registry-and-contract-versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions scripts/validate_registry.py
Original file line number Diff line number Diff line change
@@ -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())
114 changes: 114 additions & 0 deletions src/dqo/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
56 changes: 56 additions & 0 deletions tests/test_registry_validation.py
Original file line number Diff line number Diff line change
@@ -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)
Loading