diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f8d789 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.9', '3.13'] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: pip install -e . + + - name: Unit tests + run: python -m unittest discover -s tests -v + + # Regression guard for the failure this repo shipped for months: a + # `language: python` hook is only installable when pyproject.toml exposes it + # as a console script. `service-yaml-check` pointed at a repo-relative path, + # so every consumer got "Directory '.' is not installable" and the hook + # silently never ran anywhere. Nothing exercised the install path until now. + hook-install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install pre-commit + run: pip install pre-commit + + - name: Every python hook resolves as a console script + run: | + set -euo pipefail + pip install . + stevedore-release-scope --help > /dev/null + service-yaml-check > /dev/null + + - name: try-repo each hook against a fixture consumer + run: | + set -euo pipefail + fixture=$(mktemp -d) + mkdir -p "$fixture/charts/oms" "$fixture/.github/workflows" "$fixture/.platform/services" + cat > "$fixture/.stevedore.yaml" <<'YAML' + images: + - id: oms + project: PinPredict/PinPredict.csproj + change_detection: + shared_paths: + - "Directory.Build.props" + YAML + printf 'name: oms\nversion: 0.1.0\n' > "$fixture/charts/oms/Chart.yaml" + printf 'name: CI\non:\n push:\njobs: {}\n' > "$fixture/.github/workflows/ci.yml" + # A well-formed spec: the point of this job is that each hook installs + # and runs, so the fixture must satisfy the checks the hook enforces. + cat > "$fixture/.platform/services/oms.yaml" <<'YAML' + name: oms + repositories: + chart: charts/oms + image: pinpredict/oms + YAML + + git -c init.defaultBranch=main init -q --template= "$fixture" + git -C "$fixture" add -A + + for hook in stevedore-release-scope service-yaml-check; do + echo "::group::try-repo $hook" + (cd "$fixture" && pre-commit try-repo "$GITHUB_WORKSPACE" "$hook" --all-files) + echo "::endgroup::" + done diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..657af60 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +# The hooks repo dogfoods its own gate. The unit tests run here too: a hook +# whose tests only run in CI is exactly how `service-yaml-check` stayed broken. +repos: + - repo: local + hooks: + - id: hook-unit-tests + name: hook unit tests + entry: python3 -m unittest discover -s tests + language: system + pass_filenames: false + files: ^(pinpredict_hooks/|tests/|pyproject\.toml$) + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + - id: detect-private-key + + - repo: https://github.com/adrienverge/yamllint + rev: v1.38.0 + hooks: + - id: yamllint + args: ['-c', '.yamllint.yml'] + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 5907532..d37c9c7 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -17,11 +17,28 @@ chart path resolves in service-template, image repo/PIA/push-role exist in TF, and networkPolicy ingress ports match the chart's declared health port. Catches the failure modes from platform-gitops#544 before merge. - entry: hooks/service-yaml-check.py + # Console script from pyproject.toml. A path entry (hooks/service-yaml-check.py) + # cannot work here: `language: python` pip-installs this repo and runs `entry` + # as a command, so the hook must resolve on the venv PATH. + entry: service-yaml-check language: python - additional_dependencies: ["pyyaml>=6"] files: '^\.platform/services/[^/]+\.yaml$' +- id: stevedore-release-scope + name: stevedore release scope + description: | + Assert that onboarding or retiring a service does not silently widen the + image build contract. Checks that each named service is an image id in + .stevedore.yaml with a name-matching sibling chart, that docker-release and + chart-release receive the same `only:` selector, and that + change_detection.shared_paths carries the all-image contract signal without + listing paths every onboarding touches (Dockerfile, .dockerignore, *.sln). + Every assertion is opt-in via args, so repos missing a surface skip it. + entry: stevedore-release-scope + language: python + files: '^(\.stevedore\.yaml|\.github/workflows/ci\.yml|charts/[^/]+/Chart\.yaml)$' + pass_filenames: false + - id: check-go-version-sync name: check go version sync (go.mod ↔ .tool-versions) description: | diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..409e757 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,11 @@ +# Org baseline: block sequences indent under their parent key (the +# Kubernetes/Helm convention), line-length left to reviewers. +extends: default + +rules: + indentation: + spaces: 2 + indent-sequences: true + line-length: disable + # .pre-commit-hooks.yaml is a top-level sequence with no parent key. + document-start: disable diff --git a/README.md b/README.md index 89924b8..fcf4c1a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ repos. |---|---|---| | `csharpier-worktree-guard` | Run `dotnet csharpier format .` (or `check .` via `CSHARPIER_MODE=check`) but fail loudly if csharpier reports "0 files" while the repo actually contains tracked `.cs` files. Catches the silent no-op observed when csharpier runs from inside a git worktree. | `*.cs` | | `service-yaml-check` | Static checks for new/changed `.platform/services/.yaml` files: chart path resolves, ECR repo / PIA / GHA push-role exist in TF, networkPolicy ingress ports match the chart's declared health port. Catches the post-merge failure modes from [platform-gitops#544](https://github.com/pinpredict/platform-gitops/issues/544). | `.platform/services/*.yaml` | +| `stevedore-release-scope` | Assert that onboarding or retiring a service does not widen the shared image build contract: named services pair an `.stevedore.yaml` image id with a name-matching sibling chart, `docker-release` and `chart-release` receive the same `only:` selector, and `change_detection.shared_paths` carries the all-image signal without listing paths every onboarding touches. | `.stevedore.yaml`, `.github/workflows/ci.yml`, `charts/*/Chart.yaml` | +| `check-go-version-sync` | Fails when a `go.mod` `go` directive and the governing `.tool-versions` `golang` pin drift apart. | `go.mod`, `.tool-versions` | ## Using a hook @@ -19,7 +21,7 @@ Reference this repo from a consumer's `.pre-commit-config.yaml`: ```yaml repos: - repo: https://github.com/pinpredict/pre-commit-hooks - rev: v0.1.0 # bump to upgrade + rev: v0.3.0 # bump to upgrade hooks: - id: csharpier-worktree-guard ``` @@ -81,16 +83,75 @@ and revertable. Tags follow semver: - **major** — breaking change to a hook's contract (entry, default mode, required env, etc.) +## Repository layout + +| Path | What | +|---|---| +| `pinpredict_hooks/` | Python hooks, shipped as console scripts via `pyproject.toml` | +| `hooks/` | Shell hooks (`language: script`), run directly from the repo | +| `tests/` | Unit tests for the Python hooks — `python -m unittest discover -s tests` | + +**Python hooks must be console scripts.** pre-commit's `language: python` +pip-installs this repo and then runs the hook's `entry` as a *command*, so a +repo-relative path entry (`hooks/foo.py`) cannot work. Every Python hook needs +an entry in `[project.scripts]` and an `entry:` matching that script name. +Getting this wrong fails at install time for every consumer with +`Directory '.' is not installable` — `service-yaml-check` shipped that way and +was never runnable anywhere until it was fixed. The `hook-install` CI job now +exercises the install path for exactly this reason. + +Shell hooks stay in `hooks/` with `language: script`; they need no packaging. + ## Adding a new hook -1. Drop the script in `hooks/.sh` (or another language — pre-commit - supports `language: script` / `python` / `golang` / etc.). +1. Python: add a module under `pinpredict_hooks/` with a `main(argv=None) -> int` + and register it in `[project.scripts]`. Shell: drop the script in + `hooks/.sh` and use `language: script`. 2. Add an entry to `.pre-commit-hooks.yaml` with `id`, `name`, `description`, `entry`, `language`, `files`, and any other relevant keys. See the [pre-commit docs](https://pre-commit.com/#creating-new-hooks) for the full schema. -3. Update this README's "Available hooks" table. -4. Open a PR. After merge, cut a tag. +3. Add tests under `tests/`. Keep them file-based and free of `git`/subprocess + work so the suite stays fast enough to run as a hook itself. +4. Update this README's "Available hooks" table. +5. Open a PR. After merge, cut a tag. + +## `stevedore-release-scope` + +Every assertion is opt-in via args, so the hook fits repos that have only some +of the surfaces — a repo with no `chart-release` job, or no +`change_detection.shared_paths` key, skips those checks instead of failing. +A repo with no `.stevedore.yaml` at all is a clean no-op. + +```yaml +- repo: https://github.com/pinpredict/pre-commit-hooks + rev: v0.3.0 + hooks: + - id: stevedore-release-scope + args: + - --service=nadex-rfqgw + - --require-shared-path=Directory.Build.props + - --forbid-shared-path=Dockerfile + - --forbid-shared-path=.dockerignore + - --forbid-shared-path=*.sln +``` + +| Flag | Purpose | +|---|---| +| `--service ID` (repeatable) | `ID` is an image id in `.stevedore.yaml` **and** `charts/ID/Chart.yaml` declares `name: ID` | +| `--require-shared-path P` (repeatable) | `change_detection.shared_paths` must contain `P` | +| `--forbid-shared-path P` (repeatable) | `change_detection.shared_paths` must **not** contain `P` | +| `--expect-selector EXPR` | both release jobs pass exactly this `only:` expression | +| `--docker-job` / `--chart-job` | job names to compare (default `docker-release` / `chart-release`) | +| `--catalog` / `--workflow` / `--charts-dir` | override the default paths | + +Note that the manual-dispatch selector expression is **not** uniform across the +org — most repos map `services: all` to an empty selector, while `trading` maps +it to `all` on purpose. Pin `--expect-selector` only when a repo wants its own +variant frozen; the docker/chart consistency check runs either way. + +All violations are collected and reported in one run rather than failing on the +first, so a single `pre-commit run` shows the whole picture. ## Why this repo exists diff --git a/pinpredict_hooks/__init__.py b/pinpredict_hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hooks/service-yaml-check.py b/pinpredict_hooks/service_yaml_check.py similarity index 95% rename from hooks/service-yaml-check.py rename to pinpredict_hooks/service_yaml_check.py index 9ae253b..0dda4df 100755 --- a/hooks/service-yaml-check.py +++ b/pinpredict_hooks/service_yaml_check.py @@ -154,7 +154,11 @@ def format_finding(path: Path, f: Finding) -> str: return f" {icon} [{f.check}] {f.message}" -def main(argv: list[str]) -> int: +def main(argv: list[str] | None = None) -> int: + # Defaulted so setuptools can wire this as a console script (called with no + # arguments) while direct `python -m` / script invocation still works. + if argv is None: + argv = sys.argv[1:] files = [Path(a) for a in argv] if not files: sys.stderr.write("service-yaml-check: no files provided\n") @@ -172,4 +176,4 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) + sys.exit(main()) diff --git a/pinpredict_hooks/stevedore_release_scope.py b/pinpredict_hooks/stevedore_release_scope.py new file mode 100644 index 0000000..f594eef --- /dev/null +++ b/pinpredict_hooks/stevedore_release_scope.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Assert a repo's stevedore release-scope contract. + +Onboarding or retiring a service must not silently widen the image build +contract so that *every* image in the catalog re-releases. This hook checks the +three surfaces where that widening happens: + + 1. **Catalog/chart pairing** — a named service is an image id in + `.stevedore.yaml` and has a sibling chart whose `Chart.yaml` name matches. + Keeps a manual `services: ` dispatch resolvable on both release paths. + + 2. **Release selectors** — the `docker-release` and `chart-release` reusable + workflow calls receive the *same* `only:` selector, so a manual dispatch + can never release a different set of images than charts. + + 3. **`shared_paths` hygiene** — `change_detection.shared_paths` carries the + explicit all-image contract signal, and does *not* carry paths that every + service onboarding touches anyway (the Dockerfile, `.dockerignore`, the + solution file). Listing those makes each onboarding rebuild the catalog. + +Every assertion is opt-in via flags, so the hook fits repos that have only some +of these surfaces — a repo with no `chart-release` job or no `shared_paths` key +simply skips those checks rather than failing. + +Unlike a fail-fast shell guard, all violations are collected and reported in a +single run, so one `pre-commit` invocation shows the whole picture. +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import Any + +import yaml + +MISSING = object() + + +def load_yaml(path: Path) -> Any: + """Parse `path`, returning MISSING when the file does not exist.""" + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return MISSING + try: + return yaml.safe_load(text) + except yaml.YAMLError as error: + raise SystemExit(f"stevedore-release-scope: {path} is not valid YAML: {error}") + + +def image_ids(catalog: Any) -> list[str]: + images = (catalog or {}).get("images") or [] + return [image.get("id") for image in images if isinstance(image, dict)] + + +def shared_paths(catalog: Any) -> list[str] | None: + """The declared shared_paths list, or None when the key is absent.""" + detection = (catalog or {}).get("change_detection") + if not isinstance(detection, dict) or "shared_paths" not in detection: + return None + return list(detection.get("shared_paths") or []) + + +def check_services( + catalog: Any, charts_dir: Path, services: list[str], failures: list[str] +) -> None: + known = image_ids(catalog) + for service in services: + if service not in known: + failures.append( + f"{service!r} is not an image id in the stevedore catalog " + f"(found: {', '.join(sorted(i for i in known if i)) or 'none'})" + ) + + chart = charts_dir / service / "Chart.yaml" + doc = load_yaml(chart) + if doc is MISSING: + failures.append(f"{service!r} has no sibling chart at {chart}") + elif not isinstance(doc, dict) or doc.get("name") != service: + declared = doc.get("name") if isinstance(doc, dict) else None + failures.append( + f"{chart} declares name {declared!r} but must match the image id {service!r}" + ) + + +def check_shared_paths( + catalog: Any, required: list[str], forbidden: list[str], failures: list[str] +) -> None: + if not required and not forbidden: + return + + declared = shared_paths(catalog) + if declared is None: + if required: + failures.append( + "change_detection.shared_paths is absent but " + f"{', '.join(repr(path) for path in required)} must be declared" + ) + # Nothing declared means nothing forbidden can be present. + return + + for path in required: + if path not in declared: + failures.append( + f"change_detection.shared_paths must contain {path!r} — it is the " + "explicit all-image build-contract signal" + ) + for path in forbidden: + if path in declared: + failures.append( + f"change_detection.shared_paths must not contain {path!r} — every service " + "onboarding touches it, so listing it re-releases the whole catalog" + ) + + +def check_selectors( + workflow: Any, + docker_job: str, + chart_job: str, + expected: str | None, + failures: list[str], +) -> None: + jobs = (workflow or {}).get("jobs") + if not isinstance(jobs, dict): + return + + found: dict[str, str] = {} + for name in (docker_job, chart_job): + job = jobs.get(name) + if not isinstance(job, dict): + continue + with_block = job.get("with") + found[name] = (with_block or {}).get("only", "") if isinstance(with_block, dict) else "" + + if not found: + return + + if expected is not None: + for name, selector in found.items(): + if selector != expected: + failures.append( + f"job {name!r} passes only={selector!r} but the expected manual " + f"services selector is {expected!r}" + ) + + if len(found) == 2: + (first_name, first), (second_name, second) = found.items() + if first != second: + failures.append( + f"job {first_name!r} and job {second_name!r} pass different release " + f"selectors ({first!r} vs {second!r}) — a manual dispatch would " + "release a different set of images than charts" + ) + + +def emit(failures: list[str]) -> None: + github = os.environ.get("GITHUB_ACTIONS") == "true" + for failure in failures: + prefix = "::error::" if github else "ERROR: " + print(f"{prefix}stevedore release scope: {failure}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="stevedore-release-scope", description=__doc__ or "", allow_abbrev=False + ) + parser.add_argument("--catalog", default=".stevedore.yaml", type=Path) + parser.add_argument("--workflow", default=".github/workflows/ci.yml", type=Path) + parser.add_argument("--charts-dir", default="charts", type=Path) + parser.add_argument( + "--service", + action="append", + default=[], + metavar="ID", + help="assert this image id exists and has a name-matching sibling chart (repeatable)", + ) + parser.add_argument( + "--require-shared-path", + action="append", + default=[], + metavar="PATH", + help="assert change_detection.shared_paths contains PATH (repeatable)", + ) + parser.add_argument( + "--forbid-shared-path", + action="append", + default=[], + metavar="PATH", + help="assert change_detection.shared_paths does not contain PATH (repeatable)", + ) + parser.add_argument( + "--expect-selector", + default=None, + metavar="EXPR", + help="assert both release jobs pass exactly this `only:` expression", + ) + parser.add_argument("--docker-job", default="docker-release") + parser.add_argument("--chart-job", default="chart-release") + # pre-commit passes matched filenames even with pass_filenames: false in + # some configurations; accept and ignore them so the hook stays robust. + parser.add_argument("filenames", nargs="*", help=argparse.SUPPRESS) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + catalog = load_yaml(args.catalog) + if catalog is MISSING: + print(f"stevedore-release-scope: no {args.catalog}; nothing to check.") + return 0 + + failures: list[str] = [] + check_services(catalog, args.charts_dir, args.service, failures) + check_shared_paths(catalog, args.require_shared_path, args.forbid_shared_path, failures) + + workflow = load_yaml(args.workflow) + if workflow is not MISSING: + check_selectors( + workflow, args.docker_job, args.chart_job, args.expect_selector, failures + ) + + if failures: + emit(failures) + print( + f"\n{len(failures)} release-scope violation(s). " + "Onboarding must not widen the shared image build contract.", + file=sys.stderr, + ) + return 1 + + checked = ", ".join(args.service) or "catalog" + print(f"stevedore release scope OK ({checked}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8387f59 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "pinpredict-pre-commit-hooks" +version = "0.3.0" +description = "Shared pre-commit hooks used across PinPredict repositories" +readme = "README.md" +requires-python = ">=3.9" +dependencies = ["PyYAML>=6"] + +# pre-commit's `language: python` installs this repo with pip and then runs the +# hook's `entry` as a command, so every Python hook needs a console script here. +# Without a build definition the install fails outright with "Directory '.' is +# not installable" — see the `service-yaml-check` regression this replaced. +[project.scripts] +stevedore-release-scope = "pinpredict_hooks.stevedore_release_scope:main" +service-yaml-check = "pinpredict_hooks.service_yaml_check:main" + +[tool.setuptools] +packages = ["pinpredict_hooks"] diff --git a/tests/test_stevedore_release_scope.py b/tests/test_stevedore_release_scope.py new file mode 100644 index 0000000..e1acb2a --- /dev/null +++ b/tests/test_stevedore_release_scope.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Regression tests for the stevedore-release-scope hook. + +These write plain files into a temp dir — no git repo, no subprocess — so the +whole suite stays fast enough to run as a pre-commit hook in this repo itself. +""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from pinpredict_hooks.stevedore_release_scope import main # noqa: E402 + +TRADING_SELECTOR = "${{ github.event_name == 'workflow_dispatch' && inputs.services || '' }}" + + +def catalog(images: list[tuple[str, str | None]], shared: list[str] | None) -> str: + lines = ["images:"] + for image_id, project in images: + lines.append(f" - id: {image_id}") + if project: + lines.append(f" project: {project}") + if shared is not None: + lines.append("change_detection:") + lines.append(" shared_paths:") + for path in shared: + lines.append(f' - "{path}"') + return "\n".join(lines) + "\n" + + +def workflow(docker_only: str | None, chart_only: str | None) -> str: + lines = ["name: CI", "on:", " push:", " branches: [main]", "jobs:"] + for job, selector in (("docker-release", docker_only), ("chart-release", chart_only)): + if selector is None: + continue + lines.append(f" {job}:") + lines.append(" uses: pinpredict/.github/.github/workflows/x.yml@main") + lines.append(" with:") + lines.append(f' only: "{selector}"') + return "\n".join(lines) + "\n" + + +class Repo: + """A temp directory shaped like the files the hook reads.""" + + def __init__(self, root: Path) -> None: + self.root = root + + def write(self, relative: str, content: str) -> None: + target = self.root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + def seed(self) -> None: + self.write( + ".stevedore.yaml", + catalog([("oms", "PinPredict/PinPredict.csproj")], ["Directory.Build.props"]), + ) + self.write("charts/oms/Chart.yaml", "name: oms\nversion: 0.1.0\n") + self.write( + ".github/workflows/ci.yml", workflow(TRADING_SELECTOR, TRADING_SELECTOR) + ) + + def run(self, *args: str) -> int: + return main( + [ + f"--catalog={self.root / '.stevedore.yaml'}", + f"--workflow={self.root / '.github/workflows/ci.yml'}", + f"--charts-dir={self.root / 'charts'}", + *args, + ] + ) + + +class StevedoreReleaseScopeTests(unittest.TestCase): + def make_repo(self) -> Repo: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + repo = Repo(Path(directory.name)) + repo.seed() + return repo + + def test_a_well_formed_repo_passes(self) -> None: + repo = self.make_repo() + self.assertEqual( + 0, + repo.run( + "--service=oms", + "--require-shared-path=Directory.Build.props", + "--forbid-shared-path=Dockerfile", + ), + ) + + def test_missing_catalog_is_a_no_op(self) -> None: + repo = self.make_repo() + (repo.root / ".stevedore.yaml").unlink() + self.assertEqual(0, repo.run("--service=oms")) + + def test_unknown_service_fails(self) -> None: + repo = self.make_repo() + self.assertEqual(1, repo.run("--service=nadex-rfqgw")) + + def test_service_without_a_sibling_chart_fails(self) -> None: + repo = self.make_repo() + repo.write(".stevedore.yaml", catalog([("oms", None), ("rfqgw", None)], None)) + self.assertEqual(1, repo.run("--service=rfqgw")) + + def test_chart_name_must_match_the_image_id(self) -> None: + repo = self.make_repo() + repo.write("charts/oms/Chart.yaml", "name: order-management\nversion: 0.1.0\n") + self.assertEqual(1, repo.run("--service=oms")) + + def test_forbidden_shared_path_fails(self) -> None: + repo = self.make_repo() + repo.write( + ".stevedore.yaml", + catalog([("oms", None)], ["Directory.Build.props", "Dockerfile"]), + ) + self.assertEqual(1, repo.run("--forbid-shared-path=Dockerfile")) + + def test_required_shared_path_missing_fails(self) -> None: + repo = self.make_repo() + repo.write(".stevedore.yaml", catalog([("oms", None)], ["Directory.Packages.props"])) + self.assertEqual(1, repo.run("--require-shared-path=Directory.Build.props")) + + def test_absent_shared_paths_key_fails_a_required_path(self) -> None: + repo = self.make_repo() + repo.write(".stevedore.yaml", catalog([("oms", None)], None)) + self.assertEqual(1, repo.run("--require-shared-path=Directory.Build.props")) + + def test_absent_shared_paths_key_satisfies_forbidden_paths(self) -> None: + repo = self.make_repo() + repo.write(".stevedore.yaml", catalog([("oms", None)], None)) + self.assertEqual(0, repo.run("--forbid-shared-path=Dockerfile")) + + def test_divergent_release_selectors_fail(self) -> None: + repo = self.make_repo() + repo.write(".github/workflows/ci.yml", workflow(TRADING_SELECTOR, "''")) + self.assertEqual(1, repo.run("--service=oms")) + + def test_a_repo_without_a_chart_release_job_skips_the_pairing_check(self) -> None: + repo = self.make_repo() + repo.write(".github/workflows/ci.yml", workflow(TRADING_SELECTOR, None)) + self.assertEqual(0, repo.run("--service=oms")) + + def test_expected_selector_mismatch_fails(self) -> None: + repo = self.make_repo() + other = "${{ (github.event_name == 'workflow_dispatch' && inputs.services != 'all') && inputs.services || '' }}" # noqa: E501 + repo.write(".github/workflows/ci.yml", workflow(other, other)) + self.assertEqual(1, repo.run(f"--expect-selector={TRADING_SELECTOR}")) + + def test_expected_selector_match_passes(self) -> None: + repo = self.make_repo() + self.assertEqual(0, repo.run(f"--expect-selector={TRADING_SELECTOR}")) + + def test_missing_workflow_skips_selector_checks(self) -> None: + repo = self.make_repo() + (repo.root / ".github/workflows/ci.yml").unlink() + self.assertEqual(0, repo.run("--service=oms", f"--expect-selector={TRADING_SELECTOR}")) + + def test_all_violations_are_reported_in_one_run(self) -> None: + repo = self.make_repo() + repo.write(".stevedore.yaml", catalog([("oms", None)], ["Dockerfile"])) + repo.write(".github/workflows/ci.yml", workflow(TRADING_SELECTOR, "''")) + # Unknown service + missing required path + forbidden path + selector drift. + self.assertEqual( + 1, + repo.run( + "--service=missing", + "--require-shared-path=Directory.Build.props", + "--forbid-shared-path=Dockerfile", + ), + ) + + +if __name__ == "__main__": + unittest.main()