Files
ersatztv/scripts/post-review-verdict.sh
T
timothyandClaude Fable 5.1 1fc24e8cf6 fix(881): a mutation-outcome claim is executed wherever it is written, bound to the sentence that makes it
`testing.mutation-claims-are-executed` was the right rule scoped to its first
site: `mutation_manifest.py` declared itself "one per `MUTATION`-graded row of
`docs/guard-inventory.md`", so the same claim written in a code comment, a test
docstring or a decision record was outside it by construction. That is where all
four of ersatztv#812's consecutive review-round defects lived.

Extend the rule in place rather than adding a sibling record: a sibling would
recreate the exact shape (a rule per site class, with the next site class outside
both) that #773, #784 and #743 each are. The subject is unchanged; only the
population widens.

Mechanism: `CLAIMS` in `scripts/tests/mutation_manifest.py`, keyed on the PROSE.
Each entry carries the tracked `site` and the verbatim `quote`, checked every run,
so a reworded sentence reports as a retarget instead of drifting from the entry
that justifies it — this is proposal 2 (a quotation of another file is a claim
about that file) adopted where the referent is declared. Each entry also declares
RED or GREEN and is executed in the existing sandbox. GREEN is new: 47 of the 128
candidate lines the corpus grep returns at efadbec29 assert that a mutation is NOT
noticed, and no `MUTATION` row can express that, so the rule was unsatisfiable for
them. The green direction is read by two separate clauses (exited 0, and something
actually passed) so neither can mask the other, and each carries its own disarm
proof.

Proposal 3 (never anchor prose to a state your own commit moves) is rejected as a
DETECTOR and kept as a phrasing rule: measured 2026-09-04, the only plausible
pattern set for it matched 16 lines across the scanned corpus and every one was
legitimate rationale prose.

The seed set falsified a shipped claim on its first run: `post-review-verdict.sh`
asserted that disarming its array-TYPE read-back test left the suite green. It
does not — jq refuses to iterate a `null` `.statuses` and the script dies with the
parse message, reddening `test_a_readback_whose_statuses_array_is_NULL_is_refused`.
Comment corrected, entry graded RED.

fixes #881

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 04:44:28 +02:00

469 lines
36 KiB
Bash
Executable File

