#!/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("") -> 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 ] 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) # `shift 2` with a missing value fails under `set -e` and exits 1 with NOTHING on either # stream — a CI step dying with an empty log is exactly the diagnostic hole this script exists # to remove. Check explicitly instead. if [ "$#" -lt 2 ] || [ -z "${2:-}" ]; then echo "jq-preflight: --expect requires a value" >&2 exit 2 fi 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 # Take jq's EXIT STATUS seriously, and keep stderr OUT of the parse input. # # This was `raw=$(jq --version 2>&1 || true)`, which did neither — and that combination turned the # guard fail-OPEN on the case it most needs to catch. A jq that cannot start (the canonical one is a # glibc mismatch after a base-image change) exits 127 and writes something like # `jq: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found` to stderr. Folded into `raw`, # that string contains `2.34`, which the version pattern happily matched — so the preflight printed # "parsed 2.34", certified the floor, and exited 0 on a jq that cannot run at all. The strip-based # parse this replaced failed CLOSED there, so it was a regression introduced by the fix. # `$?` inside an `if ! cmd; then` block is the NEGATED status (0), not jq's, so capture it explicitly. set +e raw=$(jq --version 2>/dev/null) jq_rc=$? set -e if [ "$jq_rc" -ne 0 ]; then echo "jq-preflight: 'jq --version' failed (exit ${jq_rc}). jq is on PATH but cannot run — a broken build or a missing shared library. Failing closed rather than certifying a version it did not report." >&2 exit 1 fi # `jq --version` prints e.g. `jq-1.6`, `jq-1.7.1`, or on some builds `jq-1.8.2-dirty`. # Parse with an explicit regex rather than by stripping around the first `-` and `.`. # # The strip approach had a hole that defeated the whole point of this script. It assumed the format # is exactly `jq-X.Y`, so a build printing anything else — `jq version 1.6` (a distro wrapper), # `JQ-1.6`, `jq-1.-6` — left ONE of major/minor empty. The old sanity check was # `case "$major$minor" in *[!a-9]*|"")`, and on `jq version 1.6` that concatenation is "6": non-empty # and all-digits, so the guard PASSED. The floor comparison then ran `[ "" -lt 1 ]`, which exits 2 # with "integer expression expected" — and `set -e` exempts a failing command in an `if` condition, # so the whole conditional read false and the script exited 0 having asserted NOTHING, after printing # a plausible-looking "parsed" line. # # That is the silently-untested-axis failure this script was written to eliminate, reproduced inside # the script itself. Require a real `.` match, and fail closed when there isn't one. # ANCHORED to the leading `jq` token, not "first digits.digits anywhere in the string". # # An unanchored match takes whatever number comes first, wherever it is. That accepted a leading # warning line or a date prefix as the version — `2026.07.26 jq-1.6` parsed as 2026.07, which sails # over the floor. Anchoring keeps every legitimate form (`jq-1.6`, `jq version 1.6`, `jq-1.7.1`, # `jq-1.6-dirty`, `jq-1.6 (Debian 1.6-2.1)`) and rejects the rest, which then fails closed below. # FIRST LINE ONLY, and bounded everywhere. Both bounds are load-bearing; this is the third round on # this one predicate and each previous version failed for a variant of the same reason. # # * First line only. `[[:space:]]` matches NEWLINES, so an "anchored" pattern still scanned the # whole output: `jq\n2.34: cannot load` matched `jq`, crossed the newline as separator, and # parsed 2.34 — fail-open, the round-2 bug narrowed but not closed. `[[:blank:]]` (space/tab # only) plus a first-line slice confines the match to the line that can actually carry a version. # * Bounded digit runs. This is the round-1 mechanism resurrected. The regex guaranteed the # operands were digits but not that they fit in `test`'s integer range, so a 23-digit major made # `[ "$major" -lt "$min_major" ]` error with "integer expression expected" — and `set -e` exempts # a failing command in an `if` condition, so the conditional read false and THE FLOOR WAS NEVER # ASSERTED, exit 0. Exactly what the empty-string case did in round 1. `{1,9}` keeps every # operand inside a 32-bit integer, so the comparison can no longer error. # * Bounded separator runs, so the pattern cannot be walked across arbitrary filler. first=${raw%%$'\n'*} first=${first%$'\r'} # The separator is one of the two forms real jq actually emits — `jq-1.6` or `jq version 1.6` — not # "any run of dashes and blanks". A permissive class let the pattern be walked across filler: # `jq -- 2.34 (real jq-1.6)` parsed as 2.34, and `jq9.9` as 9.9. A blank separator now # REQUIRES the literal word `version`, which is the only context a real build puts one in. # # The trailing `([^0-9]|$)` is what actually bounds the digit runs. `{1,9}` alone does not: the regex # is unanchored at the end, so `jq-1.99999999999999999999999` simply matched the first 9 digits of # the minor and compared THAT — a mis-parse that passes the floor. Requiring a non-digit (or # end-of-string) after the minor makes an over-long run fail to match at all, so it fails closed. if [[ "$first" =~ ^[[:blank:]]*[Jj][Qq](-v?|[[:blank:]]+version[[:blank:]]+v?)([0-9]{1,9})\.([0-9]{1,9})([^0-9]|$) ]]; then major="${BASH_REMATCH[2]}" minor="${BASH_REMATCH[3]}" else echo "jq-preflight: could not parse a major.minor version out of '${first}'. Refusing to assert a floor against an unparsed version — that would silently pass." >&2 exit 1 fi # 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. # `$first`, not `$raw`: a multi-line `--version` would split this across lines, breaking the single # grep-able log line that is the entire point of the no-arg mode. echo "jq-preflight: jq version in use = ${first} (parsed ${major}.${minor}; floor ${MIN_VERSION})" 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