Two real defects, and three docs claims that were simply wrong. jq-preflight.sh parsed the version by stripping around the first `-` and `.`, which assumed the format is exactly `jq-X.Y`. A build printing `jq version 1.6` left major empty; the sanity check concatenated major+minor into "6", which is non-empty and all-digits, so it PASSED. The floor comparison then ran `[ "" -lt 1 ]`, which errors — and `set -e` exempts a failing command in an `if` condition, so the conditional read false and the script exited 0 having asserted nothing, after printing a plausible "parsed" line. The silently-untested-axis failure this script exists to eliminate, reproduced inside the script itself. Now parsed by explicit regex, failing closed with a diagnosis when there is no <digits>.<digits> match. Also: `--expect` with no value exited 1 with empty output on both streams. The hook's exit-status check was pinned by nothing: mutating `if files=$(...)` into `files=$(...) || true; files_complete=yes` left the ENTIRE suite green. It survived only by redundancy — the script writes stdout once, right before exit 0, so failures also happen to yield empty stdout and `[ -n "$files" ]` catches it. Safe by accident, which is the exact criticism this branch levels at the old code. Four tests now pin it, with a stub that FAILS while emitting a docs-only list (the one case redundancy cannot absorb) plus a positive control proving the harness can see the difference. Verified: the mutation now turns exactly those tests red. Docs corrections. The record claimed the --expect pin was safe because script-tests is "advisory, not a required check" — false. The merge-consent hook reads the COMBINED status (ci.advisory-red-blocks-the-merge-gate, #598), so firing the tripwire blocks every non-docs-only merge until someone re-pins. Kept anyway, for a stated reason, but no longer described as free. The record also asserted in the present tense that review-verdict.yml checks out the base ref; it has no checkout step at all, so that is now a future-tense requirement on the follow-up. And the documented .status allow-list named GitHub's `removed`, which the code rejects. The drift-guard regex anchored on `?limit=`, so a re-inlined copy written `files?page=1&limit=50` would have walked past it. Decisions-Edit: yes
124 lines
7.2 KiB
Bash
Executable File
124 lines
7.2 KiB
Bash
Executable File
#!/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)
|
|
# `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 <major.minor> 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
|
|
|
|
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`.
|
|
# 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 `<digits>.<digits>` match, and fail closed when there isn't one.
|
|
if [[ "$raw" =~ ([0-9]+)\.([0-9]+) ]]; then
|
|
major="${BASH_REMATCH[1]}"
|
|
minor="${BASH_REMATCH[2]}"
|
|
else
|
|
echo "jq-preflight: could not parse a major.minor version out of '${raw}'. 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.
|
|
echo "jq-preflight: jq version in use = ${raw} (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
|