Files
ersatztv/docs/decisions/archive/ci/jq-version-contract.md
T
timothyandClaude Opus 5.5 41d604f0ae fix(935): script-tests jq tripwire accepts exactly {1.6, 1.7} until server-management#1080 pins the fleet
jq-preflight.sh --expect takes a comma-separated set (validated whole, exact
major.minor membership); pr-checks.yml passes 1.6,1.7. Both small runners float
on runner-images:ubuntu-latest, so the fleet's jq is a function of pull history
(1.7 measured on PR #933 job 12347 and PR #934 job 12360, 2026-10-29).

New record ci.jq-accepted-version-set supersedes ci.jq-version-contract
(archived); docs/ci-cd.md, the script header and runner-ships-1.6 comments now
state the dated invariant. review-verdict.yml stays floor-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
2026-10-29 21:38:38 +01:00

8.4 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.jq-version-contract 2026-07-26 — jq 1.6 is the FLOOR every shell gate must run on; `scripts/jq-preflight.sh` makes the version observable, and only `script-tests` pins it (#648) superseded 2026-07-26 none ci.jq-accepted-version-set@2026-10-29 (superseded) Every shell gate that shells out to `jq` is authored to the jq 1.6-compatible subset, because the CI runner ships jq 1.6 while every developer Mac ships 1.8.x. `scripts/jq-preflight.sh` (no args) prints the parsed version and asserts a floor of 1.6 in every gate job's log; `scripts/jq-preflight.sh --expect 1.6` additionally pins and fails loudly, but ONLY in the `script-tests` job. `review-verdict.yml` never pins — it writes the branch-protection-required `review-verdict/h10` status, so a hard pin there would turn any jq bump into a repo-wide merge deadlock. jq version divergence, jq 1.6 vs 1.8, jq -e exit code on empty input, contains NUL false positive, jq parse-error exit code collision, jq-preflight, script-tests --expect, review-verdict jq floor, merge deadlock from a pinned dependency · paths: `scripts/jq-preflight.sh`, `.gitea/workflows/review-verdict.yml`, `.gitea/workflows/pr-checks.yml`, `docs/ci-cd.md` · issues: #643, #647, #648, #649 `scripts/jq-preflight.sh` (no args) -> floor+observability in every gate job; `scripts/jq-preflight.sh --expect 1.6` -> tripwire, `script-tests` job only; `docs/ci-cd.md` -> "The jq contract"

Three independent jq-version divergences hit inside a single day (#643, #647), all in gates written and tested on a developer Mac (jq 1.8.x) but running on the CI runner (jq 1.6):

  • jq -e over EMPTY input exits 4 on jq >= 1.7, but 0 on jq 1.6 — the docs-only pagination guard inferred "transport failure" from that exit status, so on 1.6 a failed page silently passed and the loop walked past unread pages while still reporting files_complete=yes.
  • contains("\u0000") — the NUL escape truncates to "" on jq 1.6, so the containment test is vacuously true for every string, not just ones actually containing a NUL. The H10 review-verdict classifier that relied on this was entirely inert on the runner.
  • Parse-error exit code: jq empty exits 5 on jq >= 1.7 but 4 on jq 1.6 — the same code jq 1.6 uses for "no output produced". A garbage API response and an empty-but-valid one were indistinguishable, and the garbage case was read as "no comments."

None of these are exotic jq usage — they are constructs anyone would reach for first, and each one was discovered only because a real gate broke, not because anyone thought to test jq 1.6. That is the argument for a contract, not three point fixes: the failures share one shape (a shell gate's behavior is a function of an interpreter version nobody was treating as a variable), so patching each construct as it's found does not converge — it just narrows the next surprise.

Why 1.6 is the floor and not 1.8. The runner is the binding constraint, not the author's machine. Baking a pinned jq into docker/ci/Dockerfile was the obvious first idea and was rejected because it provably cannot cover the gate that actually broke: review-verdict.yml is runs-on: small with no toolchain-image pin, and per ci.small-lane-git-only the small lane is git-only — it gets the host's jq 1.6 no matter what the toolchain image contains. That was checked against the running binary, not assumed. So the fix has to hold at 1.6, in every gate, regardless of which lane it runs in.

Why the pin is asymmetric. scripts/jq-preflight.sh has two modes on purpose:

  • No arguments — print the parsed version and fail only below the 1.6 floor. This is pure observability: the jq version CI actually used is now in the job log, so a future divergence can be diagnosed from the log alone instead of by guessing at the runner image. review-verdict.yml runs this mode. It cannot run the pinned mode, because that job's output is the required review-verdict/h10 status check on main — a hard version pin there means the day the runner's jq is upgraded (a base-image bump, a host reimage, anything outside this repo's control), every PR on main stops merging until someone notices and re-pins. A required merge gate cannot have a failure mode that is "an upstream package manager did its job."

  • --expect 1.6 — pin and fail loudly. Used only by script-tests. scripts/tests/ currently exercises the 1.6 code path only because the runner happens to ship 1.6; if that silently changed, the 1.6 coverage this whole contract depends on would evaporate with no signal. The tripwire forces a human decision — re-pin after re-reading this record, or add a real 1.6 matrix leg — instead of letting the coverage quietly disappear.

    Be honest about the cost: firing this tripwire DOES block merges. An earlier draft of this record claimed the pin was safe because script-tests is "advisory, not one of the required checks". That reasoning is wrong, and the correction is worth recording because it is easy to make twice. .claude/hooks/pretooluse-merge-consent.sh reads the combined commit status and denies on anything that is not success/skipped — see ci.advisory-red-blocks-the-merge-gate (#598). script-tests is a Gitea Actions job, so its red is a context folded into that combined state. A jq bump therefore reddens script-tests and blocks non-docs-only merges until someone re-pins.

    One qualification, so this does not over-correct in the other direction: that combined-status read is guarded by if [ "$mwcs" != "true" ]. On the merge_when_checks_succeed path the hook does not read the combined status at all and defers to Gitea, which gates on required checks only — and script-tests is not one. So the blast radius is the hook-mediated merge path, not literally every merge.

    The pin is kept anyway, deliberately: the fix is a one-line edit to the --expect value in pr-checks.yml, the failure message spells that out, and the alternative — silently losing the only coverage of the version axis that produced three bugs in one day — is worse than a visible stop. What is NOT acceptable is believing it is free. The difference from review-verdict.yml is therefore one of degree and recoverability, not of "blocks merges vs doesn't": there the check is required per-sha and a jq bump would deadlock merges with no in-repo remedy at all, whereas here a human can unblock the repo in one commit.

The parse is strictly fail-closed, and that has an operational edge once it gates merges. scripts/jq-preflight.sh accepts only a FIRST line of the form jq-<X>.<Y> or jq version <X>.<Y>; anything else — a leading blank line, a wrapper that prints a warning first, a version reported only on stderr — exits 1 rather than guess. That is the right default for a guard whose whole purpose is refusing to certify a version it did not parse, and it was arrived at over four revisions in which every permissive variant turned out to be fail-OPEN.

But when the follow-up wires the floor-only mode into review-verdict.yml, that strictness sits in the branch-protection-required check. A jq wrapper that starts printing a banner line would then deadlock merges repo-wide — the very failure the pin/floor asymmetry exists to avoid, arriving through the parser instead of the pin. If that ever happens the fix is to widen the accepted forms in jq-preflight.sh, not to relax the fail-closed behaviour: an unparsed version must never be treated as satisfying the floor.

The three constructs to avoid, and their version-stable replacements:

  • Never infer "empty input" from a jq exit status — check the string in shell before invoking jq.
  • Never use contains("\u0000") (or any raw NUL literal) for a control-character test — use explode | index(0), which does not depend on jq's NUL-escape handling.
  • Never infer "parse error" from jq's exit code on ambiguous input — jq empty is the portable parse-only test, but its exit code collides between "no output" (1.6) and "parse error" (1.6, same code as 1.8's "no output"). Validate the response shape explicitly rather than reading one exit code as a specific failure mode.

Full narrative of how these were found (inside the #631 script-tests rollout) is in docs/decisions/records/ci/script-tests-job.md; this record is the durable contract that came out of it, rather than the incident log.