diff --git a/.gitea/workflows/pr-checks.yml b/.gitea/workflows/pr-checks.yml index 2e6e6fa76..a385eb772 100644 --- a/.gitea/workflows/pr-checks.yml +++ b/.gitea/workflows/pr-checks.yml @@ -546,16 +546,19 @@ jobs: - name: Install test dependencies run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml # jq gets its OWN step because its VERSION, not merely its presence, is load-bearing - # (ersatztv#648). `--expect` makes this a TRIPWIRE: scripts/tests exercises the jq 1.6 code path - # only because this runner ships 1.6, so an upgrade would silently delete that coverage — and - # the three divergences found in ersatztv#643/#647 all lived exactly there. Going red forces an - # explicit human decision instead of letting the coverage evaporate. + # (ersatztv#648). `--expect` makes this a TRIPWIRE over an accepted SET of versions: the + # set is exactly {1.6, 1.7} (ersatztv#935). Both `small` runners that took PR #933/#934 reported + # 1.7 (2026-10-28), but both map to the floating runner-images:ubuntu-latest image with no digest + # pin, so a host may hold an older pull and 1.6 stays in the set. Any other version goes red. No + # known runner reports 1.6 today, so real-jq 1.6 coverage of scripts/tests is currently absent. + # server-management#1080 proposes a fleet digest pin; when it settles one version, narrow this + # to that version. # # The pin lives HERE and deliberately NOT in review-verdict.yml: that workflow writes the # branch-protection-required `review-verdict/h10` status, so pinning a version there would turn # any jq bump on the runner into a repo-wide merge deadlock. It gets the floor-only mode. # See docs/ci-cd.md -> "The jq contract". - name: Preflight jq version - run: ./scripts/jq-preflight.sh --expect 1.6 + run: ./scripts/jq-preflight.sh --expect 1.6,1.7 - name: Run scripts/tests run: PYTHONPATH=. python3 -m pytest scripts/tests -q diff --git a/.gitea/workflows/review-verdict.yml b/.gitea/workflows/review-verdict.yml index d59073fe3..9fdbe9a1d 100644 --- a/.gitea/workflows/review-verdict.yml +++ b/.gitea/workflows/review-verdict.yml @@ -572,7 +572,7 @@ jobs: # verdict and leaves a FORGED one — and this job's own red status is not a required check. # # The empty case is checked EXPLICITLY, not left to jq's exit status: `jq -e` over empty input - # exits 4 on jq >= 1.7 but 0 on jq 1.6, and THE RUNNER SHIPS 1.6 (ersatztv#647) — so on a + # exits 4 on jq >= 1.7 but 0 on jq 1.6, and a runner may report 1.6 (the floor; ersatztv#647) — so on a # transient error this guard passed, the row came back "", and the job posted over a # possibly-existing human verdict. # @@ -1232,7 +1232,7 @@ jobs: # COUNTED, NOT `jq -e`, and that is this function's own standing rule rather than a # style preference. The `kind` read above says it in full: `jq -e` reports the # truthiness of its LAST OUTPUT, which is the exit-status subtlety that already bit - # this workflow at jq 1.6 (ersatztv#647), and the runner still ships 1.6. A count read + # this workflow at jq 1.6 (ersatztv#647), and a runner may still report 1.6. A count read # as a VALUE and then range-checked in shell cannot be read two ways, and it matches # every other tally here. (Using `jq -e` here would be safe by accident — the # array is known non-empty by the check above, so the empty-input diff --git a/docs/ci-cd.md b/docs/ci-cd.md index baa62091c..d089d939d 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1304,7 +1304,7 @@ minutes, and it would still be wrong to filter on `scripts/**`. stay unpinned on purpose: a pytest release does not add assertions to your suite, a ruff release adds rules to your lint. - Lint runs **before the jq preflight**, and after the `git` one. `Preflight jq version` is a hard - `--expect` tripwire; a lint sitting behind it goes dark for as long as the jq contract is broken, + `--expect` tripwire; a lint sitting behind it goes dark for as long as the jq version is outside the accepted set, under a red that says "jq". `Preflight external tools` stays ahead, because the lint steps consume `git` — without it a missing git reaches them as an empty population and they blame the glob. - Neither step is `ruff check .`. Both pass an **explicit population** from @@ -1331,7 +1331,7 @@ which is itself a small demonstration of why the suite needed to run in CI at al against a temp repo it creates itself, never this repository's history. Two **preflight steps** run before the suite. The first asserts `git` is on PATH; the second runs -`scripts/jq-preflight.sh --expect 1.6`, which checks jq's **version**, not merely its presence (see +`scripts/jq-preflight.sh --expect 1.6,1.7`, which checks jq's **version**, not merely its presence (see "The jq contract" below). Those two tests exec the real shell scripts, which shell out to `jq` ~26 times; the tests shim `curl` on PATH but not `jq`, so a runner image without it would surface as ~20 opaque assertion failures instead of one diagnosis. Both deliberately **check** rather than install — @@ -1340,13 +1340,16 @@ the runner image. ### The jq contract (ersatztv#648) -> Full rationale: `docs/decisions/records/ci/jq-version-contract.md`. +> Full rationale: `docs/decisions/records/ci/jq-accepted-version-set.md` (`ci.jq-accepted-version-set`). Every shell gate in this repo — `decisions-guard`, `script-tests`'s own harness, `pretooluse-merge-consent.sh`, `review-verdict.yml`, `scripts/pr-changed-files.sh` — is authored and -tested on a developer Mac shipping **jq 1.8.x**. The CI runner ships **jq 1.6**. Author to the -1.6-compatible subset; three concrete constructs diverge between the two and each one produced a real -bug when it hit CI for the first time: +tested on a developer Mac shipping **jq 1.8.x**. The CI runners' jq is **not pinned**: both `small` +runners map to the floating `runner-images:ubuntu-latest` image with no digest pin, so each host keeps +whatever it last pulled. Measured 2026-10-28: jq 1.7 on both hosts (PR #933 job 12347, PR #934 job +12360); no known runner reports 1.6, but an unpinned host could (server-management#1080). Author to the +1.6-compatible subset, which is the **floor**; three concrete constructs diverge between 1.6 and +later versions and each one produced a real bug when it hit CI for the first time: - **`jq -e` over EMPTY input.** Exits 4 on jq >= 1.7, but **0** on jq 1.6. A guard that infers "transport failure" from that exit status silently passes an empty/failed page on 1.6. @@ -1361,16 +1364,25 @@ bug when it hit CI for the first time: the parsed version and asserts a floor of 1.6) so a future divergence can be diagnosed from the log alone instead of guessing at the runner image. -**Pin vs floor is deliberately asymmetric.** `scripts/jq-preflight.sh --expect 1.6` additionally pins -the version and fails loudly if it drifts, but that mode is used **only** by `script-tests` -(`.gitea/workflows/pr-checks.yml`) — advisory, not a required check. `review-verdict.yml` runs the -no-args floor-only mode and never pins, because that workflow writes `review-verdict/h10`, the -branch-protection-**required** status check on `main`: a hard pin there would mean the day the -runner's jq version changes (a base-image bump, a host reimage — nothing this repo controls), every -PR on `main` stops merging until someone notices and re-pins. A required merge gate cannot fail -because an upstream package manager did its job. The narrower pin on `script-tests` exists precisely -because that job is the suite's only 1.6 coverage — if the runner's jq silently changed, that coverage -would evaporate with no signal, so failing loudly there forces a human decision instead. +**Accepted set, not a single pin (2026-10-29, ersatztv#935).** `scripts/jq-preflight.sh --expect +1.6,1.7` additionally asserts the version is a member of the comma-separated set and fails loudly +(exit 1) on any other version; a malformed set (`1.6,`, `1.x`) is a usage error (exit 2). It is used +**only** by `script-tests` (`.gitea/workflows/pr-checks.yml`). The set is exactly {1.6, 1.7}: a +1.8 runner stays red so a person decides. As of 2026-10-28 no known runner provides +jq 1.6, so **real-jq 1.6 coverage of `scripts/tests/` is currently absent**; 1.6 stays in the set only +because the tag is unpinned. In-repo jq shims (`test_pr_changed_files.py`, +`test_merge_consent_exemption.py`) reproduce the empty-input `-e` quirk only; the NUL-truncation and +parse-error-exit-code quirks have no coverage on a 1.7 runner. The floor stays at 1.6. **server-management#1080** (a fleet digest +pin) is what narrows the set: when it settles one version, change `--expect` to that single version. +The tripwire is advisory in name only: `pretooluse-merge-consent.sh` reads the combined status, so a +red `script-tests` blocks the hook-mediated merge path until the `--expect` value is fixed. + +**Pin vs floor is deliberately asymmetric.** `review-verdict.yml` runs the no-args floor-only mode and +never `--expect`, because that workflow writes `review-verdict/h10`, the branch-protection-**required** +status check on `main`: a version assertion there would mean the day a runner's jq version changes (a +base-image pull, a host reimage — nothing this repo controls), every PR on `main` stops merging until +someone notices and re-pins. A required merge gate cannot fail because an upstream package manager did +its job. Baking a pinned jq into `docker/ci/Dockerfile` was considered and rejected: `review-verdict.yml` is `runs-on: small` with no toolchain-image pin, and per `ci.small-lane-git-only` the small lane is diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5354d40dd..b4ebbf05e 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -57,7 +57,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera | `ci.grep-q-pipefail-inversion` | In any script running under `set -o pipefail`, a security or classification predicate of the form `producer \| grep -q…` is FORBIDDEN: `grep -q` exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so `pipefail` reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (`grep -q… <<< "$data"`) is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an `if`/`!` that failure flips the predicate the same way. COUNT instead — `n=$(printf '%s\n' "$data" \| grep -cE "$re")` — because `grep -c` drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an `if`/`elif` condition: inside `$( )` an `exit` leaves only the subshell and `set -e` does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate `.gitea/workflows/review-verdict.yml` and the advisory hook `.claude/hooks/pretooluse-merge-consent.sh`. | 2026-07-29 | [link](records/ci/grep-q-pipefail-inversion.md) | | `ci.image-build-delegates-the-spa-suite` | `docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`'s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step's `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step's own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`'s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts` PINNED WHOLE — it decides what the suite collects and what `vite build` loads, and pinning a BLOCK of it was defeated seven measured ways across three rounds, so the partial match was withdrawn rather than respelled an eighth time; and the ABSENCE of any config file that outranks it — `vitest.config.*` for vitest, and `vite.config.js`/`.mjs` for `vite build`, whose `DEFAULT_CONFIG_FILES` lists them BEFORE `.ts`. Pinning one file is worthless while a second can outrank it, and that family went short twice. The gating job's `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. The count of such routes lives in the `docs/guard-inventory.md` row and nowhere else — it briefly existed in three places with two different values, which is how a number stays wrong. It is a running total, wrong at every count so far. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. | 2026-08-30 | [link](records/ci/image-build-delegates-the-spa-suite.md) | | `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](records/ci/infra-shaped-red-under-load.md) | -| `ci.jq-version-contract` | 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. | 2026-07-26 | [link](records/ci/jq-version-contract.md) | +| `ci.jq-accepted-version-set` | Every shell gate that shells out to `jq` is authored to the jq 1.6-compatible subset (floor 1.6, unchanged). Both `small` runners map to the floating `runner-images:ubuntu-latest` image with no digest pin, so the fleet jq version is not guaranteed; measured 2026-10-28, both hosts report jq 1.7 and no known runner reports 1.6. `scripts/jq-preflight.sh --expect 1.6,1.7` in the `script-tests` job accepts exactly that comma-separated SET and fails loudly on any other version; 1.6 stays in the set only because the tag is unpinned, and real-jq 1.6 coverage of `scripts/tests/` is currently absent. server-management#1080 (fleet digest pin) is the event that narrows the set to one version. `review-verdict.yml` stays floor-only (no `--expect`): it writes the branch-protection-required `review-verdict/h10` status. | 2026-10-29 | [link](records/ci/jq-accepted-version-set.md) | | `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](records/ci/killed-job-triage.md) | | `ci.monitor-armed-at-pr-open` | Arm a CI monitor on the PR head sha the moment the PR opens, polling the commit-status endpoint — not at the end of the work. | 2026-07-21 | [link](records/ci/monitor-armed-at-pr-open.md) | | `ci.no-host-health-gating` | Push when your work is validated — never SSH to bumblebee to sample load/RAM first, and never hand-schedule around other sessions' runs. | 2026-07-21 | [link](records/ci/no-host-health-gating.md) | diff --git a/docs/decisions/records/ci/jq-version-contract.md b/docs/decisions/archive/ci/jq-version-contract.md similarity index 92% rename from docs/decisions/records/ci/jq-version-contract.md rename to docs/decisions/archive/ci/jq-version-contract.md index 707ea925a..c83ba179e 100644 --- a/docs/decisions/records/ci/jq-version-contract.md +++ b/docs/decisions/archive/ci/jq-version-contract.md @@ -1,11 +1,11 @@ --- 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 +status: superseded 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.' +superseded-by: ci.jq-accepted-version-set@2026-10-29 +rule: '(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.' 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"' --- diff --git a/docs/decisions/records/ci/jq-accepted-version-set.md b/docs/decisions/records/ci/jq-accepted-version-set.md new file mode 100644 index 000000000..c40ec3f5a --- /dev/null +++ b/docs/decisions/records/ci/jq-accepted-version-set.md @@ -0,0 +1,80 @@ +--- +key: ci.jq-accepted-version-set +title: '2026-10-29 — jq 1.6 stays the FLOOR; `script-tests` accepts exactly the SET {1.6, 1.7} until server-management#1080 pins the runner fleet (#935)' +status: active +since: '2026-10-29' +supersedes: ci.jq-version-contract@2026-07-26 +superseded-by: none +rule: 'Every shell gate that shells out to `jq` is authored to the jq 1.6-compatible subset (floor 1.6, unchanged). Both `small` runners map to the floating `runner-images:ubuntu-latest` image with no digest pin, so the fleet jq version is not guaranteed; measured 2026-10-28, both hosts report jq 1.7 and no known runner reports 1.6. `scripts/jq-preflight.sh --expect 1.6,1.7` in the `script-tests` job accepts exactly that comma-separated SET and fails loudly on any other version; 1.6 stays in the set only because the tag is unpinned, and real-jq 1.6 coverage of `scripts/tests/` is currently absent. server-management#1080 (fleet digest pin) is the event that narrows the set to one version. `review-verdict.yml` stays floor-only (no `--expect`): it writes the branch-protection-required `review-verdict/h10` status.' +signals: 'jq version divergence, jq 1.6 vs 1.7, script-tests jq tripwire red, jq-1.7 (parsed 1.7; floor 1.6), --expect set, unpinned runner image, floating runner-images ubuntu-latest, server-management#1080, jq-preflight, merge blocked by combined status · paths: `scripts/jq-preflight.sh`, `scripts/tests/test_jq_preflight.py`, `.gitea/workflows/pr-checks.yml`, `.gitea/workflows/review-verdict.yml`, `docs/ci-cd.md` · issues: #935, #648, #643, #647' +mechanics: '`scripts/jq-preflight.sh` (no args) -> floor+observability in every gate job; `scripts/jq-preflight.sh --expect 1.6,1.7` -> set tripwire, `script-tests` job only (`test_jq_preflight.py` pins the exact value); `docs/ci-cd.md` -> "The jq contract"' +--- + +Supersedes `ci.jq-version-contract` (2026-07-26, archived), whose premise was that the CI runner +ships jq 1.6. That is no longer a fact the repo can state. + +**Measured 2026-10-28.** `script-tests`' tripwire (`--expect 1.6`) went red on jazz-small-runner +(PR #933, job 12347) and on bumblebee's small-runner (PR #934, job 12360), each reporting +`jq-1.7 (parsed 1.7; floor 1.6)`. Cause (server-management#1080): both `small` runners map to the +floating `runner-images:ubuntu-latest` image with no digest pin or forced pull, so each host keeps +whatever it last pulled and the fleet's jq version is a function of pull history. A digest pin across +the fleet is proposed in server-management#1080 and not yet decided. + +**The invariant.** `script-tests` accepts exactly the set `{1.6, 1.7}`: + +- `scripts/jq-preflight.sh --expect 1.6,1.7` parses the argument as a comma-separated set of + `.` members; an empty or free-form member (`1.6,`, `1.6,,1.7`, `1.x`) is a usage + error (exit 2), never a member that matches nothing or everything. Membership is exact on + `major.minor`, not a prefix or range test (`1.10` and `1.71` do not match `1.7` / `1.6`). +- A version outside the set (`1.8`, which every developer Mac ships, `2.x`) exits 1 with the tripwire + message. That red is witnessed by `scripts/tests/test_jq_preflight.py` + (`test_a_version_outside_the_accepted_set_is_LOUD`), and the exact `--expect 1.6,1.7` value in + `pr-checks.yml` is pinned by `test_script_tests_pins_the_jq_version`. +- The set is exactly {1.6, 1.7}, not "1.6 or newer": widening it to every version would delete the + tripwire. Admitting 1.7 is evidence-driven (both measured hosts run it); admitting 1.8 is not, and + a 1.8 runner should go red so a person decides. + +**The floor stays at 1.6.** Raising it would assert that no host in the fleet can report 1.6, which +the unpinned image does not guarantee, and which only the #1080 decision can establish. A gate +authored to the 1.6 subset is harmless on 1.7, and the three divergences that motivated the contract +(`jq -e` over empty input, `contains("\u0000")`, the parse-error exit code) remain real on whichever +host still runs 1.6. + +**What coverage is lost.** As of 2026-10-28 no known runner provides jq 1.6, so the real-jq 1.6 run +of `scripts/tests/` is currently absent; it would happen only if a host held an older image. In-repo +jq shims reproduce ONE of the three 1.6 divergences: `jq -e` over empty input exiting 0 +(`test_pr_changed_files.py`, `test_merge_consent_exemption.py`). The `contains("\u0000")` truncation +and the parse-error exit code (4 vs 5) have no shim, so on a 1.7 run they are covered only by the +constructs having been replaced with version-stable ones, and any NEW 1.6-only divergence is +uncovered. + +**What narrows the set.** server-management#1080. When it pins the fleet to one jq version, change +the `--expect` value in `pr-checks.yml` to that single version (the script accepts a one-member set) +and record the pin here by superseding this record. If the pin lands on 1.7, decide then whether the +floor rises, with the fleet evidence in hand. + +**Unchanged from the superseded record.** + +- *Pin vs floor is asymmetric.* `review-verdict.yml` runs the no-args floor-only mode and never + `--expect`: it writes `review-verdict/h10`, the branch-protection-required status on `main`, so a + version assertion there would turn any runner jq change into a repo-wide merge deadlock. + `test_review_verdict_never_pins_a_jq_version` holds that. +- *Firing the tripwire blocks merges.* `.claude/hooks/pretooluse-merge-consent.sh` reads the combined + commit status and denies on anything not `success`/`skipped` (ersatztv#598), and `script-tests` is a Gitea Actions job folded into it. Only the + `merge_when_checks_succeed` path, which defers to Gitea's required checks, is spared. This is the + cost of the tripwire, accepted because the remedy is a one-line edit to `--expect`, and the + 2026-10-28 firing is that cost being paid. +- *Baking jq into `docker/ci/Dockerfile` cannot reach the gate.* `review-verdict.yml` is + `runs-on: small` with no toolchain-image pin and `ci.small-lane-git-only` keeps that lane + git-only, so it gets the host's jq whatever the toolchain image holds. The fleet pin in + server-management#1080 is the only lever that reaches it. +- *The parse is strictly fail-closed.* `jq-preflight.sh` accepts only a first line of the form + `jq-.` or `jq version .`; anything else exits 1. If a wrapper banner ever breaks that + in the required `review-verdict.yml` job, widen the accepted forms, never relax fail-closed: an + unparsed version must not satisfy the floor. +- *The three constructs to avoid:* never infer "empty input" from a jq exit status (check the string + in shell first); never use `contains("\u0000")` (use `explode | index(0)`); never read `jq`'s + exit code as a specific parse-error signal on ambiguous input (validate the response shape). + +Full rationale for how the three divergences were found is in +`docs/decisions/archive/ci/jq-version-contract.md` and `docs/decisions/records/ci/script-tests-job.md`. diff --git a/docs/decisions/records/ci/python-lint-ruff-config-committed.md b/docs/decisions/records/ci/python-lint-ruff-config-committed.md index 998b8200b..654a508b8 100644 --- a/docs/decisions/records/ci/python-lint-ruff-config-committed.md +++ b/docs/decisions/records/ci/python-lint-ruff-config-committed.md @@ -91,7 +91,7 @@ a judgement that type checking does not belong. **The pin is the second half of the fix.** An unpinned `pip install ruff` re-introduces exactly the divergence the config closes, one layer up: the verdict becomes a function of *when* the job ran. Same -argument as the `jq` pin in the same job (`ci.jq-version-contract`), and the same consequence — a bump +argument as the `jq` pin in the same job (`ci.jq-accepted-version-set`), and the same consequence — a bump is a PR someone reads. `pytest` and `pyyaml` in the same job stay unpinned, and the asymmetry is the point rather than an oversight: a pytest release does not add assertions to your suite, a ruff release adds rules to your lint. diff --git a/docs/decisions/records/ci/script-tests-job.md b/docs/decisions/records/ci/script-tests-job.md index ba8360c6c..1ee8da672 100644 --- a/docs/decisions/records/ci/script-tests-job.md +++ b/docs/decisions/records/ci/script-tests-job.md @@ -56,7 +56,7 @@ both reds were real: PyYAML). It passed locally only because the author's machine had PyYAML. 2. A **fail-open in the merge-consent gate itself.** `jq -e` over EMPTY input exits 4 on jq ≥ 1.7 but **0 on jq 1.6** (verified against both binaries), and the docs-only pagination guard leaned - on that exit status to reject a transport failure. On jq 1.6 — which the CI runner ships — the + on that exit status to reject a transport failure. On jq 1.6 — which the CI runner shipped on 2026-07-26 — the failed page passed the guard, the loop walked past it, the next page legitimately returned `[]`, and `files_complete=yes` was set over a PARTIAL list: the docs-only exemption firing over unread pages that may be pure code. Fixed by rejecting an empty body explicitly instead of inferring it @@ -68,7 +68,7 @@ on a developer Mac (jq 1.8) — only in CI, where the suite had never run. Two f the same shape (a `contains("\u0000")` false positive and a colliding parse-error exit code) turned up the same day; the durable contract that came out of all three — the exact constructs to avoid, and why `jq-preflight.sh` pins in `script-tests` but only floors the version in `review-verdict.yml` — is -recorded once, in `ci.jq-version-contract` (`docs/decisions/records/ci/jq-version-contract.md`), and +recorded once, in `ci.jq-accepted-version-set` (`docs/decisions/records/ci/jq-accepted-version-set.md`), and is not restated here. An independent cross-family review of that fix then found **two further fail-opens in the same diff --git a/docs/decisions/records/ci/verdict-write-retarget-fence.md b/docs/decisions/records/ci/verdict-write-retarget-fence.md index 60bf68c3d..8d485e56d 100644 --- a/docs/decisions/records/ci/verdict-write-retarget-fence.md +++ b/docs/decisions/records/ci/verdict-write-retarget-fence.md @@ -131,7 +131,7 @@ Two reasons it read as deliberate rather than broken, both worth carrying forwar other. The type is now read as a value (`case` over `jq -r 'type'`) rather than through `jq -e`, whose -exit-status semantics already bit this workflow at jq 1.6 (`ci.jq-version-contract`), and both `null` +exit-status semantics already bit this workflow at jq 1.6 (`ci.jq-accepted-version-set`), and both `null` and `[]` are read as the same EMPTY shape (which since #870 ends the walk only at page 1 or at the page cap, not in between). `test_the_fence_TRUSTS_the_count_and_POSTS_when_the_timeline_terminates` is parameterised over both shapes and asserts the POSTED STATUS rather than the log line — on the real diff --git a/docs/defect-shapes-773.md b/docs/defect-shapes-773.md index c398bc38a..10321b612 100644 --- a/docs/defect-shapes-773.md +++ b/docs/defect-shapes-773.md @@ -320,7 +320,7 @@ different fixes, and conflating them is why several of these recurred. | **B** | **Every guard ships with a proof it can go red: delete or disarm *that guard alone* and the suite must fail.** Not "a test exists" — a mutation. | Family B: 9 records (13%) | **rule-present-unenforced** — stated in #685's record and in project memory, enforced nowhere | | **C** | **Dedup by construction.** When two copies of one rule exist, delete one and have both callers invoke it — *unless* deriving the list would feed the judged input back into the judge, which is #711's reasoned exception. | Family C: 9 records (13%) | **rule-missing** | | **D** | **Anything read-then-written against live Gitea/remote state pins a version or uses compare-and-set.** See the honesty note below — this is a fix pattern, and its *detector* is detector A applied to an enumerated inventory of such sites. | Family D: 5 records (7%) | **rule-missing** (partially addressed for the merge path by the per-sha required check from #622) | -| **E** | **Run the check under the interpreter/provider it will actually run under**, and preflight-log the version. | Environment divergence: 7 records | **rule-present** and working — `scripts/jq-preflight.sh` + `ci.jq-version-contract` closed the jq axis after #643/#647/#648. **Unclosed axes**: SQLite-vs-MySQL query semantics (#668), GPU generation (#505), CI-VM speed (#512) | +| **E** | **Run the check under the interpreter/provider it will actually run under**, and preflight-log the version. | Environment divergence: 7 records | **rule-present** and working — `scripts/jq-preflight.sh` + `ci.jq-accepted-version-set` closed the jq axis after #643/#647/#648. **Unclosed axes**: SQLite-vs-MySQL query semantics (#668), GPU generation (#505), CI-VM speed (#512) | | **F** | **Test the DENY path with the production config value.** #756's lesson generalised: a fixture that omits a field tests only the default, so a fail-open in the production value stays invisible. Parametrise the whole matrix, including "config absent". | Fail-open-by-default: ~5 records | **rule-missing** | | **G** | **Full-replace endpoints assert their complete field list in a test**, and prefer reconcile-by-id over delete-and-reinsert where child state exists. | Destructive replace: ~3 records | **rule-missing** | diff --git a/scripts/check-required-contexts.sh b/scripts/check-required-contexts.sh index 0163108e8..264c20519 100755 --- a/scripts/check-required-contexts.sh +++ b/scripts/check-required-contexts.sh @@ -100,8 +100,8 @@ classifier="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/branch-rule-classi # comparison was made", which is the truth for a non-array body. # # "To produce a finding" rather than "before the classifier sees it", and the distinction is the -# `ci.jq-version-contract` gap (ersatztv#643): on jq 1.6 — the version `scripts/jq-preflight.sh` -# pins for `script-tests` — `jq -e` over EMPTY input exits 0, so an empty 200 body is not diverted +# `ci.jq-accepted-version-set` gap (ersatztv#643): on jq 1.6 — the floor `scripts/jq-preflight.sh` +# admits for `script-tests` — `jq -e` over EMPTY input exits 0, so an empty 200 body is not diverted # here and does reach the classifier. Measured on both 1.6 and 1.8.2: the class word is `unreadable` # either way, because the classifier then produces nothing and the catch-all answers. The outcome is # version-independent; only the mechanism differs, so the comment states the outcome. diff --git a/scripts/check-review-verdict.sh b/scripts/check-review-verdict.sh index ebb17bdad..ef56195b1 100755 --- a/scripts/check-review-verdict.sh +++ b/scripts/check-review-verdict.sh @@ -139,8 +139,8 @@ comments=$(cat) # NOTE the NUL test is `explode | index(0)`, NOT `contains("\u0000")` (ersatztv#647). On jq 1.6 the # escape truncates the literal to the EMPTY string, and every string contains "" — so that form # returns true for ALL input, making this guard reject every comment body as malformed. Verified -# against both binaries: 1.6 says true for "hello", 1.7+ says false. The CI runner ships jq 1.6, so -# the whole verdict classifier was inert there. `explode | index(0)` agrees on both. +# against both binaries: 1.6 says true for "hello", 1.7+ says false. A CI runner reporting jq 1.6 (the floor) +# had the whole verdict classifier inert. `explode | index(0)` agrees on both. # # `Review-verdict: MERGEABLE @ ` would arrive at the matcher as a valid verdict line — # text that is not a verdict silently becoming one. diff --git a/scripts/jq-preflight.sh b/scripts/jq-preflight.sh index ea5e24428..50eb6695a 100755 --- a/scripts/jq-preflight.sh +++ b/scripts/jq-preflight.sh @@ -2,9 +2,10 @@ # 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: +# jq 1.8.x. The CI runners shipped jq 1.6 when this was written (2026-07-26); on 2026-10-28 both +# `small` runners reported jq 1.7 (server-management#1080). 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) @@ -20,11 +21,15 @@ # 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. +# the small lane is git-only. It therefore gets the HOST's jq, whatever 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. +# So the contract is the other way round: 1.6 is the FLOOR every gate must work on. Both `small` +# runners map to the floating runner-images:ubuntu-latest image with no digest pin, so each host keeps +# whatever it last pulled (2026-10-28: both measured hosts reported 1.7; a host may hold an older +# pull). No known runner reports 1.6, so real-jq 1.6 coverage of `scripts/tests/` is currently absent; +# 1.6 stays accepted only because the tag is unpinned. server-management#1080 proposes a fleet digest +# pin; when it settles one version, narrow the `--expect` set in pr-checks.yml to that version. # # TWO MODES, deliberately asymmetric: # @@ -34,20 +39,21 @@ # `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. +# --expect X.Y[,X.Y] Additionally assert the version is exactly one member of the comma-separated +# set, and FAIL if not. Used by the `script-tests` job with `1.6,1.7` (since +# 2026-10-29, until server-management#1080 pins the fleet). This is the +# tripwire: a version outside the set means the runner image moved on and the +# coverage the suite assumed has changed, so going red forces a human to decide +# rather than letting it change unnoticed. # -# Usage: jq-preflight.sh [--expect ] +# 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. +# The lowest jq every shell gate in this repo must run correctly on. Do not raise this until every +# runner that can take a job is confirmed above it — the runner fleet, not the dev Mac, is the binding +# constraint, and while the runner image tag is unpinned (server-management#1080) a host may still hold +# a 1.6 pull. MIN_VERSION="1.6" expect="" @@ -156,18 +162,35 @@ if [ "$major" -lt "$min_major" ] || { [ "$major" -eq "$min_major" ] && [ "$minor fi if [ -n "$expect" ]; then - if [ "${major}.${minor}" != "$expect" ]; then - echo "jq-preflight: expected jq ${expect}, found ${major}.${minor}." >&2 + # --expect is a comma-separated SET. Validate the WHOLE string first: every member must be a plain + # ., so an empty member (`1.6,`, `,1.6`, `1.6,,1.7`) or a free-form one is a usage + # error, never a member that silently matches nothing (or, via a later split, something it should not). + member_re='[0-9]{1,9}\.[0-9]{1,9}' + if [[ ! "$expect" =~ ^${member_re}(,${member_re})*$ ]]; then + echo "jq-preflight: --expect must be [,...]; got '${expect}'" >&2 + exit 2 + fi + matched="" + IFS=',' read -r -a expect_set <<<"$expect" + for member in "${expect_set[@]}"; do + if [ "$member" = "${major}.${minor}" ]; then + matched="yes" + fi + done + if [ -z "$matched" ]; then + echo "jq-preflight: expected jq in {${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 "This is a TRIPWIRE, not a defect in your change (ersatztv#648, #935). scripts/tests/ is run" >&2 + echo "under whichever jq the runner that took this job ships, and the accepted set is {${expect}}." >&2 + echo "This runner reports ${major}.${minor}, so the jq the suite assumed has changed — jq 1.7 altered" >&2 + echo "NUL handling, exit codes, @base64d and number precision, every one of which a gate here" >&2 + echo "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 " * widen or re-pin the set after re-reading docs/ci-cd.md -> 'The jq contract'" >&2 + echo " (server-management#1080 is the fleet digest pin that narrows it to one version), 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})." + echo "jq-preflight: version ${major}.${minor} is a member of the expected set (${expect})." fi diff --git a/scripts/pr-changed-files.sh b/scripts/pr-changed-files.sh index f984ff152..8f1e65e57 100755 --- a/scripts/pr-changed-files.sh +++ b/scripts/pr-changed-files.sh @@ -79,7 +79,8 @@ # ETV_GITEA_URL | GITEA_BASE_URL -> API base; defaults to the homelab Gitea. A value ending in # /api/v1 is used as-is, otherwise /api/v1 is appended. # -# jq COMPATIBILITY (ersatztv#648). This runs on the CI runner, which ships **jq 1.6**, while it is +# jq COMPATIBILITY (ersatztv#648). This runs on CI runners whose jq is unpinned (1.7 measured 2026-10-28; +# floor 1.6), while it is # authored on Macs shipping 1.8.x. It is therefore written to the 1.6-compatible subset: # * never rely on `jq -e`'s exit status over EMPTY input — 1.6 exits 0 where >=1.7 exits 4, which is # precisely the fail-open that ersatztv#647 found live in the enforced gate. Emptiness is always @@ -180,7 +181,7 @@ while [ "$page" -le "$MAX_PAGES" ]; do raw=$(gq "repos/$owner/$repo/pulls/$pr/files?limit=${PAGE_SIZE}&page=${page}") # An EMPTY body is rejected in SHELL, before jq sees it. `jq -e` over empty input exits 4 on - # jq >= 1.7 but 0 on jq 1.6, and the runner ships 1.6 — leaving this to jq's exit status is the + # jq >= 1.7 but 0 on jq 1.6, and a runner may still report 1.6 (unpinned image) — leaving this to jq's exit status is the # exact fail-open ersatztv#647 found in the enforced copy. A transport failure must never # masquerade as a legitimate short final page. if [ -z "${raw//[[:space:]]/}" ]; then diff --git a/scripts/tests/test_jq_preflight.py b/scripts/tests/test_jq_preflight.py index 74807c765..b6e0f4c98 100644 --- a/scripts/tests/test_jq_preflight.py +++ b/scripts/tests/test_jq_preflight.py @@ -1,7 +1,8 @@ """Tests for `scripts/jq-preflight.sh` — the jq version contract (ersatztv#648). The axis this guards. Every shell gate in this repo is authored on a Mac shipping jq 1.8.x; the CI -runner ships jq 1.6. Nothing pinned or checked that, and three independent divergences surfaced in a +runners shipped jq 1.6 (2026-07-26) and both measured runners report 1.7 (2026-10-28). Nothing pinned +or checked that, and three independent divergences surfaced in a single day — `jq -e` over empty input (exit 4 vs 0), `contains("")` (false vs true for every string), and the parse-error exit code (5 vs 4, colliding with "no output"). Each was patched with a version-stable construct, but patching constructs one at a time leaves the AXIS untested. @@ -15,6 +16,7 @@ reproducible: it re-runs on every PR instead of living in one CI run's history. from __future__ import annotations import os +import re import shutil import subprocess from pathlib import Path @@ -132,13 +134,16 @@ def test_build_suffixes_still_parse_as_1_6(preflight, version_line): assert preflight.run("--expect", "1.6").returncode == 0, version_line +ACCEPTED = "1.6,1.7" # the set pr-checks.yml passes until server-management#1080 pins the fleet + + def test_expect_mismatch_is_LOUD(preflight): - """THE TRIPWIRE. scripts/tests exercises the jq 1.6 path only because the runner ships 1.6. If - the runner were upgraded that coverage would vanish silently, so the pin must go red instead.""" + """THE TRIPWIRE. A version outside the accepted set must go red, so a runner image moving on + changes the suite's jq visibly instead of silently.""" preflight.with_jq("jq-1.7.1") r = preflight.run("--expect", "1.6") assert r.returncode == 1 - assert "expected jq 1.6, found 1.7" in r.stderr + assert "expected jq in {1.6}, found 1.7" in r.stderr def test_expect_match_passes(preflight): @@ -146,6 +151,34 @@ def test_expect_match_passes(preflight): assert preflight.run("--expect", "1.6").returncode == 0 +@pytest.mark.parametrize("version_line", ["jq-1.6", "jq-1.7", "jq-1.7.1", "jq-1.6-dirty"]) +def test_the_accepted_set_passes_on_either_member(preflight, version_line): + """The unpinned fleet (server-management#1080): whichever version the runner holds must be green.""" + preflight.with_jq(version_line) + r = preflight.run("--expect", ACCEPTED) + assert r.returncode == 0, r.stderr + assert "member of the expected set" in r.stdout + + +@pytest.mark.parametrize("version_line", ["jq-1.8.1", "jq-1.10", "jq-2.6", "jq-1.71"]) +def test_a_version_outside_the_accepted_set_is_LOUD(preflight, version_line): + """The witnessed red. 1.8 is what the dev Macs ship and 1.10/1.71 are string-prefix lookalikes of + 1.7/1.6 members: membership is exact on major.minor, not a prefix or numeric-range test.""" + preflight.with_jq(version_line) + r = preflight.run("--expect", ACCEPTED) + assert r.returncode == 1, version_line + assert "expected jq in {1.6,1.7}" in r.stderr + + +@pytest.mark.parametrize("bad", ["1.6,", ",1.6", "1.6,,1.7", "1.6 1.7", "1.x", "1", "1.6;1.7", "1.6,1.7,"]) +def test_a_malformed_expect_set_is_a_usage_error(preflight, bad): + """An empty or free-form member must not become a member that matches nothing (or everything).""" + preflight.with_jq("jq-1.6") + r = preflight.run("--expect", bad) + assert r.returncode == 2, bad + assert "--expect must be" in r.stderr + + def test_unknown_argument_is_a_usage_error(preflight): preflight.with_jq("jq-1.6") assert preflight.run("--pin", "1.6").returncode == 2 @@ -345,6 +378,11 @@ def test_script_tests_pins_the_jq_version(): """ pr_checks = (WORKFLOWS / "pr-checks.yml").read_text() assert "jq-preflight.sh --expect" in pr_checks, "script-tests must pin the jq version — that pin is the tripwire" + # Exactly the accepted set until server-management#1080 settles one version (#935): not 1.6 alone + # (red on a 1.7 runner), not a wider set (a 1.8 runner must stay red). + assert re.search(rf"^\s*run: \./scripts/jq-preflight\.sh --expect {re.escape(ACCEPTED)}\s*$", pr_checks, re.M), ( + f"script-tests must run --expect {ACCEPTED}" + ) def test_review_verdict_never_pins_a_jq_version(): diff --git a/scripts/tests/test_pr_changed_files.py b/scripts/tests/test_pr_changed_files.py index aed20de49..ea9a852d8 100644 --- a/scripts/tests/test_pr_changed_files.py +++ b/scripts/tests/test_pr_changed_files.py @@ -493,12 +493,12 @@ def test_a_SHORT_page_does_not_end_the_enumeration(enumerate_files): assert "ErsatzTV/Program.cs" in enumerate_files.paths() -# --- The same guard, under the runner's jq 1.6 ------------------------------------------------ +# --- The same guard, under jq 1.6 ------------------------------------------------ # # `test_transport_failure_mid_pagination_fails_closed` above does NOT isolate the explicit # `if [ -z "${raw//[[:space:]]/}" ]` clause — mutation confirms it: deleting that clause leaves # the whole suite green on a developer Mac, because jq 1.8 rejects empty -# input on its own. jq 1.6 does not, and the runner ships 1.6 — so the one environment where the +# input on its own. jq 1.6 does not, and a runner may report 1.6 — so the one environment where the # clause is load-bearing was the one environment with no coverage. That is the #643/#647 failure # class exactly, reproduced in the test suite meant to prevent it. # @@ -1840,7 +1840,7 @@ def _run_classify( curl.write_text(WORKFLOW_STUB_CURL) curl.chmod(0o755) if jq16: - # Reproduce the runner's jq 1.6 (`-e` over EMPTY input exits 0, not 4). The shim is the one + # Reproduce jq 1.6 (`-e` over EMPTY input exits 0, not 4). The shim is the one # already used for pr-changed-files.sh in this file, so its fidelity is covered by that # suite's own verify-the-verifier test. jq = bindir / "jq" @@ -2184,7 +2184,7 @@ def test_a_transport_failure_under_jq_1_6_STILL_refuses_to_classify(tmp_path): """The ersatztv#647 fail-open, tested where it actually lives: jq 1.6. The shell emptiness check exists because `jq -e` over EMPTY input exits 4 on jq >= 1.7 but **0 on - jq 1.6, which is what the runner ships**. Remove that check and, on a dev Mac's 1.8, the guard + jq 1.6, which a runner may report (the floor)**. Remove that check and, on a dev Mac's 1.8, the guard still fires and every test stays green — the bug is invisible locally. Pinning the construct STRUCTURALLY instead, on the grounds that "no behavioural test can catch @@ -3345,8 +3345,8 @@ def test_count_pr_mutations_uses_NO_jq_e(tmp_path): The function's own comment states the rule and the reason: `jq -e` reports the truthiness of its LAST OUTPUT, and over empty input jq 1.6 exits 0 where >= 1.7 exits 4 — the divergence that - already took this workflow's enforced gate down once (ersatztv#647), on a runner that still ships - 1.6. Every tally here is therefore read with `jq -r` and range-checked in shell. + already took this workflow's enforced gate down once (ersatztv#647), on a runner that may report + 1.6 (the floor). Every tally here is therefore read with `jq -r` and range-checked in shell. This exists because the rule was BROKEN one commit after it was quoted (ersatztv#803 added a row-validation guard using `jq -e`, three lines below the comment forbidding it) and nothing @@ -3391,7 +3391,7 @@ def test_count_pr_mutations_uses_NO_jq_e(tmp_path): offenders = [ln.strip() for ln in joined if _uses_jq_exit_status(ln)] assert not offenders, ( "`jq -e` appeared inside `count_pr_mutations`, whose own comment bans it because the runner " - "ships jq 1.6 and `-e`'s exit status over empty input diverges there (ersatztv#647/#803). " + "may report jq 1.6 and `-e`'s exit status over empty input diverges there (ersatztv#647/#803). " "Read the value with `jq -r` and range-check it in shell instead:\n " + "\n ".join(offenders) )