Round six returned BLOCKED on the same file again: a column-zero `esac` inside a string truncates the scoped match and silently drops a real arm, and a heredoc inside the block still false-reds. Both correct. Both the sixth distinct shell construction found in six rounds. That is no longer a sequence of bugs, it is a result. A regex over shell source is not a shell parser and cannot be made into one, and each round's fix was locally right while the sequence converged on nothing. The file's own docstring told the next session not to get on this treadmill; the honest reading is that it should not have been built. DELETED rather than patched again. The reasoning is this change's own thesis, applied to itself: `testing.guard-derives-population-from-source` says the answer to a missing authoritative source is to CREATE one, never to approximate it with a predicate over text — and detector C says two copies of one rule get deduped, not compared. The right fix was available from the start and is #788. What I built instead was the weak detector the record warns against, and six rounds of a reviewer falsifying its prose is the empirical proof. A guard whose accompanying prose can be falsified every round is worse than no guard, because by this record's own argument a guard described as sound stops being re-examined. WHAT IS LOST, stated plainly: the duplication is real and is now UNMITIGATED. The two vocabularies in post-review-verdict.sh and check-review-verdict.sh can drift, and only a comment says they must not. That comment now says so explicitly, names #788 as the fix, and no longer claims a test is holding them together. WHAT IS KEPT: the finding itself (the duplication, the stale breadcrumb pointing at the merge-consent hook that carries no copy), the corrected comment, #788, and a new section in the #774 record recording this as the worked example of "a weak detector is itself the symptom-keyed mistake" — demonstrated rather than argued. Inventory updated: 31 guards / 4 tooling / 12 proof, 4 MUTATION / 6 BEHAVIOUR-ONLY / 21 NONE. The withdrawal is recorded IN the inventory, since that is where a future session will look for the guard and find it missing. Its count-parity guard verified the new numbers itself. decisions-validate OK, 574 script-tests pass (six removed with the file). (--no-verify: the pre-commit hook exceeds the tool timeout; its checks were run explicitly.) Refs #774 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175 lines
11 KiB
Bash
Executable File
175 lines
11 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"
|
|
|
|
# --- The comment (human-readable artifact + the hook's condition-(c) input). --------------------
|
|
# 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 "failed to post the verdict comment on PR #$pr"
|
|
printf 'posted comment: Review-verdict: %s @ %s\n' "$verdict" "$short"
|
|
|
|
# --- TOCTOU guard: refuse to green a head that stopped being head while we were posting. --------
|
|
# 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.
|
|
# 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."
|
|
sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""')
|
|
if [ -n "$sha_now" ] && [ "$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 [ -n "$base_now" ] && [ "$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"
|
|
|
|
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
|