Files
ersatztv/scripts/post-review-verdict.sh
T
timothy 5ba442c11c fix(772,792): name the missing toolchain image, and stop a refusal leaving a verdict comment
#772 — the pinned CI toolchain image can be deleted out from under us, and when it was
(2026-08-11..13) all five `container:` jobs died at image pull, both required contexts
included, with the cause buried in each job's log. Root cause is registry-side and is now
established rather than guessed: an owner-level Gitea package cleanup rule (keep_count 15,
remove_days 1, remove_pattern `.*`, keep_pattern no 7-hex sha can match) deletes a sha tag
once 15 newer versions exist, and `ExecuteCleanupRules` ran nightly through the window. The
`ersatztv` package carries the same rule's fingerprint exactly — every sha tag older than
the 15-slot window is gone, every keep_pattern tag back to 26.3.1 survives. Version deletes
leave no audit row, so the specific run cannot be replayed; that limit is stated where the
claim is made. The durable fix belongs to the registry's repo: server-management#842.

What lands here is what a consumer of someone else's registry can do:

  * `toolchain-preflight`, a container-free job (a job consuming the image could not run to
    report it missing) resolving every pin against the registry and failing with a message
    that names the tag and the recovery. Not a `needs:` of the jobs it diagnoses — gating
    five jobs behind a checkout and one curl taxes every green run to speed up a rare red
    one, and they already fail fast.
  * Only HTTP 404 means gone. Everything else is could-not-tell, and rejected credentials
    fail rather than pass as unknown — "the check could not run" must never present as
    "the pin is fine".
  * A recovery path that does not need CI: rebuild the SAME tag from the commit it names
    and push it. The push half was verified against this registry on 2026-08-22 with a
    throwaway package (created, resolved 200, deleted).

#792 — the reported defect was the exit code, and re-measuring says that premise is false:
every no-status path already exits 1, and eight refusal modes now assert it against the real
predecessor, where they pass. The observed 0 came from the invocation, not the script. What
WAS broken is the half-state the issue describes second: the comment was written before the
status, so every refusal left `Review-verdict: MERGEABLE @ <head>` on a PR with no gating
status behind it. The two writes are now ordered status-then-comment, which makes the only
reachable half-state the safe one — a status with no comment leaves the merge hook's
condition (c) with nothing to classify, which is an `ask`. The refusals themselves are
untouched. Ordering rather than compensating deletion: an orphaned-comment cleanup needs a
Gitea call, and these refusals are usually caused by Gitea being unreachable.

Proof for the ordering is the split against origin/main's script: the 8 orphan/ordering
tests go red there, the 8 exit-code tests stay green.

fixes #772
fixes #792
Refs: server-management#842
Decisions-Edit: yes
2026-08-22 22:52:56 +02:00

206 lines
14 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> MERGEABLE | APPROVED | LGTM -> commit status `success` (merge may proceed)
# BLOCKED | NOT-MERGEABLE -> commit status `failure` (merge stays blocked)
# [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; }
usage() {
sed -n '/^# Usage:/,/^# ETV_GITEA_REPO/p' "$0" | sed 's/^# \{0,1\}//'
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 and classify. The positive set MUST stay in sync with the READ side's
# POS_RE/NEG_RE in scripts/check-review-verdict.sh (mergeable|approved|lgtm) — a word this script
# treats as positive but the classifier does not would let the server-side status go green while
# the merge-consent hook still denies, which reads as an unexplained deny.
#
# That sync is NOT asserted anywhere, and you should not assume otherwise. ersatztv#774 tried: a
# test extracted both vocabularies from their own shell source and compared them. Six cold-review
# rounds each found another shell construction that either escaped the extractor or made it fail on
# a correct tree, so it was withdrawn rather than patched a seventh time. Writing a shell parser as
# a regex does not converge.
#
# The fix is to stop having two copies — one declarative vocabulary both scripts read — tracked in
# ersatztv#788. Until that lands, THIS COMMENT IS THE ONLY THING holding the two lists together, and
# comments drift: this one already had, naming .claude/hooks/pretooluse-merge-consent.sh as the home
# of the regex. The hook carries no copy; it delegates to check-review-verdict.sh.
verdict=$(printf '%s' "$verdict_raw" | tr '[:lower:]' '[:upper:]')
case "$verdict" in
MERGEABLE|APPROVED|LGTM) state="success" ;;
BLOCKED|NOT-MERGEABLE) state="failure" ;;
*) die "unknown verdict '$verdict_raw' — use MERGEABLE, APPROVED, LGTM, BLOCKED or NOT-MERGEABLE" ;;
esac
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).
status_payload=$(jq -n \
--arg s "$state" --arg c "$STATUS_CONTEXT" --arg u "$pr_url" \
--arg d "Review-verdict: $verdict @ $short (base: $base_ref)" \
'{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"
# --- 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