From f35203362caf6dfc8f8b0368233711ca69d9a72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 11:29:49 +0200 Subject: [PATCH 1/2] test(warm): probe warm/offline GitHub Action contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add targeted tests verifying the composite action's warm→offline contract and the uvx cache key: - action.yml: assert cache key hashes both tools.py and tools.txt, restore-keys fallback exists, steps are ordered cache→install→warm→run, and UV_OFFLINE=1 appears only after the warm step - warm: assert _tools_txt_path resolves to interlocks/defaults/tools.txt, and per-tool fallback carries the pinned version from DEFAULTS for every tool --- tests/tasks/test_warm.py | 34 ++++++++++++++++++++ tests/test_github_action.py | 62 +++++++++++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/tests/tasks/test_warm.py b/tests/tasks/test_warm.py index 60e85d6..73b44c8 100644 --- a/tests/tasks/test_warm.py +++ b/tests/tasks/test_warm.py @@ -164,3 +164,37 @@ def test_warm_treats_empty_tools_txt_as_missing( warm_mod.cmd_warm() assert "tools.txt missing" in capsys.readouterr().out + + +def test_tools_txt_path_resolves_inside_package() -> None: + """_tools_txt_path must point to interlocks/defaults/tools.txt inside the package.""" + p = warm_mod._tools_txt_path() + assert p.name == "tools.txt" + assert p.parent.name == "defaults" + assert p.parent.parent.name == "interlocks" + + +def test_warm_per_tool_uses_pinned_versions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every uvx invocation in per-tool fallback mode must carry the version pin from DEFAULTS.""" + monkeypatch.chdir(_project_with_pyproject(tmp_path)) + monkeypatch.setattr(warm_mod.shutil, "which", lambda _name: "/usr/bin/uv") + monkeypatch.setattr(warm_mod, "_tools_txt_path", lambda: tmp_path / "missing.txt") + + captured: list[list[str]] = [] + + def fake_run(cmd: list[str], **_: object) -> _StubProc: + captured.append(cmd) + return _StubProc(returncode=0) + + monkeypatch.setattr(warm_mod.subprocess, "run", fake_run) + warm_mod.cmd_warm() + + joined = [" ".join(cmd) for cmd in captured] + for name, version in DEFAULTS.items(): + spec = f"{name}=={version}" + assert any(spec in cmd for cmd in joined), ( + f"Expected {spec!r} in one of the uvx calls but got: {captured}" + ) diff --git a/tests/test_github_action.py b/tests/test_github_action.py index 1ec5141..afe5dee 100644 --- a/tests/test_github_action.py +++ b/tests/test_github_action.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import subprocess from pathlib import Path @@ -9,6 +10,8 @@ from interlocks import github_action +_ACTION = (Path(__file__).resolve().parent.parent / "action.yml").read_text(encoding="utf-8") + def test_command_from_args_defaults_to_interlock_ci() -> None: assert github_action._command_from_args(()) == ["interlocks", "ci"] @@ -75,18 +78,51 @@ def fake_run(command: list[str], *, check: bool) -> subprocess.CompletedProcess[ def test_action_metadata_delegates_to_interlock_ci() -> None: - action = (Path(__file__).resolve().parent.parent / "action.yml").read_text(encoding="utf-8") - - assert "using: composite" in action + assert "using: composite" in _ACTION # interlocks 0.2 ships through `uv tool install` rather than pip — the # action sets up uv, restores the uvx cache, warms it, then runs offline. - assert "astral-sh/setup-uv@" in action - assert "default: uv tool install interlocks" in action - assert "default: interlocks ci" in action - assert "actions/cache@v4" in action - assert "interlocks warm" in action - assert 'UV_OFFLINE: "1"' in action - assert 'python -m interlocks.github_action --command "${{ inputs.command }}"' in action - assert "ruff" not in action - assert "coverage run" not in action - assert "pip install interlocks" not in action + assert "astral-sh/setup-uv@" in _ACTION + assert "default: uv tool install interlocks" in _ACTION + assert "default: interlocks ci" in _ACTION + assert "actions/cache@v4" in _ACTION + assert "interlocks warm" in _ACTION + assert 'UV_OFFLINE: "1"' in _ACTION + assert 'python -m interlocks.github_action --command "${{ inputs.command }}"' in _ACTION + assert "ruff" not in _ACTION + assert "coverage run" not in _ACTION + assert "pip install interlocks" not in _ACTION + + +def test_action_cache_key_covers_pin_material() -> None: + """Cache key must hash both tools.py (pin table) and tools.txt (compiled hashes).""" + hash_match = re.search(r"hashFiles\([^)]+\)", _ACTION) + assert hash_match is not None, "no hashFiles() expression in action.yml cache key" + hash_expr = hash_match.group() + assert "tools.py" in hash_expr, "tools.py not in hashFiles expression" + assert "tools.txt" in hash_expr, "tools.txt not in hashFiles expression" + + +def test_action_restore_keys_provides_fallback() -> None: + """restore-keys must allow a partial cache hit when the exact pin set changes.""" + assert "restore-keys:" in _ACTION + + +def test_action_steps_ordered_cache_install_warm_run() -> None: + """Steps must appear in the order: cache-restore → install → warm → offline run.""" + markers = [ + "actions/cache@", # restore uvx cache + "${{ inputs.install-command }}", # install interlocks + "interlocks warm", # populate cache online + "python -m interlocks.github_action", # run with UV_OFFLINE=1 + ] + positions = [_ACTION.index(m) for m in markers] + assert positions == sorted(positions), ( + "action.yml steps are not in the expected order: cache-restore → install → warm → run" + ) + + +def test_action_uv_offline_only_after_warm_step() -> None: + """UV_OFFLINE=1 must come after the warm step — warm runs online to fetch wheels.""" + warm_pos = _ACTION.index("interlocks warm") + offline_pos = _ACTION.index('UV_OFFLINE: "1"') + assert offline_pos > warm_pos, "UV_OFFLINE=1 must appear after 'interlocks warm', not before" From 1b4df7a92af521ba12f60c9d9aae40b45f5358c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Gait=C3=A1n-Villamizar?= Date: Mon, 11 May 2026 12:42:51 +0200 Subject: [PATCH 2/2] test(github_action): strengthen cache key and restore-keys assertions Address gemini-code-assist review feedback on PR #51: - test_action_cache_key_covers_pin_material: capture hashFiles() args group and assert full relative paths ('interlocks/defaults/tools.py' and 'interlocks/defaults/tools.txt') instead of bare filenames, so the test guards against accidental path changes in action.yml. - test_action_restore_keys_provides_fallback: replace the trivial "restore-keys:" substring check with a regex that extracts both the primary cache key and the restore prefix, then asserts the restore key is a true prefix of the primary key, validating the fallback wiring. --- tests/test_github_action.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_github_action.py b/tests/test_github_action.py index afe5dee..e7e6203 100644 --- a/tests/test_github_action.py +++ b/tests/test_github_action.py @@ -95,16 +95,19 @@ def test_action_metadata_delegates_to_interlock_ci() -> None: def test_action_cache_key_covers_pin_material() -> None: """Cache key must hash both tools.py (pin table) and tools.txt (compiled hashes).""" - hash_match = re.search(r"hashFiles\([^)]+\)", _ACTION) + hash_match = re.search(r"hashFiles\(([^)]+)\)", _ACTION) assert hash_match is not None, "no hashFiles() expression in action.yml cache key" - hash_expr = hash_match.group() - assert "tools.py" in hash_expr, "tools.py not in hashFiles expression" - assert "tools.txt" in hash_expr, "tools.txt not in hashFiles expression" + hash_args = hash_match.group(1) + assert "'interlocks/defaults/tools.py'" in hash_args + assert "'interlocks/defaults/tools.txt'" in hash_args def test_action_restore_keys_provides_fallback() -> None: """restore-keys must allow a partial cache hit when the exact pin set changes.""" - assert "restore-keys:" in _ACTION + key_match = re.search(r"key: (uvx-tools-[^\n]+)", _ACTION) + restore_match = re.search(r"restore-keys: \|\s+([^\n]+)", _ACTION) + assert key_match and restore_match, "Could not find cache key or restore-keys in action.yml" + assert key_match.group(1).startswith(restore_match.group(1).strip()) def test_action_steps_ordered_cache_install_warm_run() -> None: