#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
206 lines
14 KiB
Bash
Executable File
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
|