#!/usr/bin/env bash
# post-review-verdict.sh — post an H10 review verdict as BOTH a PR comment and a per-sha commit
# status, so merge consent is bound to the exact commit that was reviewed (ersatztv#622).
#
# WHY THIS EXISTS. `pretooluse-merge-consent.sh` evaluates all three consent conditions at the
# moment the merge tool is called. When that call passes `merge_when_checks_succeed=true`, condition
# (a) is deliberately delegated to Gitea — but Gitea then merges whatever head is green at ITS merge
# time, which may be several commits past the head that conditions (b) and (c) were proven against.
# The gate reports satisfied and the newest code merges with no review verdict covering it. That is
# not a bypass: the gate is *satisfied against a snapshot that stops being true* (ersatztv#622).
# Shown by a controlled A/B: with a slow CI check still pending so Gitea waits, a commit pushed after
# scheduling MERGED unreviewed without the required verdict context, and was REFUSED with it.
#
# THE FIX IS THE SHA, NOT THE SCRIPT. A Gitea commit status is attached to ONE sha. Making
# `review-verdict/h10` a REQUIRED status check on `main` therefore makes the H10 invariant
# self-invalidating by construction: push a new commit and the new head simply has no such status,
# so Gitea's own auto-merge refuses to fire until someone re-reviews THAT head and posts a fresh
# verdict. No re-evaluation hook, no polling, no snapshot to go stale — a status cannot be inherited
# by a commit that did not exist when it was written. This closes the hole for every merge path
# (Gitea UI, API, auto-merge, another agent's session), not just the one that goes through the
# Claude PreToolUse hook.
#
# The comment is still posted because it is the human-readable artifact and the hook's condition (c)
# parser reads it; the status is what the SERVER enforces. Both name the same sha on purpose — if
# they ever disagree, the status wins, because it is the one a merge is actually gated on.
#
# Usage:
# scripts/post-review-verdict.sh <pr-number> <verdict> [note ...]
#
# <verdict> a POSITIVE word -> commit status `success` (merge may proceed)
# a NEGATIVE word -> commit status `failure` (merge stays blocked)
# The words are declared ONCE in scripts/lib/review-verdict-vocabulary.sh and are
# printed live by running this script with no arguments — deliberately not restated
# here, because a hand-kept list in a usage banner is the same drift, one layer out.
# [note] optional free text appended to the comment under the verdict line.
#
# Env: ETV_GITEA_TOKEN (token) or ETV_GITEA_BASICAUTH (user:pass) — required.
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
# ETV_GITEA_REPO overrides owner/repo (default: timothy/ersatztv).
set -euo pipefail
STATUS_CONTEXT="review-verdict/h10"
die() { printf 'post-review-verdict: %s\n' "$*" >&2; exit 1; }
# --- The shared verdict vocabulary (ersatztv#788). ----------------------------------------------
# This script (the WRITE side) and scripts/check-review-verdict.sh (the READ side) used to carry two
# hand-written copies of the word list, held together by nothing but a comment. They now derive from
# one declaration. Resolved relative to THIS FILE, never to the caller's cwd: the merge-consent hook
# invokes the read side by absolute path from an arbitrary directory, and both scripts must behave
# the same way about where their vocabulary comes from.
#
# Fails CLOSED on every branch. A vocabulary that cannot be loaded or does not validate means this
# script cannot know whether a word is positive, and posting a `success` it cannot justify is the
# one outcome the H10 gate exists to prevent.
_here=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) \
|| die "could not resolve this script's own directory, so the shared verdict vocabulary cannot be located"
VOCABULARY_LIB="$_here/lib/review-verdict-vocabulary.sh"
[ -r "$VOCABULARY_LIB" ] \
|| die "the shared verdict vocabulary is missing or unreadable at $VOCABULARY_LIB — no status was written"
# Probe-source in a subshell and require the end-of-file marker before trusting the file: a
# top-level `exit` in a sourced library terminates THIS script at the `source` line, which exited 0
# having posted neither a status nor a comment. See the read side for the full reasoning.
# shellcheck source=lib/review-verdict-vocabulary.sh
vocabulary_probe=$( . "$VOCABULARY_LIB" >/dev/null 2>&1 && etv_verdict_vocabulary_loaded 2>/dev/null ) || vocabulary_probe=''
[ "$vocabulary_probe" = 'etv-verdict-vocabulary-loaded' ] \
|| die "the shared verdict vocabulary at $VOCABULARY_LIB did not load to completion (syntax error, truncated, or it exits at top level) — no status was written"
# shellcheck source=lib/review-verdict-vocabulary.sh
. "$VOCABULARY_LIB" \
|| die "the shared verdict vocabulary at $VOCABULARY_LIB could not be sourced — no status was written"
# `set -e` makes this `die` reliable here, unlike on the read side — but the sentinel the library
# sets on validation's last line is what actually gates the words, on both sides.
etv_verdict_vocabulary_validate \
|| die "the shared verdict vocabulary at $VOCABULARY_LIB did not validate (see above) — no status was written"
usage() {
sed -n '/^# Usage:/,/^# ETV_GITEA_REPO/p' "$0" | sed 's/^# \{0,1\}//'
# Upper-cased to match `etv_verdict_words_display`, which the unknown-verdict error uses: an
# operator who has just been shown "use MERGEABLE, ..." should not then read "mergeable" here.
printf '\nVerdict words (from %s):\n positive -> success : %s\n negative -> failure : %s\n' \
"lib/review-verdict-vocabulary.sh" \
"$(etv_verdict_alternation positive | tr '|' ' ' | tr '[:lower:]' '[:upper:]')" \
"$(etv_verdict_alternation negative | tr '|' ' ' | tr '[:lower:]' '[:upper:]')"
exit 2
}
[ $# -ge 2 ] || usage
pr="$1"; verdict_raw="$2"; shift 2
note="$*"
printf '%s' "$pr" | grep -qE '^[0-9]+$' || die "pull-request number must be numeric, got '$pr'"
# Normalize to upper-case for display, and classify against the SHARED vocabulary sourced above.
# There is no word list here any more: `etv_verdict_class` reads the same declaration that builds
# the read side's POS_RE/NEG_RE, so a word this script accepts as positive is positive over there by
# construction rather than by two people editing two files in step (ersatztv#788).
#
# The unknown-word message is derived too. A hand-written "use <word>, <word>, ..." list is how an
# operator learns a vocabulary that has moved on — the same drift, displaced into an error string.
verdict=$(printf '%s' "$verdict_raw" | tr '[:lower:]' '[:upper:]')
verdict_class=$(etv_verdict_class "$verdict") \
|| die "unknown verdict '$verdict_raw' — use $(etv_verdict_words_display)"
case "$verdict_class" in
positive) state="success" ;;
negative) state="failure" ;;
*) die "the shared vocabulary classified '$verdict' as '$verdict_class', which is neither positive nor negative — refusing to guess a commit-status state" ;;
esac
# --- WHOSE verdict the gate will inherit (ersatztv#845). ----------------------------------------
# Loaded HERE: after the arguments validate, so a bare `post-review-verdict.sh` still prints its
# usage, and before the first network call, so a checkout that cannot answer the question refuses
# without having touched the server.
#
# AND ONLY FOR A `success`, for the same reason membership itself is `success`-only (see the
# read-back below). A `failure` is inherited from any attributable account, so who is on the
# allow-list is irrelevant to one — and loading unconditionally meant a workflow whose
# `H10_REVIEWERS` declaration is broken refused REJECTIONS too. That is precisely the outcome the
# success-only argument exists to avoid ("no supported way to record a rejection"), reached one
# condition earlier, on the branch most likely to have broken that declaration: the one editing it.
#
# The list is NOT declared here. `.gitea/workflows/review-verdict.yml` owns it and this derives from
# that file; see `scripts/lib/h10-reviewers.sh` for why the derivation is a parse rather than a
# declaration both sides source. Every branch OF THIS LOAD fails CLOSED, exactly like the vocabulary
# above — scoped that way deliberately, since the load itself is now conditional: a writer that
# cannot find out whether its own POSITIVE verdict is inheritable must not post one and report
# success, because that combination is the ersatztv#845 stall itself. A negative verdict never
# reaches here and needs nothing from it.
if [ "$state" = "success" ]; then
H10_REVIEWERS_LIB="$_here/lib/h10-reviewers.sh"
[ -r "$H10_REVIEWERS_LIB" ] \
|| die "the reviewer allow-list derivation is missing or unreadable at $H10_REVIEWERS_LIB — no status was written"
# Probe-source in a subshell and require the end-of-file marker before trusting the file, for the
# same reason the vocabulary does: a top-level `exit` in a sourced library terminates THIS script at
# the `source` line, having posted neither a status nor a comment, with exit 0.
# shellcheck source=lib/h10-reviewers.sh
h10_probe=$( . "$H10_REVIEWERS_LIB" >/dev/null 2>&1 && etv_h10_reviewers_loaded 2>/dev/null ) || h10_probe=''
[ "$h10_probe" = 'etv-h10-reviewers-loaded' ] \
|| die "the reviewer allow-list derivation at $H10_REVIEWERS_LIB did not load to completion (syntax error, truncated, or it exits at top level) — no status was written"
# shellcheck source=lib/h10-reviewers.sh
. "$H10_REVIEWERS_LIB" \
|| die "the reviewer allow-list derivation at $H10_REVIEWERS_LIB could not be sourced — no status was written"
etv_h10_reviewers_load \
|| die "could not derive the gate's H10_REVIEWERS allow-list (see above) — no status was written. Until it can be read, this tool cannot tell whether the POSITIVE verdict it posts would be honoured or silently re-derived. A negative verdict does not need the allow-list and is unaffected."
fi
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
repo_slug="${ETV_GITEA_REPO:-timothy/ersatztv}"
owner="${repo_slug%%/*}"
repo="${repo_slug##*/}"
[ -n "$owner" ] && [ -n "$repo" ] && [ "$owner" != "$repo_slug" ] \
|| die "ETV_GITEA_REPO must be owner/repo, got '$repo_slug'"
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
auth=(-H "Authorization: token $ETV_GITEA_TOKEN")
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
auth=(-u "$ETV_GITEA_BASICAUTH")
else
die "no Gitea credentials in env — set ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH"
fi
api_get() { curl -sf "${auth[@]}" "$base_url/$1"; }
api_post() { curl -sf "${auth[@]}" -X POST -H 'Content-Type: application/json' -d "$2" "$base_url/$1"; }
# --- Resolve the head sha from the SERVER, never from a local checkout. -------------------------
# A local `git rev-parse HEAD` can be ahead of (unpushed work) or behind (stale fetch) what the PR
# actually points at, and the status must land on the sha Gitea will merge.
prjson=$(api_get "repos/$owner/$repo/pulls/$pr") \
|| die "could not fetch PR #$pr from $base_url (unreachable, missing, or auth rejected)"
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""')
pr_state=$(printf '%s' "$prjson" | jq -r '.state // ""')
pr_url=$(printf '%s' "$prjson" | jq -r '.html_url // ""')
[ -n "$sha" ] || die "PR #$pr has no resolvable head sha"
[ "$pr_state" = "open" ] || die "PR #$pr is '$pr_state', not open — refusing to post a verdict"
short=${sha:0:7}
# --- Record the BASE BRANCH the verdict was formed against (ersatztv#632). ----------------------
# The sha binding closes "the head moved under a fixed verdict". It does not close the mirror case:
# RETARGETING a PR's base changes neither the head sha nor the status, yet changes the effective
# diff — so a verdict written while the PR targeted `main` still reads green after it is pointed at
# a branch with a very different merge-base. Consent outliving what it was granted for, reached from
# the other direction.
#
# The comparator is `base.ref` (the BRANCH NAME), deliberately NOT `base.sha`. `base.sha` tracks the
# base branch's tip, which moves every time anything merges to `main` — comparing it would invalidate
# every open verdict on every unrelated merge, i.e. a self-inflicted merge deadlock. `base.ref`
# changes exactly when someone retargets the PR, which is the event being guarded. A base branch that
# merely ADVANCES is out of scope by design: that is ordinary churn, and rebasing onto it changes the
# head sha, which the existing per-sha binding already catches.
base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""')
[ -n "$base_ref" ] || die "PR #$pr has no resolvable base branch (.base.ref) — refusing to post a verdict that cannot record what it was formed against"
# --- TOCTOU guard: refuse to green a head that stopped being head since we read it. -------------
# Without this, a commit pushed between the head read above and the status write below would inherit
# a verdict written for its parent — reintroducing ersatztv#622 at a smaller time scale. We do NOT
# retry against the new head: the new commit is genuinely unreviewed, and silently re-targeting the
# verdict at it is exactly the failure this script exists to prevent.
#
# THE WINDOW THIS FENCES USED TO BE MUCH WIDER, and that is why the comment is now written AFTER the
# status rather than before it (ersatztv#792). Posting the comment first meant every refusal below
# left a PR carrying `Review-verdict: MERGEABLE @ <head>` with NO `review-verdict/h10` status — and
# the comment is not the gate. The half-state was read by an operator as consent that had not been
# granted. Ordering the two writes status-first makes the surviving half the SAFE half: a status
# with no comment leaves the hook at condition (c) with nothing to classify, which is an `ask`, not
# a grant. The refusals themselves are unchanged and must stay — see
# `release.verdict-writes-status-before-comment`.
# Fail CLOSED if the re-read itself fails. This used to be `sha_now=$(api_get ... | jq ...)`, where
# `set -e` + `pipefail` aborted the script on a failed GET — implicitly, but before any status was
# written. Folding the two reads into one variable with `|| true` would have swallowed that: both
# `sha_now` and `base_now` come back empty, both `[ -n … ]` guards become no-ops, and the status is
# written having confirmed NOTHING about the head or the base. That is a fail-open regression
# introduced by the refactor, so the refusal is now explicit rather than a side effect of `set -e`.
prjson_now=$(api_get "repos/$owner/$repo/pulls/$pr") \
|| die "could not re-read PR #$pr to confirm the head and base had not moved while posting — no status was written. Re-run once Gitea is reachable."
# `[ -n "$x" ] && [ "$x" != "$want" ]` was a fail-OPEN on BOTH of the checks below (ersatztv#778).
# A 2xx body that merely LOST the field — `{"head":{},"base":{}}` — yields an empty value, so the
# `-n` conjunct is false, the comparison never runs, and the status is posted having confirmed
# NOTHING about either the head or the base. The re-read exists precisely to refuse when it cannot
# confirm, so a field it cannot read must die exactly like a field that moved. The transport failure
# one line up is already fatal; this closes the same hole one level down, which is where it keeps
# reappearing in this repo.
#
# WHICH LINE CARRIES THE SAFETY, stated because it is not the one it looks like: dropping the `-n`
# conjunct is the fix. The unconditional `!=` below already rejects an empty value, so the explicit
# `-z` arms are REDUNDANT for the safety property and exist only to give the operator an accurate
# message ("carried no head sha" rather than "moved to ''"). Disarming a `-z` arm alone therefore
# leaves the suite green — the two overlap, and a mutation proof aimed at it would be vacuous. The
# proof in `test_a_reread_that_LOSES_a_field_refuses_instead_of_posting` is taken against the real
# predecessor (the `-n` conjunct restored), which is what actually goes red.
sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""')
if [ -z "$sha_now" ]; then
die "re-read PR #$pr but its response carried no head sha, so it is UNPROVEN that the head is still $short — no status was written. Re-run once Gitea returns a well-formed PR body."
fi
if [ "$sha_now" != "$sha" ]; then
die "head moved from $short to ${sha_now:0:7} while posting — that commit is UNREVIEWED, so no status was written. Re-review the new head and run this again."
fi
# The same TOCTOU window applies to the base (ersatztv#632): a retarget between the read above and
# the status write below would bind the verdict to a base that is no longer the PR's, and the head
# sha check would not notice because retargeting does not move the head.
base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""')
if [ -z "$base_now" ]; then
die "re-read PR #$pr but its response carried no base ref, so it is UNPROVEN that the base is still '$base_ref' — no status was written. Re-run once Gitea returns a well-formed PR body."
fi
if [ "$base_now" != "$base_ref" ]; then
die "base branch changed from '$base_ref' to '$base_now' while posting — the diff you reviewed is not the diff this PR now merges, so no status was written. Re-review against the new base and run this again."
fi
# The base branch goes in the status DESCRIPTION, not in the comment. The comment body is parsed by
# `scripts/check-review-verdict.sh`, whose grammar had three false-opens in its history; nothing
# parses the description today, so this adds a field without reopening that surface. The hook reads
# it back and compares (ersatztv#632).
# Held in a variable rather than inlined, because the read-back below identifies OUR write by it.
status_desc="Review-verdict: $verdict @ $short (base: $base_ref)"
status_payload=$(jq -n \
--arg s "$state" --arg c "$STATUS_CONTEXT" --arg u "$pr_url" \
--arg d "$status_desc" \
'{state:$s, context:$c, description:$d, target_url:$u}')
api_post "repos/$owner/$repo/statuses/$sha" "$status_payload" >/dev/null \
|| die "failed to post the '$STATUS_CONTEXT' commit status on $short"
printf 'posted status: %s = %s on %s\n' "$STATUS_CONTEXT" "$state" "$short"
# --- Is the verdict we just wrote one the GATE would honour? (ersatztv#845) ---------------------
# The gate inherits an existing `review-verdict/h10=success` only from a status whose
# `.creator.login` is on its `H10_REVIEWERS` allow-list (ersatztv#742). This script posts with
# whatever account owns the credential in the environment and used to never ask whose it was, so the
# two coupled values could drift apart with no diagnostic anywhere: the status really is written,
# this tool really did report success, and then the next `pull_request_target` event re-derives it
# and posts `pending` over it. Again on the next event. The PR deadlocks and the only trace is a
# `::warning::` inside a workflow run nobody is reading.
#
# MEASURED AFTER THE WRITE, NOT BEFORE IT. A pre-flight `GET /user` would test what the credential
# CLAIMS to be; this tests what Gitea actually recorded as the author of this status, which is the
# value the gate reads. It also needs no scope beyond the repo access the POST already required —
# a `/user` probe would add a read:user dependency that a narrowly-scoped but perfectly valid
# reviewer token might not carry, turning a hardening step into a new way to block every verdict.
#
# THE RESIDUAL, stated: on refusal a non-inheritable status is left standing on the head, because
# the check runs after the POST. That is the half-state `release.verdict-writes-status-before-comment`
# already designates as the safe one — a status with no comment leaves the merge hook at condition
# (c) with nothing to classify, which is an `ask`, not a grant — and the gate re-derives the status
# itself on the next PR event. It is not repaired here on purpose: a second corrective write is the
# sticky-sentinel mechanism ersatztv#849 is separately designing, and inventing a parallel one on
# this path would be two mechanisms for one invariant.
# WHY OUR ROW IS ALWAYS IN THIS RESPONSE — measured, and the INTUITIVE reading is wrong in a way
# worth recording. This endpoint PAGES, and its `total_count` reports the PAGE rather
# than the total (`?limit=2` on a 15-context head returns 2 rows and `total_count: 2`), so a
# truncated body is not detectable from any field in it. That looked like a risk of the verdict row
# falling off a page. It is not: Gitea selects the MAX id per context, orders those DESCENDING, and
# paginates that. Measured on the same head — ids
# [17,19,21,23,25,27,30,31,32,34,36,38,39,41,43], and `?limit=2` returns 41 and 43, the two highest.
# The status this script has just POSTed is by construction the newest on the head, so it sorts
# first and page 1 contains it whatever the page size.
#
# `?limit=100` therefore has NO observable effect under that ordering, and is kept anyway as the
# cheapest insurance against the two ways the reasoning could stop holding: a burst of concurrent
# status writes between our POST and this read, and the ordering property changing. Said to be
# unwitnessed ON PURPOSE — no test can show an effect a correct server does not produce — rather
# than left looking like a guard someone forgot to test. The instance clamps `limit` to
# `MAX_RESPONSE_ITEMS`, measured at 50, so 100 asks for whatever the server will give.
#
# THE GATE ASKS A HARDER QUESTION WITH THE SAME PARAMETER, which is worth stating so this does not
# read as an inconsistency. `review-verdict.yml` sends `?limit=100` on its reads too — what it
# rejected was using that limit as a COMPLETENESS ARGUMENT ("refuse when the page came back FULL at
# 100"), which was dead code because the instance caps at 50; it settles completeness with a page-2
# probe instead. The two are asking different questions: the gate must prove a verdict is ABSENT
# before acting on that absence, which no single page can establish, while this script only confirms
# that a row it just wrote is PRESENT and refuses when it is not. Presence needs no completeness
# argument, which is why no page-2 probe appears here.
status_readback=$(api_get "repos/$owner/$repo/commits/$sha/status?limit=100") \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but it could not be read back to confirm the gate will honour it — no comment was written. Re-run once Gitea is reachable."
# `.statuses` serialises as `null` rather than `[]` on a head with no statuses (ersatztv#751), so
# the array TYPE is tested rather than assumed; a body that merely lost its array yields no entry
# and is refused below, exactly like one that carries no verdict.
# Three outcomes are kept apart below, each with its own message, because they call for different
# actions: a body whose `.statuses` is not an array at
# all, a well-formed body that does not carry our context, and a body carrying MORE THAN ONE row for
# it. Keeping them apart is also what makes the array-TYPE test witnessable, and that is EXECUTED
# rather than asserted: disarming the type test feeds a `null` `.statuses` to `map(select(...))`,
# jq refuses to iterate it, and the script dies with the PARSE message instead of the shape one — so
# `test_a_readback_whose_statuses_array_is_NULL_is_refused` reddens. It carries no date because it
# is not a witnessing: it is a declared claim in `CLAIMS` (`scripts/tests/mutation_manifest.py`),
# re-taken on every run (ersatztv#881).
readback_matches=$(printf '%s' "$status_readback" | jq -c --arg c "$STATUS_CONTEXT" \
'if (.statuses | type) == "array" then (.statuses | map(select(.context == $c))) else null end') \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but the read-back body could not be parsed — no comment was written."
if [ -z "$readback_matches" ] || [ "$readback_matches" = "null" ]; then
die "the '$STATUS_CONTEXT' status was posted on $short, but the read-back body carries no \`.statuses\` ARRAY (it is missing, null, or some other type), so nothing about the verdict can be confirmed — no comment was written. This is a response-SHAPE problem; check what the combined-status endpoint returns on this Gitea version."
fi
# This `|| die` and the `.[0]` one below are DECLARED UNREACHABLE, not counted as protection:
# `jq -c` has already produced `$readback_matches` as an array, so `length` and `.[0]` cannot fail.
# They are kept for the same reason the rest of this block carries messages — a future edit that
# widens what reaches here should fail with a sentence, not a jq trace — and are stated as
# unreachable so nobody mistakes them for tested guards.
readback_count=$(printf '%s' "$readback_matches" | jq -r 'length') \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but the matching rows could not be counted — no comment was written."
# A body carrying SEVERAL JSON documents makes jq emit one count per document, and the arithmetic
# comparisons below would then interpolate "0 0" into a message about "0 0 separate rows". Not
# producible by Gitea and it already failed closed, but a mangled diagnostic is the defect class this
# change exists to remove, so the shape is rejected explicitly.
case "$readback_count" in
'' | *[!0-9]*)
die "the '$STATUS_CONTEXT' status was posted on $short, but the read-back did not yield a single countable list of rows (got '$readback_count') — no comment was written. This is a response-SHAPE problem; the body may carry more than one JSON document."
;;
esac
if [ "$readback_count" = 0 ]; then
# Does NOT assert a single cause. Reaching here means the row this run just POSTed is absent from
# a response that, by the ordering measured above, should have it FIRST — so the honest statement
# is that something removed or replaced it, not a guess at which. Paging is deliberately not
# named: it is not a live candidate for THIS row.
#
# `.statuses` is known to be an array here (the branch above refused otherwise), so `length` cannot
# fail and needs no fallback. The `|| readback_rows=...` guard that used to be here was dead for a
# reason worth remembering: jq exits 0 with EMPTY OUTPUT on empty input, so an exit-status fallback
# would not have fired even in the case it was written for.
readback_rows=$(printf '%s' "$status_readback" | jq -r '.statuses | length')
die "the '$STATUS_CONTEXT' status was posted on $short but is not among the $readback_rows status rows read back, so it is UNPROVEN that the verdict landed — no comment was written. The row this run wrote is the newest on the head and should sort first, so its absence means something removed or replaced it. Check the PR's checks before re-running."
fi
if [ "$readback_count" != 1 ]; then
# Not reachable against Gitea as measured (the combined endpoint returns the latest row PER
# context), so this is a guard against that property silently ceasing to hold rather than against
# an observed shape. It is here because the alternative is `[0]`, which would pick a row and
# judge its author while a DIFFERENT row for the same context is the one standing.
die "the '$STATUS_CONTEXT' status on $short reads back as $readback_count separate rows, so which one is in force is ambiguous — no comment was written. The combined-status endpoint is expected to return one row per context; if that has changed, this check needs rewriting before verdicts can be trusted."
fi
readback_entry=$(printf '%s' "$readback_matches" | jq -c '.[0]') \
|| die "the '$STATUS_CONTEXT' status was posted on $short, but its row could not be read — no comment was written."
# `.status`, NOT `.state`, and with NO FALLBACK to `.state`. A row inside `.statuses[]` serialises
# its state under the key `status`; `state` exists only as the AGGREGATE at the top level of the
# combined body. Measured on Gitea 1.27.1, 2026-08-29, against a real `review-verdict/h10` row.
# Reading `.state` per row yields the empty string for every well-formed response, which compares
# unequal to the state just posted and would refuse EVERY verdict — a repo-wide deadlock.
#
# A `(.status // .state)` fallback was here and was WRONG, recorded because it looks like harmless
# defensiveness. The gate reads `.status` and nothing else (`review-verdict.yml`,
# `ex_state=$(... jq -r '.status // ""')`). On a response carrying only `.state`, the fallback made
# THIS tool accept and report success while the gate saw no recognisable verdict and re-derived it
# — ersatztv#845 itself, recreated by the code meant to prevent it. The writer must read exactly
# what the gate reads: on a shape neither understands, refusing loudly here is correct, and
# tolerating one the gate cannot is not.
# Each extraction carries its own `|| die`. Without one they still fail closed — a jq error under
# `set -e` aborts the script before the comment — but with only jq's own stderr, which does not say
# that a status was left standing on the head. `.creator` arriving as a STRING rather than an object
# is the shape that reaches this (probed: jq exits 5, "Cannot index string with string").
readback_state=$(printf '%s' "$readback_entry" | jq -r '.status // ""') \
|| die "the '$STATUS_CONTEXT' status was posted on $short but its state could not be read from the response — no comment was written."
readback_desc=$(printf '%s' "$readback_entry" | jq -r '.description // ""') \
|| die "the '$STATUS_CONTEXT' status was posted on $short but its description could not be read from the response — no comment was written."
readback_creator=$(printf '%s' "$readback_entry" | jq -r '.creator.login // ""') \
|| die "the '$STATUS_CONTEXT' status was posted on $short but its creator could not be read from the response — no comment was written. The verdict is UNPROVEN, not necessarily wrong."
# A row carrying no `status` key is a SHAPE problem, not an overwrite. Kept as its own branch because
# a refusal that states a cause which did not happen is its own defect class in this repo (#859):
# "something overwrote it" would send the reader looking for a race that never occurred.
if [ -z "$readback_state" ]; then
die "the '$STATUS_CONTEXT' status on $short read back with no state field, so it is UNPROVEN that the verdict just written is the one standing — no comment was written. This is a response-SHAPE problem, not a race: check what \`.statuses[]\` rows look like on this Gitea version."
fi
# Identify OUR write before judging its author. `/commits/{sha}/status` returns the LATEST row per
# context — measured, not assumed: on 2026-08-29 a head carrying two `review-verdict/h10` rows in the
# `/statuses` list (an Actions `pending`, then a reviewer `success`) returned only the newer one here.
# So a run of the gate that overwrote ours between the POST and this read would otherwise have ITS
# author judged below, and an Actions write carries `creator: null` — which would surface as a
# wrong-credential accusation against a credential that is fine.
# WHICH ARM CARRIES THE SAFETY, stated because the two OVERLAP and a fixture that changes both
# leaves either one disarmable with the suite still green (#685's shape). The DESCRIPTION is the
# identifier: it carries the verdict word, the short sha and the base. Precisely, that identifies a
# write BY THIS SCRIPT WITH THIS VERDICT ON THIS HEAD AND BASE — a second run with the same verdict
# reproduces it byte for byte — which is what the check needs, since any such row is equally ours.
# The STATE is defence in depth — our own writer cannot produce our description with a different
# state, since the verdict word determines it. Both are pinned by their own single-field fixture
# below rather than by one fixture that differs in both.
if [ "$readback_state" != "$state" ] || [ "$readback_desc" != "$status_desc" ]; then
die "the '$STATUS_CONTEXT' status standing on $short is not the one just written (state '$readback_state', description '$readback_desc') — it was overwritten, so this verdict is not in force. No comment was written. If the head is unchanged the gate simply re-derived the status and re-running this is enough; re-review only if the head moved."
fi
if [ -z "$readback_creator" ]; then
# States the fact first and the likely cause second. `creator` absent, null, or carrying an empty
# login all land here; an ACTIONS-token status is the one that actually occurs, but saying it IS
# one would assert a cause this response cannot establish.
die "the '$STATUS_CONTEXT' status on $short reads back with no attributable creator (absent, null, or an empty login). A status POSTed by an ACTIONS token reads exactly this way, and the gate never inherits one as a reviewer verdict (ersatztv#742), so this verdict would be silently re-derived. No comment was written. Post it with a reviewer's own credential."
fi
# THE ALLOW-LIST APPLIES TO `success` ONLY, because that is what the gate does, and mirroring it is
# the entire point of this check. `review-verdict.yml` short-circuits on an existing `success` only
# when the creator is a MEMBER, but on an existing `failure` when it is merely ATTRIBUTABLE — any
# account, because inheriting a rejection can only ever withhold an exemption while re-deriving one
# can turn it green. Enforcing membership on a `failure` here would therefore refuse a verdict the
# gate honours perfectly well, and say so with a diagnostic that is simply false: it would tell an
# off-list reviewer their rejection will be re-derived when it will not. Worse, it would refuse to
# write the BLOCKED comment, leaving a real reviewer no supported way to record a rejection.
#
# Attributability is already established for both states by the checks above — the creator is
# non-null, and the description is the one this run wrote, which is `Review-verdict:`-shaped and
# carries this base. Membership is the only additional thing `success` needs.
if [ "$state" = "success" ]; then
# Explicit capture rather than `if ! etv_h10_reviewers_contains ...`, so the THIRD outcome cannot
# be folded into the second. The function answers 0 member / 1 not-a-member / 2 cannot-tell.
set +e
etv_h10_reviewers_contains "$readback_creator"
h10_membership=$?
set -e
case "$h10_membership" in
0)
printf 'verdict author: %s (on the gate allow-list: %s)\n' "$readback_creator" "$ETV_H10_REVIEWERS"
;;
1)
die "the '$STATUS_CONTEXT' status on $short was posted by '$readback_creator', which is NOT on the gate's H10_REVIEWERS allow-list ('$ETV_H10_REVIEWERS'). A positive verdict is inherited only from an allow-listed account, so the status is written but the gate will not honour it: the next PR event re-derives it and posts over it, and the PR stalls with no visible cause. No comment was written. Either post with an allow-listed reviewer's credential, or add '$readback_creator' to H10_REVIEWERS in .gitea/workflows/review-verdict.yml."
;;
*)
die "could not determine whether '$readback_creator' is on the gate's H10_REVIEWERS allow-list (membership check returned $h10_membership). Refusing to report a positive verdict as posted when it is unknown whether the gate will honour it. No comment was written."
;;
esac
else
printf 'verdict author: %s (a %s verdict is honoured from any attributable account, so the allow-list does not apply)\n' "$readback_creator" "$state"
fi
# --- The comment (human-readable artifact + the hook's condition-(c) input). --------------------
# Written LAST, after the gating status exists (ersatztv#792). The verdict line MUST start the line:
# the hook anchors its parser to line-start precisely so a comment that merely QUOTES the template
# mid-sentence cannot self-approve a merge.
body="Review-verdict: $verdict @ $short"
[ -n "$note" ] && body="$body"$'\n\n'"$note"
comment_payload=$(jq -n --arg b "$body" '{body:$b}')
api_post "repos/$owner/$repo/issues/$pr/comments" "$comment_payload" >/dev/null \
|| die "the '$STATUS_CONTEXT' status was written on $short, but the verdict COMMENT could not be posted. The merge gate needs both: it reads the comment for condition (c) and will ASK rather than auto-grant until one exists. Re-run this command once Gitea is reachable."
printf 'posted comment: Review-verdict: %s @ %s\n' "$verdict" "$short"
if [ "$state" = "failure" ]; then
printf '\nPR #%s stays BLOCKED: %s is failing on head %s.\n' "$pr" "$STATUS_CONTEXT" "$short"
else
printf '\nPR #%s may merge on this head. Any NEW commit clears this status by construction —\n' "$pr"
printf 'a fresh review + verdict is required for each head (ersatztv#622).\n'
fi