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
86 changes: 86 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 19 additions & 2 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
11 changes: 11 additions & 0 deletions .yamllint.yml
Original file line number Diff line number Diff line change
@@ -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
71 changes: 66 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<svc>.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

Expand All @@ -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
```
Expand Down Expand Up @@ -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/<hook-id>.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/<hook-id>.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

Expand Down
Empty file added pinpredict_hooks/__init__.py
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -172,4 +176,4 @@ def main(argv: list[str]) -> int:


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
sys.exit(main())
Loading
Loading