Files
ersatztv/scripts/check-required-contexts.sh
T
timothyandtimothy 761e575836
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 6s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m22s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
fix(787): derive the dropped-step guard's scope, and reconcile its snapshot against the server (#861)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-27 22:37:02 +00:00

128 lines
7.9 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
# 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; }
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) : ;;
# jq threw, or the payload was not an array the classifier could read. The comparison did NOT
# happen, and `unreadable` is the class that says exactly that — never a silent success.
*) 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