From 71a43d96583a5c4c34aeb6b0e0a2b92dd8e2697d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 28 Aug 2026 14:12:00 +0200 Subject: [PATCH] Script the gate rollout instead of hand-copying it into sixteen repos The gate is three files per repo -- a delegate, a pre-commit hook entry and a CI workflow -- and Phase 3 needs them in sixteen more repos. Hand-replication is precisely how the 250-line cap ended up shipping under four different hook ids, two of which were independent reimplementations rather than delegates, so the copy is now a script and the templates have exactly one home. scripts/install_dependency_freshness_gate.sh [--check] [--fvm] Idempotent, and it rewrites a copy that has drifted from the template rather than leaving it. `--check` reports without writing. `--fvm` pins the Flutter SDK, and only in a repo that actually holds a pubspec.yaml -- an .fvmrc in a Python repo declares a toolchain nothing there builds with. An unresolvable SDK version leaves the pin alone and exits 1: guessing would pin every repo to a number nobody chose. The pre-commit block is APPENDED rather than parsed and re-emitted. Every one of these configs carries comments explaining why its hooks exist, and a YAML round-trip through the standard library drops all of them. 306 tests, 100% branch coverage on every production module. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CRykabMwPgDzFHcARoqpaK --- dep_freshness/install.py | 113 ++++++++++++ dep_freshness/install_cli.py | 57 ++++++ dep_freshness/install_main.py | 10 + dep_freshness/templates/delegate.sh | 34 ++++ dep_freshness/templates/precommit-hook.yaml | 20 ++ dep_freshness/templates/workflow.yml | 57 ++++++ dep_freshness/tests/test_install.py | 182 +++++++++++++++++++ scripts/install_dependency_freshness_gate.sh | 85 +++++++++ 8 files changed, 558 insertions(+) create mode 100644 dep_freshness/install.py create mode 100644 dep_freshness/install_cli.py create mode 100644 dep_freshness/install_main.py create mode 100755 dep_freshness/templates/delegate.sh create mode 100644 dep_freshness/templates/precommit-hook.yaml create mode 100644 dep_freshness/templates/workflow.yml create mode 100644 dep_freshness/tests/test_install.py create mode 100755 scripts/install_dependency_freshness_gate.sh diff --git a/dep_freshness/install.py b/dep_freshness/install.py new file mode 100644 index 0000000..eff5500 --- /dev/null +++ b/dep_freshness/install.py @@ -0,0 +1,113 @@ +"""Install the three gate pieces into a repo, from one set of templates. + +The gate ships as three hand-replicated files per repo -- a delegate script, a +pre-commit hook entry and a CI workflow -- and hand-replication is exactly how +the 250-line cap ended up with four different hook ids, two of them +independent reimplementations rather than delegates. Sixteen more repos of +copy-paste would repeat that, so the copy is a script and the templates have +one home. + +Idempotent by design: running it twice changes nothing the second time, and +`plan()` reports what a run WOULD do without touching the repo. +""" + +from __future__ import annotations + +from pathlib import Path + +TEMPLATES = Path(__file__).resolve().parent / "templates" +DELEGATE = Path("scripts/check_dependency_freshness.sh") +WORKFLOW = Path(".github/workflows/dependency-freshness.yml") +PRECOMMIT = Path(".pre-commit-config.yaml") +HOOK_ID = "dependency-freshness" + +EMPTY_PRECOMMIT = "repos:\n - repo: local\n hooks:\n" + + +def _template(name: str) -> str: + return (TEMPLATES / name).read_text(encoding="utf-8") + + +def _needs_delegate(repo: Path) -> bool: + target = repo / DELEGATE + return not target.is_file() or target.read_text(encoding="utf-8") != _template( + "delegate.sh" + ) + + +def _needs_workflow(repo: Path) -> bool: + target = repo / WORKFLOW + return not target.is_file() or target.read_text(encoding="utf-8") != _template( + "workflow.yml" + ) + + +def _needs_hook(repo: Path) -> bool: + target = repo / PRECOMMIT + if not target.is_file(): + return True + return f"id: {HOOK_ID}" not in target.read_text(encoding="utf-8") + + +def _write_hook(repo: Path) -> None: + """Append the hook block to the repo's `local` hooks list. + + Appending rather than parsing-and-re-emitting is deliberate: every one of + these configs carries comments explaining why a hook exists, and a YAML + round-trip through the standard library drops all of them. + """ + target = repo / PRECOMMIT + body = target.read_text(encoding="utf-8") if target.is_file() else EMPTY_PRECOMMIT + if not body.endswith("\n"): + body += "\n" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"{body}\n{_template('precommit-hook.yaml')}", encoding="utf-8") + + +def plan(repo: Path) -> list[str]: + """Which pieces are missing or have drifted from the template.""" + todo = [] + if _needs_delegate(repo): + todo.append(str(DELEGATE)) + if _needs_workflow(repo): + todo.append(str(WORKFLOW)) + if _needs_hook(repo): + todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)") + return todo + + +def install(repo: Path) -> list[str]: + """Write every missing or drifted piece. Returns what changed.""" + done = [] + if _needs_delegate(repo): + target = repo / DELEGATE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(_template("delegate.sh"), encoding="utf-8") + target.chmod(0o755) + done.append(str(DELEGATE)) + if _needs_workflow(repo): + target = repo / WORKFLOW + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(_template("workflow.yml"), encoding="utf-8") + done.append(str(WORKFLOW)) + if _needs_hook(repo): + _write_hook(repo) + done.append(f"{PRECOMMIT} ({HOOK_ID} hook)") + return done + + +def write_fvmrc(repo: Path, version: str) -> bool: + """Pin the Flutter SDK. Returns True if the file changed. + + Only for repos that actually hold a pubspec.yaml -- an .fvmrc in a Python + repo is a declaration about a toolchain nothing there builds with, and the + gate would then check a version that cannot go stale in any useful sense. + """ + if not any(repo.rglob("pubspec.yaml")): + return False + target = repo / ".fvmrc" + body = '{\n "flutter": "%s"\n}\n' % version + if target.is_file() and target.read_text(encoding="utf-8") == body: + return False + target.write_text(body, encoding="utf-8") + return True diff --git a/dep_freshness/install_cli.py b/dep_freshness/install_cli.py new file mode 100644 index 0000000..a6fd592 --- /dev/null +++ b/dep_freshness/install_cli.py @@ -0,0 +1,57 @@ +"""Command line for `install`: which repo, check-only, and the SDK pin.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from dep_freshness.install import install, plan, write_fvmrc +from dep_freshness.registries.toolchain import flutter_latest + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Install the dependency-freshness gate into a repo." + ) + parser.add_argument("repo", type=Path) + parser.add_argument( + "--check", + action="store_true", + help="report what would be written, change nothing", + ) + parser.add_argument( + "--fvm", action="store_true", help="also pin the Flutter SDK in /.fvmrc" + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + repo = args.repo.resolve() + + if args.check: + todo = plan(repo) + for piece in todo: + print(f"would write: {piece}") + if not todo: + print("gate already current") + return 0 + + for piece in install(repo): + print(f"wrote: {piece}") + + if args.fvm: + version, _dart = flutter_latest() + if version is None: + # Guessing a version here would pin every repo to a number nobody + # chose, and the gate would then compare against it forever. + print( + "could not resolve latest stable Flutter; .fvmrc left alone", + file=sys.stderr, + ) + return 1 + if write_fvmrc(repo, version): + print(f"wrote: .fvmrc (flutter {version})") + + return 0 diff --git a/dep_freshness/install_main.py b/dep_freshness/install_main.py new file mode 100644 index 0000000..f261681 --- /dev/null +++ b/dep_freshness/install_main.py @@ -0,0 +1,10 @@ +"""`python3 -m dep_freshness.install_main` — the installer's entrypoint. + +Three lines for the same reason as `__main__.py`: an `if __name__` guard +inside install_cli.py would be a branch no test can enter, and the honest way +to hold full coverage is to have no untestable branch rather than suppress one. +""" + +from dep_freshness.install_cli import main + +raise SystemExit(main()) diff --git a/dep_freshness/templates/delegate.sh b/dep_freshness/templates/delegate.sh new file mode 100755 index 0000000..5cb0d78 --- /dev/null +++ b/dep_freshness/templates/delegate.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# ============================================================================ +# Fail if any dependency in the commit is behind its ecosystem's newest stable. +# +# Thin delegate to the shared gate in ~/utils, which owns the registry +# adapters, the pre-release rules and the allowlist semantics. Copying that +# logic here is what lets one repo's idea of "current" drift from every other +# repo's -- so this script only locates the shared checker and forwards its +# arguments. +# +# Usage: +# scripts/check_dependency_freshness.sh [ ...] # pre-commit +# scripts/check_dependency_freshness.sh --all --strict # CI +# +# Exit: 0 current | 1 behind | 2 allowlist wrong | 3 undeterminable (offline) +# ============================================================================ + +set -euo pipefail + +readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_dependency_freshness.sh" + +main() { + if [[ ! -x "$SHARED_GATE" ]]; then + echo "Error: shared dependency-freshness gate not found at $SHARED_GATE" >&2 + echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2 + echo " UTILS_ROOT to where it lives." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" +} + +main "$@" diff --git a/dep_freshness/templates/precommit-hook.yaml b/dep_freshness/templates/precommit-hook.yaml new file mode 100644 index 0000000..3f557cb --- /dev/null +++ b/dep_freshness/templates/precommit-hook.yaml @@ -0,0 +1,20 @@ + # Every dependency on its ecosystem's newest STABLE release, exact-pinned + # so the question is mechanically answerable at all. + # + # `files:` is what keeps this cheap: a commit touching only source makes + # ZERO network requests, and manifest commits mostly hit a 6h disk cache. + # + # Runs WITHOUT --strict on purpose. Offline it degrades to the cached + # answer and passes with a loud banner; `--no-verify` is banned here, so + # a hook that hard-failed on a flaky connection would leave no way to + # commit at all. CI, always online, is the strict authority. + - id: dependency-freshness + name: dependencies on newest stable + entry: scripts/check_dependency_freshness.sh + language: system + pass_filenames: true + files: >- + (pubspec\.(yaml|lock)|pyproject\.toml|requirements.*\.txt|uv\.lock + |package\.json|package-lock\.json|pnpm-lock\.yaml + |Cargo\.(toml|lock)|go\.(mod|sum) + |\.fvmrc|\.nvmrc|\.python-version)$ diff --git a/dep_freshness/templates/workflow.yml b/dep_freshness/templates/workflow.yml new file mode 100644 index 0000000..e3f46ac --- /dev/null +++ b/dep_freshness/templates/workflow.yml @@ -0,0 +1,57 @@ +name: dependency freshness + +# Every dependency on its ecosystem's newest STABLE release, enforced on push +# as well as at commit time. The local pre-commit hook only sees the manifests +# in a commit and can be skipped entirely by a fresh clone that never ran +# `pre-commit install`; this runs `--all` over the whole repo. +# +# `--strict` is CI-only. Offline the gate degrades to its cached answer and +# passes, because `--no-verify` is banned locally and a hook that hard-failed +# on a flaky connection would leave no way to commit. A runner is always +# online, so here an undeterminable answer is a real failure. +# +# Expect this to go red for reasons this repo's commit did not cause: an +# upstream release overnight is enough. That is the chosen behaviour -- the +# alternative is a gate that only notices staleness when someone remembers to +# look. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + dependency-freshness: + name: dependencies on newest stable + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The gate lives in the shared utils repo so every repo resolves the + # same "latest stable" the same way. Checking it out beats vendoring a + # copy, which is exactly how a gate and its survey start disagreeing. + - name: Check out the shared gate + uses: actions/checkout@v4 + with: + repository: kuhyx/utils + path: .utils + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install the gate's two runtime dependencies + run: python -m pip install --upgrade pyyaml packaging + + - name: Cache registry lookups + uses: actions/cache@v4 + with: + path: ~/.cache/dep-freshness + key: dep-freshness-${{ hashFiles('**/pubspec.yaml', '**/pyproject.toml', + '**/requirements*.txt', '**/package.json', '**/Cargo.toml', '**/go.mod') }} + restore-keys: dep-freshness- + + - name: Every dependency is on newest stable + run: bash .utils/scripts/check_dependency_freshness.sh --all --strict diff --git a/dep_freshness/tests/test_install.py b/dep_freshness/tests/test_install.py new file mode 100644 index 0000000..9cfff4a --- /dev/null +++ b/dep_freshness/tests/test_install.py @@ -0,0 +1,182 @@ +"""The gate installer: three pieces in, idempotent, and no comments lost. + +The point of this module is that sixteen repos get byte-identical copies, so +the assertions compare against the templates rather than against a paraphrase +of them -- a test that spelled the expected content out by hand would let the +template and the installed file drift, which is the failure the installer +exists to prevent. +""" + +from __future__ import annotations + +import runpy +from pathlib import Path + +import pytest + +from dep_freshness import install as installer +from dep_freshness.install_cli import main +from dep_freshness.tests.conftest import write + +TEMPLATES = Path(installer.__file__).resolve().parent / "templates" + + +def _template(name: str) -> str: + return (TEMPLATES / name).read_text(encoding="utf-8") + + +def test_a_bare_repo_gets_all_three_pieces(repo): + assert installer.install(repo) == [ + "scripts/check_dependency_freshness.sh", + ".github/workflows/dependency-freshness.yml", + ".pre-commit-config.yaml (dependency-freshness hook)", + ] + delegate = repo / "scripts/check_dependency_freshness.sh" + assert delegate.read_text(encoding="utf-8") == _template("delegate.sh") + assert delegate.stat().st_mode & 0o111, "the hook execs it; it must be +x" + assert (repo / ".github/workflows/dependency-freshness.yml").read_text( + encoding="utf-8" + ) == _template("workflow.yml") + assert "id: dependency-freshness" in (repo / ".pre-commit-config.yaml").read_text( + encoding="utf-8" + ) + + +def test_a_second_run_writes_nothing(repo): + installer.install(repo) + assert installer.install(repo) == [] + assert installer.plan(repo) == [] + + +def test_plan_names_what_is_missing_without_writing_it(repo): + assert installer.plan(repo) == [ + "scripts/check_dependency_freshness.sh", + ".github/workflows/dependency-freshness.yml", + ".pre-commit-config.yaml (dependency-freshness hook)", + ] + assert not (repo / "scripts").exists() + + +def test_a_drifted_copy_is_rewritten(repo): + installer.install(repo) + delegate = repo / "scripts/check_dependency_freshness.sh" + delegate.write_text("#!/bin/bash\necho lol\n", encoding="utf-8") + workflow = repo / ".github/workflows/dependency-freshness.yml" + workflow.write_text("name: something else\n", encoding="utf-8") + assert installer.install(repo) == [ + "scripts/check_dependency_freshness.sh", + ".github/workflows/dependency-freshness.yml", + ] + assert delegate.read_text(encoding="utf-8") == _template("delegate.sh") + + +def test_an_existing_pre_commit_config_keeps_its_hooks_and_comments(repo): + """Appending, not re-emitting: a YAML round-trip drops every comment. + + Each of these configs explains why its hooks exist, and that prose is the + only record of it. + """ + write( + repo, + ".pre-commit-config.yaml", + """\ +# The 250-line cap. A file that cannot be read in one piece forces re-reads. +repos: + - repo: local + hooks: + - id: file-length-cap + name: file length <= 250 lines + entry: bash scripts/check_file_length.sh + language: system +""", + ) + installer.install(repo) + body = (repo / ".pre-commit-config.yaml").read_text(encoding="utf-8") + assert "# The 250-line cap." in body + assert "id: file-length-cap" in body + assert "id: dependency-freshness" in body + + +def test_a_config_that_already_has_the_hook_is_left_alone(repo): + write( + repo, + ".pre-commit-config.yaml", + """\ +repos: + - repo: local + hooks: + - id: dependency-freshness + entry: scripts/check_dependency_freshness.sh + language: system +""", + ) + before = (repo / ".pre-commit-config.yaml").read_text(encoding="utf-8") + installer.install(repo) + assert (repo / ".pre-commit-config.yaml").read_text(encoding="utf-8") == before + + +def test_a_config_without_a_trailing_newline_still_appends_cleanly(repo): + write(repo, ".pre-commit-config.yaml", "repos:\n - repo: local\n hooks:") + installer.install(repo) + body = (repo / ".pre-commit-config.yaml").read_text(encoding="utf-8") + assert body.startswith("repos:\n - repo: local\n hooks:\n\n #") + assert "id: dependency-freshness" in body + + +def test_fvmrc_is_only_for_repos_that_hold_a_pubspec(repo): + assert installer.write_fvmrc(repo, "3.47.2") is False + assert not (repo / ".fvmrc").exists() + write(repo, "app/pubspec.yaml", "name: demo\n") + assert installer.write_fvmrc(repo, "3.47.2") is True + assert (repo / ".fvmrc").read_text( + encoding="utf-8" + ) == '{\n "flutter": "3.47.2"\n}\n' + assert installer.write_fvmrc(repo, "3.47.2") is False + + +def test_the_cli_installs_and_reports_each_piece(repo, capsys): + assert main([str(repo)]) == 0 + out = capsys.readouterr().out + assert "wrote: scripts/check_dependency_freshness.sh" in out + assert "wrote: .github/workflows/dependency-freshness.yml" in out + + +def test_the_cli_check_mode_writes_nothing(repo, capsys): + assert main([str(repo), "--check"]) == 0 + assert ( + "would write: scripts/check_dependency_freshness.sh" in capsys.readouterr().out + ) + assert not (repo / "scripts").exists() + main([str(repo)]) + assert main([str(repo), "--check"]) == 0 + assert "gate already current" in capsys.readouterr().out + + +def test_the_cli_pins_the_sdk_when_asked(repo, monkeypatch, capsys): + monkeypatch.setattr( + "dep_freshness.install_cli.flutter_latest", lambda: ("3.47.2", "3.13.2") + ) + write(repo, "pubspec.yaml", "name: demo\n") + assert main([str(repo), "--fvm"]) == 0 + assert "wrote: .fvmrc (flutter 3.47.2)" in capsys.readouterr().out + # Same version again: the pin is already right, so nothing is reported. + assert main([str(repo), "--fvm"]) == 0 + assert ".fvmrc" not in capsys.readouterr().out + + +def test_an_unresolvable_sdk_leaves_the_pin_alone(repo, monkeypatch, capsys): + """Offline, the alternative is pinning every repo to a guessed number.""" + monkeypatch.setattr( + "dep_freshness.install_cli.flutter_latest", lambda: (None, None) + ) + write(repo, "pubspec.yaml", "name: demo\n") + assert main([str(repo), "--fvm"]) == 1 + assert "could not resolve latest stable Flutter" in capsys.readouterr().err + assert not (repo / ".fvmrc").exists() + + +def test_the_module_entrypoint_runs_the_cli(repo, monkeypatch): + monkeypatch.setattr("sys.argv", ["install_main", str(repo), "--check"]) + with pytest.raises(SystemExit) as exit_info: + runpy.run_module("dep_freshness.install_main", run_name="__main__") + assert exit_info.value.code == 0 diff --git a/scripts/install_dependency_freshness_gate.sh b/scripts/install_dependency_freshness_gate.sh new file mode 100755 index 0000000..d244fa3 --- /dev/null +++ b/scripts/install_dependency_freshness_gate.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +# ============================================================================ +# Install the dependency-freshness gate's three pieces into a repo. +# +# The gate is a delegate script + a pre-commit hook entry + a CI workflow, and +# every repo needs all three. Copying them by hand across ~16 repos is what +# produced four different ids for the 250-line cap, two of them independent +# reimplementations, so the copy lives here and the templates have one home +# (utils/dep_freshness/templates/). +# +# Idempotent: a second run reports "already current" and writes nothing. +# +# NOTE: never install the gate ahead of the upgrade. A repo that gets the gate +# while its dependencies are still stale is instantly and permanently red -- +# gate and upgrade belong in the same commit. +# +# Usage: +# scripts/install_dependency_freshness_gate.sh [--check] [--fvm] +# +# Options: +# --check report what a run would write, change nothing +# --fvm also pin the Flutter SDK in /.fvmrc (Dart repos only) +# +# Exit: 0 installed or already current | 1 bad usage / missing repo +# ============================================================================ + +set -euo pipefail + +readonly UTILS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly SCRIPT_NAME="$(basename "$0")" + +REPO="" +CHECK="" +FVM="" + +usage() { + echo "Usage: $SCRIPT_NAME [--check] [--fvm]" + echo " --check report what would be written, change nothing" + echo " --fvm also write /.fvmrc pinning the Flutter SDK" + exit 0 +} + +validate_requirements() { + if [[ -z "$REPO" ]]; then + echo "Error: a repository path is required" >&2 + exit 1 + fi + if [[ ! -d "$REPO" ]]; then + echo "Error: no such directory: $REPO" >&2 + exit 1 + fi +} + +main() { + validate_requirements + PYTHONPATH="$UTILS_ROOT${PYTHONPATH:+:$PYTHONPATH}" \ + python3 -m dep_freshness.install_main "$REPO" $CHECK $FVM +} + +while [[ $# -gt 0 ]]; do + case $1 in + --check) + CHECK="--check" + shift + ;; + --fvm) + FVM="--fvm" + shift + ;; + -h|--help) + usage + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + REPO="$1" + shift + ;; + esac +done + +main