From d6b92cfd18e867fc15b1e43f0ce1a65d2f7adc40 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 28 Aug 2026 15:51:00 +0200 Subject: [PATCH] Installer teaches an existing pre-commit workflow where the shared gate lives Co-Authored-By: Claude Opus 5 (1M context) --- dep_freshness/install.py | 26 ++++-- dep_freshness/install_precommit_ci.py | 67 ++++++++++++++ .../tests/test_install_precommit_ci.py | 91 +++++++++++++++++++ 3 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 dep_freshness/install_precommit_ci.py create mode 100644 dep_freshness/tests/test_install_precommit_ci.py diff --git a/dep_freshness/install.py b/dep_freshness/install.py index 035eebe..4865e5f 100644 --- a/dep_freshness/install.py +++ b/dep_freshness/install.py @@ -1,11 +1,12 @@ -"""Install the three gate pieces into a repo, from one set of templates. +"""Install the gate's 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. +pre-commit hook entry and a CI workflow, plus a fourth edit to an existing +`pre-commit` workflow where one exists (see install_precommit_ci) -- 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. @@ -15,6 +16,12 @@ from __future__ import annotations from pathlib import Path +from dep_freshness.install_precommit_ci import ( + PRECOMMIT_WORKFLOW, + needs_patch, + patch, +) + TEMPLATES = Path(__file__).resolve().parent / "templates" DELEGATE = Path("scripts/check_dependency_freshness.sh") WORKFLOW = Path(".github/workflows/dependency-freshness.yml") @@ -65,8 +72,7 @@ def _local_block_end(lines: list[str]) -> int: if not starts: return -1 after = [ - n for n in range(starts[-1] + 1, len(lines)) - if lines[n].startswith(" - repo:") + n for n in range(starts[-1] + 1, len(lines)) if lines[n].startswith(" - repo:") ] return after[0] if after else len(lines) @@ -106,6 +112,8 @@ def plan(repo: Path) -> list[str]: todo.append(str(WORKFLOW)) if _needs_hook(repo): todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)") + if needs_patch(repo): + todo.append(f"{PRECOMMIT_WORKFLOW} (shared-gate checkout)") return todo @@ -126,6 +134,8 @@ def install(repo: Path) -> list[str]: if _needs_hook(repo): _write_hook(repo) done.append(f"{PRECOMMIT} ({HOOK_ID} hook)") + if patch(repo): + done.append(f"{PRECOMMIT_WORKFLOW} (shared-gate checkout)") return done diff --git a/dep_freshness/install_precommit_ci.py b/dep_freshness/install_precommit_ci.py new file mode 100644 index 0000000..f5c1eaf --- /dev/null +++ b/dep_freshness/install_precommit_ci.py @@ -0,0 +1,67 @@ +"""Give a repo's `pre-commit` CI workflow the shared-gate checkout it needs. + +The fourth piece of the install, and the one that is easy to forget because +nothing local reveals it: `.pre-commit-config.yaml` gains a hook that execs +`scripts/check_dependency_freshness.sh`, which delegates to `~/utils`. That +path exists on this machine and does not exist on a runner, so a repo whose +`pre-commit` workflow runs `pre-commit/action` against a bare checkout goes red +with "shared gate not found at /home/runner/utils/..." -- green locally, red in +CI, with no diff that explains it. screen-locker, wake-alarm and diet-guard +each hit it and each got the same twelve lines pasted in by hand. + +Only repos that HAVE such a workflow are touched; the dedicated +`dependency-freshness.yml` already carries its own checkout. +""" + +from __future__ import annotations + +from pathlib import Path + +PRECOMMIT_WORKFLOW = Path(".github/workflows/pre-commit.yml") +MARKER = "UTILS_ROOT" +CHECKOUT = "- uses: actions/checkout@v4" + +PATCH = """\ + + # Hooks here delegate to the shared gates in kuhyx/utils. Without this + # checkout they exit 1 with "shared gate not found at + # /home/runner/utils/..." -- green locally, red on every runner. + - name: Check out the shared gates + uses: actions/checkout@v4 + with: + repository: kuhyx/utils + path: .utils + - name: Point the delegates at that checkout + run: echo "UTILS_ROOT=$GITHUB_WORKSPACE/.utils" >> "$GITHUB_ENV"\ +""" + + +def needs_patch(repo: Path) -> bool: + """True when a pre-commit workflow exists and cannot find the shared gate.""" + target = repo / PRECOMMIT_WORKFLOW + if not target.is_file(): + return False + return MARKER not in target.read_text(encoding="utf-8") + + +def patch(repo: Path) -> bool: + """Insert the checkout after the workflow's own `actions/checkout`. + + Inserting after the repo's own checkout rather than at the top of `steps:` + keeps `$GITHUB_WORKSPACE` populated before `.utils` lands inside it, and + leaves every surrounding comment intact -- a YAML round-trip would drop + the ones explaining why each hook is there. + """ + if not needs_patch(repo): + return False + target = repo / PRECOMMIT_WORKFLOW + lines = target.read_text(encoding="utf-8").split("\n") + anchors = [n for n, line in enumerate(lines) if line.strip() == CHECKOUT] + if not anchors: + raise ValueError( + f"{target} has no `{CHECKOUT}` step to insert after; " + "add the shared-gate checkout by hand, then re-run" + ) + lines[anchors[0] + 1 : anchors[0] + 1] = PATCH.split("\n") + target.write_text("\n".join(lines), encoding="utf-8") + return True diff --git a/dep_freshness/tests/test_install_precommit_ci.py b/dep_freshness/tests/test_install_precommit_ci.py new file mode 100644 index 0000000..34224b5 --- /dev/null +++ b/dep_freshness/tests/test_install_precommit_ci.py @@ -0,0 +1,91 @@ +"""The fourth install piece: teaching an existing pre-commit workflow the gate. + +These assertions are about a failure that is invisible locally -- the delegate +resolves through `~/utils` on this machine and through nothing at all on a +runner -- so they check the runner-visible facts: that the shared repo is +checked out, that `UTILS_ROOT` points at it, and that the insert lands after +the workflow's own checkout rather than before it. +""" + +from __future__ import annotations + +import pytest + +from dep_freshness import install as installer +from dep_freshness import install_precommit_ci as ci +from dep_freshness.tests.conftest import write + +BARE_WORKFLOW = """\ +name: pre-commit + +on: [push] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: pre-commit/action@v3.0.1 +""" + + +def test_a_repo_with_no_precommit_workflow_is_left_alone(repo): + assert ci.needs_patch(repo) is False + assert ci.patch(repo) is False + assert not (repo / ci.PRECOMMIT_WORKFLOW).exists() + + +def test_a_bare_workflow_gains_the_checkout_and_the_env_var(repo): + write(repo, str(ci.PRECOMMIT_WORKFLOW), BARE_WORKFLOW) + + assert ci.needs_patch(repo) is True + assert ci.patch(repo) is True + + body = (repo / ci.PRECOMMIT_WORKFLOW).read_text(encoding="utf-8") + assert "repository: kuhyx/utils" in body + assert "path: .utils" in body + assert "UTILS_ROOT=$GITHUB_WORKSPACE/.utils" in body + + +def test_the_insert_lands_after_the_workflows_own_checkout(repo): + write(repo, str(ci.PRECOMMIT_WORKFLOW), BARE_WORKFLOW) + ci.patch(repo) + + lines = (repo / ci.PRECOMMIT_WORKFLOW).read_text(encoding="utf-8").split("\n") + own = lines.index(" - uses: actions/checkout@v4") + shared = lines.index(" - name: Check out the shared gates") + action = lines.index(" - uses: pre-commit/action@v3.0.1") + assert own < shared < action, "$GITHUB_WORKSPACE must exist before .utils lands" + + +def test_a_second_run_writes_nothing(repo): + write(repo, str(ci.PRECOMMIT_WORKFLOW), BARE_WORKFLOW) + ci.patch(repo) + once = (repo / ci.PRECOMMIT_WORKFLOW).read_text(encoding="utf-8") + + assert ci.needs_patch(repo) is False + assert ci.patch(repo) is False + assert (repo / ci.PRECOMMIT_WORKFLOW).read_text(encoding="utf-8") == once + + +def test_a_workflow_with_no_checkout_refuses_rather_than_guessing(repo): + write( + repo, + str(ci.PRECOMMIT_WORKFLOW), + "name: pre-commit\njobs:\n pre-commit:\n steps:\n" + " - uses: pre-commit/action@v3.0.1\n", + ) + with pytest.raises(ValueError, match="no `- uses: actions/checkout@v4` step"): + ci.patch(repo) + + +def test_the_installer_reports_the_patch_as_a_fourth_piece(repo): + write(repo, str(ci.PRECOMMIT_WORKFLOW), BARE_WORKFLOW) + + expected = f"{ci.PRECOMMIT_WORKFLOW} (shared-gate checkout)" + assert expected in installer.plan(repo) + assert expected in installer.install(repo) + assert installer.install(repo) == []