#!/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 is a measured 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