Installer teaches an existing pre-commit workflow where the shared gate lives
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-28 15:51:00 +02:00
parent da7fa911b2
commit d6b92cfd18
3 changed files with 176 additions and 8 deletions

View File

@ -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

View File

@ -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

View File

@ -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) == []