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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
128 lines
7.9 KiB
Bash
Executable File
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
|