--- key: ci.jq-version-contract title: '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)' status: active since: '2026-07-26' supersedes: none superseded-by: none rule: '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.' signals: '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' mechanics: '`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-.` or `jq version .`; 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.