Files
ersatztv/scripts/check-required-contexts.sh
T
timothy e4a8b6ffb4
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 16s
PR Gates / Docs update reminder (pull_request) Successful in 11s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
review-verdict/h10 Review-verdict: MERGEABLE @ e4a8b6f (base: main)
PR Gates / decisions lifecycle (pull_request) Successful in 26s
Review verdict / Set review-verdict status (pull_request_target) Successful in 9s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 11m12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 10m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m23s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
fix(858,859): review round 4 — the corrected sentence had a twin, and its twin had two more
Round 4 returned NOT-MERGEABLE with Blocker and High both EMPTY: one Medium and two
Lows, all prose, no behavioural defect found across four attack directions.

MEDIUM — round 3 corrected "deleting it left all 57 tests green" in the hook and left the
IDENTICAL sentence, with the identical wrong number, in the test file — a line this branch
had added. Measured: 64, not 57. A comment fix applied to one of two sites, inside the PR
whose subject is arms that state what did not happen. Fix-one-path-then-check-its-twin,
one level up from the code.

Sweeping the CLASS rather than the instance then found two more, both pre-existing and
both made MORE wrong by this branch's new tests ("all 71 tests", "all 29 tests" — now 111
and 64). Fixed here because this PR falsified them; all three now state the invariant
("the WHOLE suite") with a parenthetical saying why the count was dropped. A fourth lives
in test_ci_dropped_step_guard.py, which this PR neither touches nor falsifies — left alone
and filed as ersatztv#895 with a candidate detector, since four instances of a
mechanically-detectable class is the criterion for a check rather than another sweep.

