fix(935): state the measured fact (both hosts 1.7 on 2026-10-28), not an unmeasured mixed fleet

The fleet is uniform on jq 1.7 per the job logs; 1.6 stays in the set only because the
image tag is unpinned. Real-jq 1.6 coverage is currently absent, and the shims cover only
the empty-input -e quirk, not the NUL or parse-error ones. Measurement dated by the job
date. Remaining "runner's jq 1.6" comments reworded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
This commit is contained in:
2026-10-29 22:26:09 +01:00
co-authored by Claude Opus 5.5
parent 41d604f0ae
commit 8acba80a1a
8 changed files with 40 additions and 36 deletions
+7 -6
View File
@@ -546,12 +546,13 @@ 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 over an accepted SET of versions: as of
# 2026-10-29 the `small` fleet is mixed, 1.6 or 1.7 depending on which host takes the job (both
# map to the floating runner-images:ubuntu-latest image, no digest pin), so the set is exactly
# {1.6, 1.7} (ersatztv#935). Any other version goes red. Which of the two the suite runs under is
# decided by the runner, so the 1.6 coverage is incidental. server-management#1080 proposes a fleet
# digest pin; when it settles one version, narrow this to that version.
# (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
+9 -8
View File
@@ -1344,10 +1344,10 @@ the runner image.
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 runners' jq is **not one version**: as of
2026-10-29 both `small` runners map to the floating `runner-images:ubuntu-latest` image with no digest
pin, so each host keeps whatever it last pulled (jq 1.7 reported on both hosts that took PR #933 and
#934; a host holding an older image still reports 1.6; server-management#1080). Author to the
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:
@@ -1368,10 +1368,11 @@ alone instead of guessing at the runner image.
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. Which member the suite runs under is decided by whichever
runner takes the job, so **real-jq 1.6 coverage of `scripts/tests/` is incidental**; the 1.6 quirks
are still exercised on every run by in-repo jq shims (`test_pr_changed_files.py`,
`test_merge_consent_exemption.py`). The floor stays at 1.6. **server-management#1080** (a fleet digest
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.
+1 -1
View File
@@ -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-accepted-version-set` | Every shell gate that shells out to `jq` is authored to the jq 1.6-compatible subset (floor 1.6, unchanged). As of 2026-10-29 the `small` runner fleet is MIXED: both small runners map to the floating `runner-images:ubuntu-latest` image with no digest pin, so each host keeps whatever it last pulled (jq 1.7 reported on both hosts that took PR #933 and #934; a host holding an older image still 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; which member the suite runs under is decided by the runner, so the real-jq 1.6 coverage is INCIDENTAL, not guaranteed. 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.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) |
@@ -5,15 +5,15 @@ 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). As of 2026-10-29 the `small` runner fleet is MIXED: both small runners map to the floating `runner-images:ubuntu-latest` image with no digest pin, so each host keeps whatever it last pulled (jq 1.7 reported on both hosts that took PR #933 and #934; a host holding an older image still 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; which member the suite runs under is decided by the runner, so the real-jq 1.6 coverage is INCIDENTAL, not guaranteed. 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, mixed runner fleet, 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'
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-29.** `script-tests`' tripwire (`--expect 1.6`) went red on jazz-small-runner
**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
@@ -40,11 +40,13 @@ authored to the 1.6 subset is harmless on 1.7, and the three divergences that mo
(`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.** The real-jq 1.6 run of `scripts/tests/` is now incidental: it happens only
when a 1.6 host takes the job, and nothing guarantees that on any given PR. The 1.6 quirks are still
exercised on every run by in-repo jq shims that reproduce them (`test_pr_changed_files.py`,
`test_merge_consent_exemption.py`), so the shim-based proofs hold on a 1.7 runner; what is not
covered on a 1.7 run is any NEW 1.6-only divergence in a construct no shim models.
**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)
@@ -62,7 +64,7 @@ floor rises, with the fleet evidence in hand.
#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-29 firing is that cost being paid.
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
+5 -5
View File
@@ -2,8 +2,8 @@
# 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 runners shipped jq 1.6 when this was written (2026-07-26); as of 2026-10-29 the
# `small` fleet is MIXED, 1.6 or 1.7 depending on which host takes the job (server-management#1080).
# 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:
@@ -27,9 +27,9 @@
#
# 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-29: 1.6 on some, 1.7 on others). Which jq `scripts/tests/` runs
# under is therefore decided by which runner takes the job: the 1.6 coverage is INCIDENTAL, not
# guaranteed. server-management#1080 proposes a fleet digest pin; when it settles one version, narrow
# 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:
+2 -2
View File
@@ -79,7 +79,7 @@
# 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 CI runners whose jq is 1.6 or 1.7 (mixed fleet, 2026-10-29;
# 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
@@ -181,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 a runner may still report 1.6 (mixed fleet, 2026-10-29) — 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
+3 -3
View File
@@ -1,7 +1,7 @@
"""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
runners shipped jq 1.6 (2026-07-26) and are now a mix of 1.6 and 1.7 (2026-10-29). Nothing pinned
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("<NUL>")` (false vs true for every
string), and the parse-error exit code (5 vs 4, colliding with "no output"). Each was patched with a
@@ -153,7 +153,7 @@ def test_expect_match_passes(preflight):
@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 mixed fleet (server-management#1080): whichever runner takes the job must be green."""
"""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
@@ -378,7 +378,7 @@ 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 mixed-fleet set until server-management#1080 settles one version (#935): not 1.6 alone
# 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"jq-preflight\.sh --expect {re.escape(ACCEPTED)}\s*$", pr_checks, re.M), (
f"script-tests must run --expect {ACCEPTED}"
+2 -2
View File
@@ -493,7 +493,7 @@ 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
@@ -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"