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
This commit is contained in:
2026-07-26 22:21:07 +02:00
parent e4c0db7702
commit 2fd798cccf
7 changed files with 784 additions and 152 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Make the jq version a job's shell gates run under OBSERVABLE, and any drift LOUD.
#
# ersatztv#648. Every shell gate in this repo is authored and tested on a developer Mac shipping
# jq 1.8.x. The CI runner ships jq 1.6. Nothing pinned or checked that, and until ersatztv#631 the one
# thing that could have noticed (scripts/tests/) never ran on the runner. Three independent divergences
# surfaced in a single day:
#
# ersatztv#643 `jq -e` over EMPTY input -> exit 4 on 1.8, exit 0 on 1.6 (a transport failure
# passed the docs-only pagination guard)
# ersatztv#647 contains("<NUL>") -> false on 1.8, TRUE for every string on 1.6
# (the H10 verdict classifier was entirely inert)
# ersatztv#647 parse-error exit code -> 5 on 1.8, 4 on 1.6 — same as "no output"
# (garbage API response read as "no comments")
#
# All three are fixed with version-stable constructs, but patching constructs one at a time does not
# scale: the failures share one shape — a shell gate's behaviour is a function of its interpreter's
# version, and that version was an UNTESTED AXIS. This script makes the axis explicit.
#
# WHY A FLOOR AND NOT A PIN EVERYWHERE. The obvious fix — bake a pinned jq into the CI toolchain image
# (docker/ci/Dockerfile) — provably does NOT cover the gate that actually broke. `.gitea/workflows/
# review-verdict.yml` is `runs-on: small`, carries no toolchain-image pin, and per `ci.small-lane-git-only`
# the small lane is git-only. It therefore gets the HOST's jq 1.6 no matter what the image contains.
# That was checked, not assumed (ersatztv#648's first Done-when box).
#
# So the contract is the other way round: 1.6 is the FLOOR every gate must work on, and it is the
# runner's own jq that provides the 1.6 coverage `scripts/tests/` runs under.
#
# TWO MODES, deliberately asymmetric:
#
# (no --expect) Print the version and assert it is >= MIN_VERSION. Used by jobs on the merge
# path, including review-verdict.yml. There is NO upper bound here on purpose:
# review-verdict.yml writes `review-verdict/h10`, a REQUIRED status check on
# `main`, so a hard pin there would turn any jq upgrade on the runner into a
# repo-wide merge deadlock. Observability without a deadlock risk.
#
# --expect X.Y Additionally assert the version is exactly X.Y, and FAIL if not. Used by the
# `script-tests` job. This is the tripwire: `scripts/tests/` currently exercises
# the 1.6 path only because the runner happens to ship 1.6. If the runner were
# upgraded, that coverage would vanish SILENTLY and the whole class of bug above
# would go untested again. Going red forces a human to decide — re-pin, or add a
# real 1.6 matrix leg — rather than letting the coverage evaporate unnoticed.
#
# Usage: jq-preflight.sh [--expect <major.minor>]
set -euo pipefail
# The lowest jq every shell gate in this repo must run correctly on. Do not raise this without
# confirming the CI runner has actually been upgraded first — the runner, not the dev Mac, is the
# binding constraint.
MIN_VERSION="1.6"
expect=""
while [ "$#" -gt 0 ]; do
case "$1" in
--expect) expect="${2:-}"; shift 2 ;;
*) echo "jq-preflight: unknown argument '$1'" >&2; exit 2 ;;
esac
done
if ! command -v jq >/dev/null 2>&1; then
echo "jq-preflight: jq is not on PATH. The shell gates in scripts/ and .gitea/workflows/ shell out to jq; without it they fail as a pile of opaque assertion errors instead of one clear message." >&2
exit 1
fi
raw=$(jq --version 2>&1 || true)
# `jq --version` prints e.g. `jq-1.6`, `jq-1.7.1`, or on some builds `jq-1.8.2-dirty`.
version=${raw#jq-}
major=${version%%.*}
rest=${version#*.}
minor=${rest%%.*}
# Some builds append a suffix with no further dot ("jq-1.6-dirty", "jq-1.7rc1"), which would otherwise
# leave a non-numeric minor and fail closed on a perfectly ordinary jq. Keep the leading digits only.
major=${major%%[!0-9]*}
minor=${minor%%[!0-9]*}
# THIS LINE IS THE POINT of the no-arg mode: the jq version CI actually used is in the job log, so a
# future divergence can be diagnosed from the log alone rather than by guessing at the runner image.
echo "jq-preflight: jq version in use = ${raw} (parsed ${major}.${minor}; floor ${MIN_VERSION})"
case "$major$minor" in
*[!0-9]*|"") echo "jq-preflight: could not parse a major.minor out of '${raw}' — failing closed" >&2; exit 1 ;;
esac
min_major=${MIN_VERSION%%.*}
min_minor=${MIN_VERSION#*.}
if [ "$major" -lt "$min_major" ] || { [ "$major" -eq "$min_major" ] && [ "$minor" -lt "$min_minor" ]; }; then
echo "jq-preflight: jq ${major}.${minor} is BELOW the supported floor ${MIN_VERSION}. The gates in scripts/ and .gitea/workflows/ are written against ${MIN_VERSION}+ semantics and will misbehave silently on older builds." >&2
exit 1
fi
if [ -n "$expect" ]; then
if [ "${major}.${minor}" != "$expect" ]; then
echo "jq-preflight: expected jq ${expect}, found ${major}.${minor}." >&2
echo "" >&2
echo "This is a TRIPWIRE, not a defect in your change (ersatztv#648). scripts/tests/ was pinned to" >&2
echo "jq ${expect} because that is what this runner shipped; it now reports ${major}.${minor}. The ${expect}" >&2
echo "coverage the suite assumed has therefore just disappeared, silently — and jq 1.7 altered NUL" >&2
echo "handling, exit codes, @base64d and number precision, every one of which a gate here depends on." >&2
echo "" >&2
echo "Decide explicitly, then update the --expect value in .gitea/workflows/pr-checks.yml:" >&2
echo " * re-pin to the new version after re-reading docs/ci-cd.md -> 'The jq contract', or" >&2
echo " * add a real matrix leg that runs the suite under ${MIN_VERSION} as well." >&2
exit 1
fi
echo "jq-preflight: version matches the expected pin (${expect})."
fi