diff --git a/.github/hook-pin-cutoffs.json b/.github/hook-pin-cutoffs.json new file mode 100644 index 0000000..6864d42 --- /dev/null +++ b/.github/hook-pin-cutoffs.json @@ -0,0 +1,8 @@ +{ + "config_sha256": "db72e009c83a9a9f82ac5746e02c4f0e1a0a94cb84826091e81fe6f0b5e3131c", + "pins": { + "ruamel-yaml==0.19.1": "2026-01-02T16:50:31.840645+00:00", + "ruff==0.17.3": "2026-10-27T00:34:05.914554+00:00", + "zizmor==1.32.0": "2026-11-02T23:31:21.600887+00:00" + } +} diff --git a/.github/workflows/update-python-dependencies.yaml b/.github/workflows/update-python-dependencies.yaml index 6253b8c..0485518 100644 --- a/.github/workflows/update-python-dependencies.yaml +++ b/.github/workflows/update-python-dependencies.yaml @@ -220,6 +220,10 @@ jobs: done <"$RUNNER_TEMP/file-groups" - name: โณ Wait for locked release tools wait: tools + - name: ๐Ÿ”’ Refresh hook pin cutoffs + uses: $/cooled-prek + with: + args: refresh-pins - id: install-hooks name: ๐Ÿงน Install prek hooks uses: $/cooled-prek diff --git a/docs/locked-tools.md b/docs/locked-tools.md index 8a74244..4313ed9 100644 --- a/docs/locked-tools.md +++ b/docs/locked-tools.md @@ -75,7 +75,7 @@ The check runs in three places: and takes no inputs: ```yaml - - uses: tox-dev/github-workflows/check-nab-exceptions@SHA + - uses: tox-dev/github-workflows/check-nab-exceptions@SHA ``` ## Run prek under the cutoff: `cooled-prek` @@ -100,8 +100,11 @@ cutoff, so a hook bumped to a release younger than seven days installs. The pin read. Once the release is a week old, nothing is written and the normal cutoff applies. A newer release of the pinned package, uploaded after the pinned one, stays blocked in every hook environment. The pinned release's dependencies and any ranges stay cooled. A pin and a `packages..uploaded-prior-to` exception for the same package: the later date -wins. `cooled-prek` fetches the hook repository and looks the pinned release up on PyPI; a failed fetch or lookup fails -the run. Pitfall: hook environments read exceptions only from `pyproject.toml`, not from a standalone `nab.toml`. +wins. `cooled-prek refresh-pins` fetches hook repositories and PyPI upload times and writes +`.github/hook-pin-cutoffs.json`. Commit that file with `.pre-commit-config.yaml`; the normal check validates its hash +and reads it without fetching metadata. The dependency update workflow refreshes it before installing hooks. A missing +or stale file fails with a refresh instruction. Pitfall: hook environments read exceptions only from `pyproject.toml`, +not from a standalone `nab.toml`. ## Install locked tools: `install-locked-tools` diff --git a/tests/conftest.py b/tests/conftest.py index 9d666d3..97e9255 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import hashlib import json import os import re @@ -125,6 +126,12 @@ def repository(tmp_path: Path, github_api: tuple[HTTPServer, dict[str, str]], mo " - id: mdformat\n name: mdformat\n entry: mdformat --wrap 120\n" " language: system\n types: [markdown]\n", ) + (worktree / ".github").mkdir() + manifest = { + "config_sha256": hashlib.sha256((worktree / ".pre-commit-config.yaml").read_bytes()).hexdigest(), + "pins": {}, + } + (worktree / ".github/hook-pin-cutoffs.json").write_text(f"{json.dumps(manifest, indent=2, sort_keys=True)}\n") (worktree / "CHANGELOG.md").write_text("# Changelog\n\n\n") (worktree / "changelog.d").mkdir() run(worktree, "git", "add", ".") diff --git a/tests/cooled_prek/test_cooled_prek.py b/tests/cooled_prek/test_cooled_prek.py index 2d05373..b3a562d 100644 --- a/tests/cooled_prek/test_cooled_prek.py +++ b/tests/cooled_prek/test_cooled_prek.py @@ -45,7 +45,10 @@ def route_open(request, *args, **kwargs): f"import sitecustomize; exec(compile(sys.stdin.read(), {str(SCRIPT.with_name('hook_pin_cutoffs.py'))!r}, 'exec'))" ) fake = directory / "uv" - fake.write_text(f"#!/bin/sh\nexec {sys.executable!r} -I -c {shlex.quote(code)}\n") + fake.write_text( + f'#!/bin/sh\ncase "$*" in *--refresh*) set -- --refresh ;; *) set -- ;; esac\n' + f'exec {sys.executable!r} -I -c {shlex.quote(code)} "$@"\n' + ) fake.chmod(0o755) @@ -239,6 +242,16 @@ def _cooled_prek(root: Path, httpserver: HTTPServer) -> subprocess.CompletedProc fake.write_text('#!/bin/sh\ncat "${UV_CONFIG_FILE:-/dev/null}" >config\n') fake.chmod(0o755) _fake_uv(root, httpserver) + refreshed = subprocess.run( + [str(SCRIPT), "refresh-pins"], + cwd=root, + env=os.environ | {"PATH": f"{root}{os.pathsep}{os.environ['PATH']}", "NO_PROXY": "localhost,127.0.0.1"}, + capture_output=True, + text=True, + check=False, + ) + if refreshed.returncode: + return refreshed return subprocess.run( [str(SCRIPT), "install-hooks"], cwd=root, @@ -427,6 +440,105 @@ def test_cooled_prek_does_not_write_pin_older_than_window(tmp_path: Path, httpse assert (tmp_path / "config").read_text() == "" +def test_cooled_prek_uses_committed_pin_dates_without_metadata_requests(tmp_path: Path, httpserver: HTTPServer) -> None: + uploaded = _uploaded(1) + _simple(httpserver, {"0.7.1": uploaded}) + repo = _hook_repo(tmp_path, {"setup.py": 'setup(install_requires=["simplint==0.7.1"])\n'}) + _config(tmp_path, repo, {"language": "python"}) + assert _cooled_prek(tmp_path, httpserver).returncode == 0 + requests = len(httpserver.log) + for path in repo.iterdir(): + if path.is_file(): + path.unlink() + + result = subprocess.run( + [str(SCRIPT), "install-hooks"], + cwd=tmp_path, + env=os.environ | {"PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}"}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert len(httpserver.log) == requests + assert (tmp_path / "config").read_text().splitlines() == ["[exclude-newer-package]", _admitted(uploaded)] + + +def test_cooled_prek_rejects_stale_pin_dates(tmp_path: Path, httpserver: HTTPServer) -> None: + _config(tmp_path, _hook_repo(tmp_path, {}), {"language": "python"}) + assert _cooled_prek(tmp_path, httpserver).returncode == 0 + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + + result = subprocess.run( + [str(SCRIPT), "install-hooks"], + cwd=tmp_path, + env=os.environ | {"PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}"}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "is stale; run cooled-prek refresh-pins" in result.stderr + + +def test_cooled_prek_refresh_keeps_unchanged_manifest(tmp_path: Path, httpserver: HTTPServer) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + assert _cooled_prek(tmp_path, httpserver).returncode == 0 + path = tmp_path / ".github/hook-pin-cutoffs.json" + previous = path.stat().st_mtime_ns + + result = subprocess.run( + [str(SCRIPT), "refresh-pins"], + cwd=tmp_path, + env=os.environ | {"PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}"}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert path.stat().st_mtime_ns == previous + + +def test_cooled_prek_requires_manifest(tmp_path: Path, httpserver: HTTPServer) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + _fake_uv(tmp_path, httpserver) + + result = subprocess.run( + [str(SCRIPT), "install-hooks"], + cwd=tmp_path, + env=os.environ | {"PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}"}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "is missing; run cooled-prek refresh-pins" in result.stderr + + +def test_cooled_prek_rejects_invalid_manifest(tmp_path: Path, httpserver: HTTPServer) -> None: + (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n") + _fake_uv(tmp_path, httpserver) + path = tmp_path / ".github/hook-pin-cutoffs.json" + path.parent.mkdir() + path.write_text("{") + + result = subprocess.run( + [str(SCRIPT), "install-hooks"], + cwd=tmp_path, + env=os.environ | {"PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}"}, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "cannot read .github/hook-pin-cutoffs.json" in result.stderr + + @pytest.mark.parametrize( ("nab", "expected"), [ diff --git a/tests/prepare_towncrier/test_prepare.py b/tests/prepare_towncrier/test_prepare.py index 7c4fd57..96a1ecd 100644 --- a/tests/prepare_towncrier/test_prepare.py +++ b/tests/prepare_towncrier/test_prepare.py @@ -1,5 +1,4 @@ -from __future__ import annotations - +import sys from pathlib import Path from textwrap import dedent @@ -252,6 +251,7 @@ def test_prepare_formats_rst( " name: docstrfmt\n entry: sed -i s/Before/After/\n" " language: system\n files: CHANGELOG.rst\n" ) + run(repository, sys.executable, str(Path(__file__).resolve().parents[2] / "tools/hook_pin_cutoffs.py"), "--refresh") run(repository, "git", "add", "-A") run(repository, "git", "commit", "-m", "Set RST hook") run(repository, "git", "push", "origin", "main") @@ -290,6 +290,7 @@ def test_prepare_avoids_rst_conflict_marker( " name: docstrfmt\n entry: bash -c true\n" " language: system\n files: CHANGELOG.rst\n" ) + run(repository, sys.executable, str(Path(__file__).resolve().parents[2] / "tools/hook_pin_cutoffs.py"), "--refresh") run(repository, "git", "add", "-A") run(repository, "git", "commit", "-m", "Set RST category") run(repository, "git", "push", "origin", "main") diff --git a/tools/cooled_prek.py b/tools/cooled_prek.py index c1dcff5..a69f495 100755 --- a/tools/cooled_prek.py +++ b/tools/cooled_prek.py @@ -11,6 +11,8 @@ def main() -> int: arguments = sys.argv[1:] + if arguments == ["refresh-pins"]: + return _pin_cutoffs(["--refresh"]).returncode if arguments[:1] != ["run"]: return _install_hooks(arguments) hooks = [] @@ -26,15 +28,7 @@ def main() -> int: def _install_hooks(arguments: list[str]) -> int: environment = clean_uv_environment() | {"UV_EXCLUDE_NEWER": "7 days", "UV_NO_CONFIG": "1", "UV_NO_SOURCES": "1"} - result = subprocess.run( - ["uv", "run", "--no-project", "--with", "pyyaml", "python", "-I", "-"], - input=Path(__file__).with_name("hook_pin_cutoffs.py").read_text(), - text=True, - capture_output=True, - env=environment, - check=False, - ) - sys.stderr.write(result.stderr) + result = _pin_cutoffs([], environment) if result.returncode: return result.returncode exceptions = result.stdout.strip() @@ -60,4 +54,17 @@ def _install_hooks(arguments: list[str]) -> int: ).returncode +def _pin_cutoffs(arguments: list[str], environment: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["uv", "run", "--no-project", "--with", "pyyaml", "python", "-I", "-", *arguments], + input=Path(__file__).with_name("hook_pin_cutoffs.py").read_text(), + text=True, + capture_output=True, + env=environment or clean_uv_environment(), + check=False, + ) + sys.stderr.write(result.stderr) + return result + + raise SystemExit(main()) diff --git a/tools/hook_pin_cutoffs.py b/tools/hook_pin_cutoffs.py index 330e15e..4dd228c 100644 --- a/tools/hook_pin_cutoffs.py +++ b/tools/hook_pin_cutoffs.py @@ -3,6 +3,7 @@ import ast import configparser import datetime +import hashlib import json import pathlib import re @@ -23,6 +24,11 @@ class Hook(TypedDict): additional_dependencies: NotRequired[list[str]] +class PinManifest(TypedDict): + config_sha256: str + pins: dict[str, str] + + def normalize(name: str) -> str: return re.sub(r"[-_.]+", "-", name).lower() @@ -174,7 +180,47 @@ def as_datetime(value: str | datetime.datetime | None) -> datetime.datetime | No return value if isinstance(value, datetime.datetime) and value.tzinfo is not None else None +def pin_manifest() -> PinManifest: + config_path = pathlib.Path(".pre-commit-config.yaml") + config_bytes = config_path.read_bytes() if config_path.is_file() else b"" + config = yaml.safe_load(config_bytes) or {} + pins = { + f"{name}=={version}": upload_time(repo["repo"], name, version).isoformat() + for repo in config.get("repos", []) + if repo["repo"] not in {"local", "meta"} + for name, version in hook_pins(repo["repo"], repo["rev"], repo.get("hooks", [])) + } + return {"config_sha256": hashlib.sha256(config_bytes).hexdigest(), "pins": pins} + + +def refresh() -> None: + path = pathlib.Path(".github/hook-pin-cutoffs.json") + path.parent.mkdir(parents=True, exist_ok=True) + content = f"{json.dumps(pin_manifest(), indent=2, sort_keys=True)}\n" + if not path.is_file() or path.read_text() != content: + path.write_text(content) + + +def read_pin_manifest() -> dict[str, str]: + path = pathlib.Path(".github/hook-pin-cutoffs.json") + if not path.is_file(): + if not pathlib.Path(".pre-commit-config.yaml").is_file(): + return {} + sys.exit(f"cooled-prek: {path} is missing; run cooled-prek refresh-pins and commit it") + try: + manifest = json.loads(path.read_text()) + config = pathlib.Path(".pre-commit-config.yaml") + if manifest["config_sha256"] != hashlib.sha256(config.read_bytes() if config.is_file() else b"").hexdigest(): + sys.exit(f"cooled-prek: {path} is stale; run cooled-prek refresh-pins and commit it") + return manifest["pins"] + except (OSError, ValueError, KeyError, TypeError) as error: + sys.exit(f"cooled-prek: cannot read {path}: {error}") + + def main() -> None: + if sys.argv[1:] == ["--refresh"]: + refresh() + return path = pathlib.Path("pyproject.toml") packages = ( tomllib.loads(path.read_text()).get("tool", {}).get("nab", {}).get("packages", {}) if path.is_file() else {} @@ -184,17 +230,13 @@ def main() -> None: for name, settings in packages.items() if settings.get("uploaded-prior-to") is not None } - path = pathlib.Path(".pre-commit-config.yaml") - config = (yaml.safe_load(path.read_text()) if path.is_file() else None) or {} seven_days_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=7) pins = {} - for repo in config.get("repos", []): - if repo["repo"] in {"local", "meta"}: - continue - for name, version in hook_pins(repo["repo"], repo["rev"], repo.get("hooks", [])): - cutoff = upload_time(repo["repo"], name, version).replace(microsecond=0) + datetime.timedelta(seconds=1) - if cutoff > seven_days_ago: - pins[name] = max(pins.get(name, cutoff), cutoff) + for pin, uploaded in read_pin_manifest().items(): + name, _, _ = pin.partition("==") + cutoff = datetime.datetime.fromisoformat(uploaded).replace(microsecond=0) + datetime.timedelta(seconds=1) + if cutoff > seven_days_ago: + pins[name] = max(pins.get(name, cutoff), cutoff) for name, cutoff in pins.items(): if (current := as_datetime(cutoffs.get(name))) is None or current < cutoff: cutoffs[name] = cutoff