Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m41s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 7m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m26s
#859 was filed as a wrong STATED CAUSE. It was masking a live false-open in the merge gate. Gitea reports a GLOB branch-protection rule with an EMPTY `branch_name` — the canonical name lives only in `rule_name`. Measured 2026-08-30 on a scratch repo against 1.27.1. jq's `//` fires on null and false but NOT on `""`, so `(.branch_name // .rule_name // "")` resolved every glob rule to the empty string — a name with no metacharacters — and the glob test, the entire basis of the classifier's undecidable-first ordering, never saw it. Measured on the predecessor: glob `m*` (not requiring review-verdict/h10) beside plain `main` (requiring it) resolved to `exact` on `main` and AUTO-GRANTED a scheduled merge, while Gitea — ordering by Priority then plain-name-ness — may be applying `m*`. That is #622's hole, reached through the ordering written to close it. Mirror case: a glob alone resolved to `none` and DENIED about a rule that provably governs the base. A name is now a non-empty string. Each field resolves to a NAME, a SKIP (absent/null/ empty — fall through), or POISON (present, wrong type — poisons whichever field carries it). A rule with no usable name is a distinct `unreadable` verdict with its own operator cause, instead of feeding `none`, whose whole authority is "the full rule list was read and none matches". The short-circuit is STRUCTURAL: jq binds `as` eagerly, so the flat form still evaluated `offs`/`nonascii` on the bad name and died before reaching the arm meant to prevent that. Also #859: `branch_protections` is fetched ONCE per run, not twice. The round trip is the smaller half — it is mutable config, so two reads can disagree and the two arms then decide about different repo states with neither able to notice. #858: `verdict_script` resolves from `$repo_root`, not `$CLAUDE_PROJECT_DIR`. And the finding that mattered more — `ETV_HOOK_FIRE_LIB` is `. `-SOURCED, so it is CODE running before stdin is read and before `decide` exists. A first draft exempted it as "telemetry, not a predicate"; cold review refuted that by execution: a decoy hook-fire-log.sh in an env-var-named tree printing an allow and exiting 0 GRANTS THE MERGE, bypassing every check. Classify a path by how it is CONSUMED, never by what it is called. This hook's copy is self-located; the other twelve are #891 (high/security), which records the reachable case — husky launches the prepush hooks by RELATIVE path, so the two roots diverge there. check-required-contexts.sh gains an array-type gate (a JSON object previously printed `nomatch`, a positive claim about server config from a body it cannot consume). Verification: 1377 passed / 2 skipped; 11 declared mutants, 11 detected, disjoint reddened sets; classifier executed across jq 1.8.2 and 1.6 with identical results; both env-var tests ship a negative control, because the passing outcome is also what an inert decoy produces. Four cold review rounds plus a bounded prose check. Every round found defects the previous round's fixes introduced — a type conflation that re-opened the auto-grant, a comment asserting the opposite of the line its own commit changed, and a corrected sentence whose identical twin survived in the same diff. Docs: new record `process.hook-resolves-inputs-from-repo-root`; both inline sites cite it rather than arguing it twice. docs/remote-state-inventory.md's row for the second read updated. Follow-ups filed: #891 (the other 12 hooks), #895 ("all N tests green" claims). fixes #858 fixes #859 Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
165 lines
11 KiB
Bash
Executable File
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
|