LOW — "THE PAYLOAD MUST BE AN ARRAY before the classifier sees it" is false on jq 1.6, the
version scripts/jq-preflight.sh pins for script-tests: `jq -e` over EMPTY input exits 0
there, so an empty 200 body is not diverted and does reach the classifier. Measured on 1.6
and 1.8.2 — the class word is `unreadable` either way, so the OUTCOME is version-independent
and the comment now states the outcome rather than the mechanism (ci.jq-version-contract,
ersatztv#643).

LOW — scripts/hook-fire-log.sh's own USAGE recipe still showed the ${CLAUDE_PROJECT_DIR:-...}
form that process.hook-resolves-inputs-from-repo-root bans. That header is what a new hook
copies, so leaving it would keep minting the defect after #891 sweeps the existing twelve.
Corrected, with the reason and the measurement beside it.

Suites: 193 passed, 2 skipped.
2026-08-30 12:38:20 +02:00

165 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Compare the LIVE required status checks on a branch against the committed snapshot
# (`.gitea/required-status-contexts.json`). ersatztv#787.
#
# WHY A SCRIPT AND NOT SIX LINES IN THE HOOK. `scripts/check-review-verdict.sh` was extracted from
# `pretooluse-merge-consent.sh` in #629 for exactly this reason: while the classification lived in
# the hook it had no tests, and three false-opens survived in it. This file is the same species —
# a pure classifier over a JSON payload its CALLER fetched — so it is testable in isolation and
# reads no remote state itself (`docs/remote-state-inventory.md` grades it `N/A` for that reason).
#
# WHAT IT IS FOR. `scripts/tests/test_ci_dropped_step_guard.py` derives its marked-job SCOPE from
# that snapshot, because `pr-checks.yml::script-tests` checks out with `persist-credentials: false`
# and cannot ask the server. That makes the snapshot the one hand-maintained input in the chain, so
# it needs a reconciliation wherever a credential DOES exist. This is that reconciliation.
#
# Input : the `GET /repos/{owner}/{repo}/branch_protections` array, on stdin.
# Output: exactly one class word on stdout.
# match — the live contexts for the branch equal the snapshot, as SETS
# drift — they were both read and they differ (the finding)
# nomatch — no rule governs the branch at all (protection removed)
# undecidable — a glob rule could govern the branch, so which rule applies is not derivable here
# unreadable — the payload, or a field this decision consumes, is not the shape it must be.
# Includes a rule whose NAME is unusable — either both `branch_name` and `rule_name`
# supply no name (absent, null or empty), or one of them is PRESENT holding a
# non-string, which poisons the rule however good its sibling is. The classifier
# answers `unreadable` for both, and a list with such a member supports no finding
# about which rule governs the branch (ersatztv#859).
# Exit : 0 having printed a class; 2 on a USAGE error (never a class, so a caller cannot mistake
# a broken invocation for a finding).
set -euo pipefail
branch=main
snapshot=""
snapshot_given=no
while [ $# -gt 0 ]; do
case "$1" in
# `[ $# -ge 2 ]` before the shift, or a trailing `--branch` makes `shift 2` fail and `set -e`
# exits 1 with no diagnostic — a status this script's own contract reserves for nothing, from a
# path its usage arm was written to cover.
--branch) [ $# -ge 2 ] || { echo "--branch needs a value" >&2; exit 2; }; branch=$2; shift 2 ;;
--snapshot) [ $# -ge 2 ] || { echo "--snapshot needs a value" >&2; exit 2; }; snapshot=$2; snapshot_given=yes; shift 2 ;;
*) echo "usage: $0 [--branch NAME] [--snapshot PATH] < branch_protections.json" >&2; exit 2 ;;
esac
done
[ -n "$branch" ] || { echo "--branch may not be empty" >&2; exit 2; }
# An EXPLICIT empty --snapshot is a usage error, not a request for the default: falling back would
# silently read a different file than the caller named.
if [ -z "$snapshot" ] && [ "$snapshot_given" = yes ]; then
echo "--snapshot may not be empty" >&2; exit 2
fi
if [ -z "$snapshot" ]; then
snapshot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.gitea/required-status-contexts.json"
fi
[ -r "$snapshot" ] || { echo "snapshot not readable: $snapshot" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "jq is required" >&2; exit 2; }
payload=$(cat)
# The snapshot is validated to the SAME depth it is consumed at. A `contexts` that is present but
# holds a non-string would otherwise compare unequal against a well-formed live list and be reported
# as `drift` — a confident finding derived from a payload that was never understood, which is the
# shape this repo has fixed twice in the merge hook (a `// []` default that fired on `false`, and an
# object-typed validation that never checked its members).
snap=$(jq -c 'if (.contexts | type) == "array" and (all(.contexts[]; type == "string"))
then (.contexts | sort | unique) else "BAD" end' "$snapshot" 2>/dev/null || true)
if [ -z "$snap" ] || [ "$snap" = '"BAD"' ] || [ "$snap" = "null" ]; then
echo unreadable; exit 0
fi
# THE SNAPSHOT NAMES ITS OWN BRANCH, and a mismatch against `--branch` is a USAGE error rather than a
# class: comparing one branch's live contexts against another branch's mirror would produce a
# confident `match` or `drift` about a pair that was never meant to be compared. Exiting 2 is the
# only report that cannot be mistaken for a finding.
# REQUIRED, not merely cross-checked when present. An absent, empty or non-string `branch` would
# otherwise let a contexts list be compared against ANY `--branch`, which is the same defect the
# mismatch check exists to prevent, reached by omission instead of by disagreement.
snap_branch=$(jq -r 'if (.branch | type) == "string" then .branch else "" end' "$snapshot" 2>/dev/null || true)
if [ -z "$snap_branch" ]; then
echo "snapshot names no branch (a non-empty string \`branch\` is required): $snapshot" >&2; exit 2
fi
if [ "$snap_branch" != "$branch" ]; then
echo "snapshot describes branch '$snap_branch' but --branch is '$branch'" >&2; exit 2
fi
# WHICH RULE GOVERNS THE BRANCH is not decided here. That question — Gitea's Priority ordering, its
# gobwas/glob dialect, case folding, non-ASCII names — is answered by the ONE classifier both this
# script and `pretooluse-merge-consent.sh` load. Reimplementing it here would be the second copy of a
# security predicate that the merge hook's own history argues against, and it would be the copy
# without authority, which is historically the one that goes stale.
classifier="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/branch-rule-classifier.jq"
[ -r "$classifier" ] || { echo "classifier not readable: $classifier" >&2; exit 2; }
# THE PAYLOAD MUST BE AN ARRAY to produce a finding, the same gate the merge hook applies
# before its own call. Without it a JSON OBJECT reached the classifier, matched no rule, and this
# script printed `nomatch` — "no rule governs the branch at all (protection removed)", a positive
# claim about the server's configuration derived from a payload that is not the shape it must be.
# `{}` produced exactly that, measured; removing the gate also turns `{"a":{...}}` into a confident
# `drift`. Pre-existing rather than introduced by ersatztv#859, and fixed here because that issue is
# precisely about arms asserting findings they did not establish; `unreadable` already means "no
# 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
# 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.
if ! printf '%s' "$payload" | jq -e 'type == "array"' >/dev/null 2>&1; then
printf 'unreadable\n'; exit 0
fi
verdict=$(printf '%s' "$payload" | jq --arg b "$branch" -c -f "$classifier" 2>/dev/null || true)
case $(printf '%s' "$verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
none) printf 'nomatch\n'; exit 0 ;;
undecidable) printf 'undecidable\n'; exit 0 ;;
exact) : ;;
# EVERYTHING ELSE, deliberately as ONE arm. The classifier's declared `unreadable` verdict (a rule
# whose name this program could not use, ersatztv#859) and a jq crash both land here, and they are
# NOT given separate arms — because this script's contract publishes exactly five class words and
# `unreadable` already means precisely "no comparison was made", which is true of both.
#
# A separate `unreadable)` arm printing the same word was written first and then deleted: cold
# review measured it as a no-op (removing it left the suite green), and the merge hook's own
# comment condemns exactly that — an arm no observation can distinguish is a comment with syntax.
# The hook can afford two arms because it has two distinct REASON STRINGS to hand an operator;
# here there is one output word, so a second arm would be decoration.
#
# The mechanism is still pinned, one level down, where it IS observable:
# `test_check_required_contexts.py::test_the_CLASSIFIER_ITSELF_returns_the_declared_unreadable_verdict`
# asserts the classifier exits 0 with `{"verdict":"unreadable"}` rather than crashing, which this
# arm cannot distinguish and therefore must not claim to.
*) printf 'unreadable\n'; exit 0 ;;
esac
# The rule's own shape is validated to the depth it is CONSUMED at, not merely as an object. An
# `enable_status_check` that arrived as the STRING "true", or a contexts list holding a non-string,
# would otherwise compare unequal against a well-formed snapshot and be reported as `drift` — a
# confident finding derived from a payload that was never understood, which is the one-level-down
# swallow the merge hook has had to fix twice.
#
# `null`/absent contexts is NOT a read failure: it legitimately means "none required", so against a
# non-empty snapshot it is the FINDING that every required context was removed.
class=$(printf '%s' "$verdict" | jq -r --argjson snap "$snap" '
.rule as $rule
# The EFFECTIVE required set, not the raw list. `enable_status_check: false` means Gitea requires
# nothing on this branch whatever `status_check_contexts` still holds, so type-checking that flag
# and then ignoring its VALUE certifies disabled protection as a current mirror — reporting `match`
# on a branch that is not gating anything at all.
| if ($rule.enable_status_check | type) != "boolean" then "unreadable"
elif ($rule.enable_status_check | not) then (if ($snap | length) == 0 then "match" else "drift" end)
elif (($rule | has("status_check_contexts")) | not) or ($rule.status_check_contexts == null)
then (if ($snap | length) == 0 then "match" else "drift" end)
elif ($rule.status_check_contexts | type) != "array"
or any($rule.status_check_contexts[]; type != "string") then "unreadable"
else (if ($rule.status_check_contexts | sort | unique) == $snap then "match" else "drift" end)
end' 2>/dev/null || true)
case "$class" in
match|drift|nomatch|undecidable|unreadable) printf '%s\n' "$class" ;;
# jq threw, or produced a word this contract does not define. Either way the comparison did NOT
# happen, and saying so is the only safe report — `unreadable` is the class that means exactly
# "no comparison was made", never a silent success.
*) printf 'unreadable\n' ;;
esac