Files
ersatztv/scripts/tests/test_jq_preflight.py
T
timothy 2fd798cccf fix(648,649): one shared PR-file enumeration + an explicit jq version contract
#649 — the enforced review-verdict.yml guard had drifted strictly WEAKER than the
advisory merge-consent hook: four rounds of #643 hardening landed on the copy whose
failures produce a human prompt, and never reached the copy that writes the
branch-protection-required review-verdict/h10 status. Its fail-closed behaviour on a
garbage response was also incidental (an empty `n` erroring a bash conditional to
false), not designed.

Extract scripts/pr-changed-files.sh as the single implementation both call. Shared
MECHANISM, not policy: the two docs-only allow-lists differ deliberately and stay
separate. review-verdict.yml now checks out the BASE ref, never the PR head, so a PR
cannot rewrite the gate that judges it.

#648 — baking jq into docker/ci/Dockerfile provably cannot cover the gate that broke:
review-verdict.yml is runs-on:small with no toolchain pin, so it gets the host's jq 1.6
(checked, not assumed). Add scripts/jq-preflight.sh: floor+observable everywhere, and a
--expect tripwire on script-tests only — pinning the required merge check would deadlock
every merge on a jq bump.

Verified by mutation: six guards individually broken, each turning exactly its own test
red, then restored byte-identical.

fixes #648
fixes #649
2026-07-26 22:21:07 +02:00

145 lines
5.9 KiB
Python

"""Tests for `scripts/jq-preflight.sh` — the jq version contract (ersatztv#648).
The axis this guards. Every shell gate in this repo is authored on a Mac shipping jq 1.8.x; the CI
runner ships jq 1.6. Nothing pinned or checked that, and three independent divergences surfaced in a
single day — `jq -e` over empty input (exit 4 vs 0), `contains("<NUL>")` (false vs true for every
string), and the parse-error exit code (5 vs 4, colliding with "no output"). Each was patched with a
version-stable construct, but patching constructs one at a time leaves the AXIS untested.
These tests shim `jq` on PATH with a fake reporting an arbitrary version, so the preflight's own
behaviour is verified by MEASUREMENT rather than by observing a green CI tick — ersatztv#648's third
Done-when box. Doing it here rather than by pushing a deliberately-red commit also keeps the proof
reproducible: it re-runs on every PR instead of living in one CI run's history.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "jq-preflight.sh"
WORKFLOWS = REPO_ROOT / ".gitea" / "workflows"
# Resolved BEFORE PATH is narrowed to the shim dir — the tests strip PATH down to just that
# directory, so `bash` could not be found by name from inside them.
BASH = shutil.which("bash") or "/bin/bash"
@pytest.fixture
def preflight(tmp_path):
bindir = tmp_path / "bin"
bindir.mkdir()
class Handle:
def with_jq(self, version_line):
"""Install a fake `jq` reporting `version_line` for --version."""
shim = bindir / "jq"
shim.write_text("#!/bin/sh\n"
'if [ "$1" = "--version" ]; then echo "%s"; exit 0; fi\nexit 0\n'
% version_line)
shim.chmod(0o755)
def without_jq(self):
shim = bindir / "jq"
if shim.exists():
shim.unlink()
def run(self, *args):
env = dict(os.environ)
# PATH contains ONLY the shim dir. An earlier draft appended /usr/bin:/bin "for the
# basics" and the missing-jq test passed vacuously against the developer machine's real
# /usr/bin/jq — the negative case was never negative. The script needs nothing from PATH
# but jq itself (`command -v` is a builtin, and bash is invoked by absolute path), so
# there is nothing to keep.
env["PATH"] = str(bindir)
return subprocess.run([BASH, str(SCRIPT), *args],
env=env, capture_output=True, text=True)
return Handle()
def test_the_version_is_printed_so_the_job_log_shows_it(preflight):
"""ersatztv#648's second Done-when box: the jq version CI actually uses must be OBSERVABLE."""
preflight.with_jq("jq-1.6")
r = preflight.run()
assert r.returncode == 0, r.stderr
assert "jq-1.6" in r.stdout
def test_floor_mode_accepts_the_runner_version(preflight):
preflight.with_jq("jq-1.6")
assert preflight.run().returncode == 0
def test_floor_mode_accepts_a_newer_jq(preflight):
"""No upper bound in floor mode — review-verdict.yml writes the REQUIRED merge check, so a jq
bump must never be able to deadlock every merge in the repo."""
preflight.with_jq("jq-1.8.2")
assert preflight.run().returncode == 0
def test_below_the_floor_is_LOUD(preflight):
preflight.with_jq("jq-1.5")
r = preflight.run()
assert r.returncode == 1
assert "below the supported floor" in r.stderr.lower()
def test_missing_jq_is_loud(preflight):
preflight.without_jq()
r = preflight.run()
assert r.returncode == 1
assert "not on PATH" in r.stderr
@pytest.mark.parametrize("version_line", ["jq-1.6-dirty", "jq-1.6", "jq-1.6.0"])
def test_build_suffixes_still_parse_as_1_6(preflight, version_line):
"""A packaging suffix must not fail a perfectly ordinary jq closed — that would be a tripwire
firing on noise, which is how tripwires get disabled."""
preflight.with_jq(version_line)
assert preflight.run("--expect", "1.6").returncode == 0, version_line
def test_expect_mismatch_is_LOUD(preflight):
"""THE TRIPWIRE. scripts/tests exercises the jq 1.6 path only because the runner ships 1.6. If
the runner were upgraded that coverage would vanish silently, so the pin must go red instead."""
preflight.with_jq("jq-1.7.1")
r = preflight.run("--expect", "1.6")
assert r.returncode == 1
assert "expected jq 1.6, found 1.7" in r.stderr
def test_expect_match_passes(preflight):
preflight.with_jq("jq-1.6")
assert preflight.run("--expect", "1.6").returncode == 0
def test_unknown_argument_is_a_usage_error(preflight):
preflight.with_jq("jq-1.6")
assert preflight.run("--pin", "1.6").returncode == 2
# --- Wiring guards: the preflight is worthless if a caller silently stops running it ------------
def test_script_tests_pins_and_review_verdict_only_floors():
"""The asymmetry is deliberate and load-bearing, so it is asserted rather than merely commented.
`script-tests` carries `--expect` (a tripwire on a normal job). `review-verdict.yml` must NOT:
it writes the branch-protection-required `review-verdict/h10` status, so a hard version pin
there would turn a jq bump on the runner into a repo-wide merge deadlock.
"""
pr_checks = (WORKFLOWS / "pr-checks.yml").read_text()
review_verdict = (WORKFLOWS / "review-verdict.yml").read_text()
assert "jq-preflight.sh --expect" in pr_checks, \
"script-tests must pin the jq version — that pin is the tripwire"
assert "jq-preflight.sh" in review_verdict, \
"review-verdict.yml must at least print/floor-check its jq version"
assert "jq-preflight.sh --expect" not in review_verdict, \
("review-verdict.yml must NOT pin a jq version: it writes the required review-verdict/h10 "
"status, so a pin would deadlock every merge on a jq bump (ersatztv#648)")