`scripts/pr-changed-files.sh` binds its paged enumeration by re-reading `.base.ref`,
`.base.sha` and `.head.sha` after paging and comparing them to what it expected. That
is a comparison of a value against itself, so it detects movement still in effect at
the end and is blind to an alias. The BASE alias (`main -> S -> main`) was fenced by
#706's monotonic `change_target_branch` count. The HEAD alias was fenced by nothing:
a force-push `H1 -> H2 -> H1` across the paging round trips leaves the final `.head.sha`
comparison equal while the middle pages came from `H2`, so a mixed file list can produce
a docs-only exemption `success` no single head ever justified.
Two issues found this independently — #664 from the #649 cold review, #803 from #778's
read-then-write inventory — which is the argument for fixing it rather than documenting
it a third time.
Measured on the live instance before choosing an option:
* Gitea is 1.27.1. `GET /compare/{base}...{head}` still returns `total_commits` and
`commits` and NO `files` key (probed over a 12-commit range), so pinning the diff to
two shas remains unavailable. This independently confirms #747's re-dating of the
same claim from 1.25.4.
* The timeline records every push as a `pull_push` event carrying
`{"is_force_push": bool, "commit_ids": [...]}`. PR #802 has eighteen (all false),
PR #761 five (three true). An ABA is necessarily two pushes, so the count moves by
two where the sha moves by zero.
* PR #761 really went `8798a1d -> 830a407 -> 8798a1d`. The alias shape occurs in
ordinary force-push-and-revert; no attacker is required.
* The fence does not abstain on its own trigger. Across 20 triggered
(push -> `pull_request_target` run) pairs on PRs #802/#834/#761 the `pull_push` event
predates its own run's `started_at` by 26-102s. That margin is runner queue latency,
so it is a deployment property, not an API guarantee, and is recorded as such.
So `count_retargets` becomes `count_pr_mutations`: one walk, two tallies, one shared
trust flag, a separate fence arm and diagnostic per axis. `is_force_push` is deliberately
not read — an ordinary push also invalidates a mid-flight enumeration, and an ABA's
restoring push can be non-forced when `H1` is an ancestor.
The advisory hook gets the other half of #803: `$sha` was captured once at the top and
every later check (CI status, H10 status, verdict comments) addressed it, so a push
landing across the enumeration was checked against the commit it replaced. `.head.sha`
is now re-read at the SAME hoist and off the SAME response as the base re-read, so the
two axes cannot describe different instants; a moved head denies, an unreadable one asks.
#803's floor — three contracts that asserted more than the code did — lands too:
`pr-changed-files.sh`'s `exit 0` contract line, its head re-read comment, and
`review-verdict.yml`'s call-site claim that `exit 0` means "complete and bound to $SHA".
Mutation-proved rather than asserted. Deleting the head arm reddens 4 tests; keying on
the count's VALUE instead of its MOVEMENT reddens the settled-history test (every PR has
a non-zero push count, so that mutation would withhold every exemption — the #751 shape);
folding both event types into one tally reddens the separation test. An earlier draft of
that separation test claimed to catch the folding mutation and stayed GREEN under it —
it posed only the base-moves direction — so it now poses both, and states which one is
unobservable and why.
Verification: 1109 passed, 2 skipped (`PYTHONPATH=. python3 -m pytest scripts/tests -q`);
ruff check + format clean; decisions-validate OK; YAML and `bash -n` clean on every
touched shell body. No `.cs` touched, so the BOM/format gate does not apply.
fixes #803
fixes #664
Decisions-Edit: yes
774 lines
64 KiB
Bash
Executable File
774 lines
64 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# PreToolUse / mcp__gitea__pull_request_write — derive merge consent from STATE instead of
|
|
# trusting the agent's judgment (ersatztv#303 H6 + H10). A PR merge is the one irreversible op; allow it
|
|
# only when ALL are true:
|
|
# (a) the PR's CI combined status is green, AND
|
|
# (b) every checkbox in the linked issue's "## Done-when" section is ticked, AND
|
|
# (c) a review-verdict comment on the PR references the CURRENT head sha (H10) — proving the
|
|
# LATEST commit was reviewed, not a stale earlier diff (the ersatztv#242 failure mode:
|
|
# "re-review the fix commit, not just the initial PR diff").
|
|
#
|
|
# EVERY ONE OF THOSE IS A SNAPSHOT, taken when the merge tool is called. The window is SMALL for an
|
|
# immediate merge and UNBOUNDED for a scheduled one. Small is not zero, and this comment used to say
|
|
# "sound", which is the overclaim ersatztv#778 removed: this hook returns `allow` and a SEPARATE call
|
|
# performs the merge, so a push can still land in between. The merge API accepts an optional
|
|
# `head_commit_id` that would make that call a true compare-and-set; a PreToolUse hook cannot add an
|
|
# argument, only refuse without one. With merge_when_checks_succeed, Gitea merges
|
|
# later, against whatever head is green then (ersatztv#622). So the sha-bound half of H10 is
|
|
# enforced by the SERVER, not here — `review-verdict/h10` is a required status check on `main`,
|
|
# written per-sha by scripts/post-review-verdict.sh, and a new commit cannot inherit it. This hook
|
|
# additionally refuses to SCHEDULE an auto-merge unless that status is already green on head, so the
|
|
# two mechanisms agree at the only moment they can both observe the same commit.
|
|
# The "## Done-when" issue-body checklist is the convention (docs/decisions.md, CLAUDE.md Task
|
|
# Completion Protocol). One box is "adversarial review passed"; the others are per-issue.
|
|
# The H10 review-verdict convention: after reviewing a PR (or its latest fix commit), post a PR
|
|
# comment carrying a line `Review-verdict: <MERGEABLE|APPROVED|LGTM|BLOCKED|NOT-MERGEABLE> @ <head-sha>`.
|
|
#
|
|
# Decision policy — a CONSENT gate, so it does NOT fail silently open:
|
|
# - state derivable and satisfied -> grant (auto-approve: permissionDecision "allow",
|
|
# so NO redundant permission prompt fires —
|
|
# the derived state IS the consent, ersatztv#314)
|
|
# - state derivable and NOT satisfied -> deny (actionable reason)
|
|
# - state NOT derivable (no creds, Gitea down,
|
|
# no linked issue, no Done-when section) -> ask (surface to a human/session judgment)
|
|
# Only a real merge is gated; every other pull_request_write method is passed through UNTOUCHED
|
|
# (bare exit 0 → normal permissioning still applies), NOT auto-granted.
|
|
#
|
|
# WHY "grant" (not a bare exit 0) on the satisfied path (ersatztv#314 root cause): a PreToolUse hook
|
|
# that exits 0 with no JSON does NOT auto-approve — it only declines to block, so control falls through
|
|
# to the normal permission system and the raw MCP prompt still fires. The gate therefore only ever
|
|
# ADDED a deny/ask net; it never REMOVED the baseline prompt on the happy path, so a satisfied merge
|
|
# was confirmed twice (conversationally + a redundant mechanical prompt). Emitting permissionDecision
|
|
# "allow" is what actually suppresses the prompt — "derive consent from state" made real.
|
|
#
|
|
# Gitea auth from env (never committed): ETV_GITEA_TOKEN (a token) OR ETV_GITEA_BASICAUTH (user:pass).
|
|
# ETV_GITEA_URL overrides the base (default: the LAN instance; a LAN address, not a secret).
|
|
set -euo pipefail
|
|
|
|
# ersatztv#776 — report that this hook fired. MUST precede any stdin read.
|
|
# Claude hook: decides by printed JSON, so stdout is captured.
|
|
ETV_HOOK_FIRE_LIB="${CLAUDE_PROJECT_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)}/scripts/hook-fire-log.sh" || true
|
|
[ -r "$ETV_HOOK_FIRE_LIB" ] && . "$ETV_HOOK_FIRE_LIB" || true
|
|
type etv_hook_fire_begin >/dev/null 2>&1 || etv_hook_fire_begin() { :; }
|
|
etv_hook_fire_begin pretooluse-merge-consent "" capture || true
|
|
input=$(cat)
|
|
|
|
decide() { # $1=grant|allow|deny|ask $2=reason
|
|
case "$1" in
|
|
# grant = the gate is SATISFIED → auto-approve so no redundant permission prompt fires.
|
|
grant) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'; exit 0 ;;
|
|
# allow = not our concern (non-merge method) → pass through untouched; normal permissioning applies.
|
|
allow) exit 0 ;;
|
|
deny) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'; exit 0 ;;
|
|
ask) jq -n --arg r "$2" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}'; exit 0 ;;
|
|
esac
|
|
}
|
|
|
|
method=$(printf '%s' "$input" | jq -r '.tool_input.method // ""' 2>/dev/null || true)
|
|
[ "$method" = "merge" ] || decide allow ""
|
|
|
|
owner=$(printf '%s' "$input" | jq -r '.tool_input.owner // ""' 2>/dev/null || true)
|
|
repo=$(printf '%s' "$input" | jq -r '.tool_input.repo // ""' 2>/dev/null || true)
|
|
pr=$(printf '%s' "$input" | jq -r '.tool_input.pull_number // ""' 2>/dev/null || true)
|
|
mwcs=$(printf '%s' "$input" | jq -r '.tool_input.merge_when_checks_succeed // false' 2>/dev/null || true)
|
|
[ -n "$owner" ] && [ -n "$repo" ] && [ -n "$pr" ] || decide ask "H6 merge gate: could not read owner/repo/pull_number from the merge call; confirm manually that CI is green and the issue's Done-when boxes are ticked."
|
|
|
|
base_url="${ETV_GITEA_URL:-http://192.168.1.95:3000}/api/v1"
|
|
# curl wrapper carrying whichever auth is configured; empty output on any failure.
|
|
gq() {
|
|
local path="$1"
|
|
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
|
curl -sf -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/$path" 2>/dev/null || true
|
|
elif [ -n "${ETV_GITEA_BASICAUTH:-}" ]; then
|
|
curl -sf -u "$ETV_GITEA_BASICAUTH" "$base_url/$path" 2>/dev/null || true
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
if [ -z "${ETV_GITEA_TOKEN:-}" ] && [ -z "${ETV_GITEA_BASICAUTH:-}" ]; then
|
|
decide ask "H6 merge gate: no Gitea credentials in env (ETV_GITEA_TOKEN or ETV_GITEA_BASICAUTH), so CI/Done-when state can't be verified. Confirm manually that CI is green and the linked issue's Done-when boxes are all ticked, then approve."
|
|
fi
|
|
|
|
prjson=$(gq "repos/$owner/$repo/pulls/$pr")
|
|
[ -n "$prjson" ] || decide ask "H6 merge gate: could not fetch PR #$pr from Gitea (unreachable or auth rejected). Verify CI-green + Done-when manually before merging."
|
|
|
|
sha=$(printf '%s' "$prjson" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
|
body=$(printf '%s' "$prjson" | jq -r '.body // ""' 2>/dev/null || true)
|
|
|
|
# --- Docs-only exemption: if every changed file is docs/process, skip the gate. ---
|
|
# The file list must be enumerated EXHAUSTIVELY, validated row by row, and bound to ONE head, or the
|
|
# exemption is unsafe. ALL of that now lives in scripts/pr-changed-files.sh — the single shared
|
|
# implementation, also called by .gitea/workflows/review-verdict.yml (ersatztv#649).
|
|
#
|
|
# Why it moved: this logic was written twice. This copy is ADVISORY (a failure produces a human
|
|
# prompt); the workflow's copy is ENFORCED (it writes the branch-protection-required
|
|
# `review-verdict/h10` status). Four rounds of ersatztv#643 hardening landed here and never reached
|
|
# there, leaving the copy with real authority strictly weaker than the copy without — and its safe
|
|
# behaviour resting on a bash arithmetic error rather than an intentional guard. Two copies of a
|
|
# security predicate drift; one cannot.
|
|
#
|
|
# What is NOT shared, deliberately: the docs-only allow-list below. This one also lets .claude/,
|
|
# .gitea/ and .husky/ through, which is safe HERE only because a match falls through to a human
|
|
# prompt rather than auto-granting. The workflow's list is narrower for exactly that reason. Sharing
|
|
# the enumeration fixes the drift; sharing the classification would erase an intended difference.
|
|
#
|
|
# A non-zero exit means "could not tell" and MUST withhold the exemption — never read stdout without
|
|
# checking the status. An empty `$sha` (unparseable PR JSON) reaches the script as an empty argument
|
|
# and is rejected there, so that path also fails closed.
|
|
#
|
|
# The 5th argument binds the enumeration to a base branch (ersatztv#698 route 1), because
|
|
# `/pulls/{n}/files` diffs against the PR's LIVE base and retargeting moves that without moving the
|
|
# head. Be precise about what it buys HERE, which is less than what it buys in the workflow: the
|
|
# workflow passes the base from a `pull_request_target` event payload, fixed at event time and beyond
|
|
# a retarget's reach, so it detects a retarget outright. This hook has no such trusted snapshot — it
|
|
# passes the base it just read from the live PR, so what it asserts is that the base did not move
|
|
# between that read and the enumeration. Narrower, and still worth having: without it the hook cannot
|
|
# tell a mid-flight retarget from an honest read at all. An empty/unparseable `.base.ref` reaches the
|
|
# script as an empty argument and is rejected there, so that path fails closed too.
|
|
base_ref=$(printf '%s' "$prjson" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
|
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
|
files=""; files_complete=no
|
|
if files=$("$repo_root/scripts/pr-changed-files.sh" "$owner" "$repo" "$pr" "$sha" "$base_ref" 2>/dev/null); then
|
|
files_complete=yes
|
|
fi
|
|
|
|
# HOW THIS PREDICATE IS EVALUATED, matching the enforced gate (ersatztv#698,
|
|
# `ci.grep-q-pipefail-inversion`). `printf … | grep -q` INVERTS under `set -o pipefail`: grep -q exits
|
|
# at its first match, printf then takes SIGPIPE (141), and a MATCH is reported as a failed pipeline —
|
|
# so this negated test would grant a spurious docs-only exemption for any PR whose path list exceeds
|
|
# the pipe buffer. A here-string fixes that but is materialised via temporary storage for large inputs,
|
|
# so it can fail when temp space is full or unwritable and flip the predicate the same way. Counting
|
|
# with `grep -c` drains stdin (no SIGPIPE) over an ordinary pipe (no temp file); `grep -c` exits 1 for
|
|
# a zero count, which is a legitimate answer, so only a status >1 is a real error and is treated as
|
|
# "cannot tell" -> no exemption.
|
|
# Advisory here, so the blast radius is a missing prompt rather than a green required check; the
|
|
# construct is identical on purpose, because the two copies drifting is what ersatztv#649 was about.
|
|
docs_nonmatching=$(printf '%s\n' "$files" | grep -cvE '^(docs/|\.claude/|\.husky/|\.gitea/|.*\.md$)') || docs_grep_status=$?
|
|
if [ "${docs_grep_status:-0}" -gt 1 ]; then
|
|
docs_nonmatching=1 # grep itself failed: cannot tell, so withhold the exemption
|
|
fi
|
|
if [ "$files_complete" = yes ] && [ -n "$files" ] && [ "${docs_nonmatching:-1}" -eq 0 ]; then
|
|
# Docs/process-only PR: the Done-when + review-verdict gate doesn't apply — but this exemption is a
|
|
# file-TYPE bypass, NOT the a+b+c "provably reviewed & ready" proof, so it does NOT auto-grant. It
|
|
# passes through to normal permissioning (one prompt). This deliberately keeps a human in the loop for
|
|
# process-control files (.claude/ / .gitea/ / .husky/ — the gate, CI, and git hooks themselves): a PR
|
|
# that weakens the gate must not silently self-merge (ersatztv#317 review nit). Only the satisfied
|
|
# merge path below auto-grants.
|
|
decide allow "" # passthrough (exit 0 → normal prompt), NOT grant
|
|
fi
|
|
|
|
# --- Base-change detection: a verdict is bound to a head AND to a base (ersatztv#632). ---
|
|
# `review-verdict/h10` is per-sha, which makes "the head moved under a fixed verdict" impossible by
|
|
# construction. Retargeting a PR's base is the mirror case and slips through: it changes neither the
|
|
# head sha nor the status, so a verdict formed while the PR targeted `main` still reads green after
|
|
# the PR is pointed at a branch with a very different merge-base. The diff moves while the verdict
|
|
# and the head both hold still.
|
|
#
|
|
# DETECTION, NOT PREVENTION, and only on this path. A commit status carries no base, so the
|
|
# server-side required check cannot see this; a merge driven through the Gitea UI or API is
|
|
# unaffected. That is the accepted exposure — base changes are rare, manual, and this is a
|
|
# two-account repo — but it is now recorded in a place that fails LOUD rather than only in a doc.
|
|
#
|
|
# GRACEFUL ADOPTION, mirroring (b) and (c): a description with no `(base: …)` field is a verdict
|
|
# posted before ersatztv#632 and gets NO opinion, rather than denying every in-flight PR the day
|
|
# this lands. The window closes on its own — verdicts are per-head and short-lived, so every verdict
|
|
# posted after this carries the field.
|
|
# "Could not check" is a THIRD outcome, distinct from both "matches" and "no base recorded". Cold
|
|
# review found the first draft collapsing it into the latter: an unreadable status response yielded
|
|
# an empty `recorded_base`, which took the graceful-adoption path and skipped validation silently —
|
|
# after which a later, successful status read could still auto-grant. A transient failure would then
|
|
# have produced a "merge gate: satisfied" message for a comparison that never happened. Every
|
|
# unreadable input here therefore falls through to a human (`ask`), never to silence.
|
|
# RE-READ THE BASE HERE, ONCE, FOR EVERY PATH BELOW (ersatztv#778).
|
|
#
|
|
# "Below" is literal, and the one consumer ABOVE is disclosed rather than implied: the docs-only
|
|
# enumeration still runs against the snapshot `$base_ref` and can `decide allow` before reaching
|
|
# this point. That is bounded and deliberate — a docs-only match is a PASSTHROUGH to the ordinary
|
|
# human prompt, never an auto-grant, so a stale base there costs a prompt someone was going to see
|
|
# anyway. Every path that can GRANT passes through the check below.
|
|
#
|
|
# `$base_ref` above comes from the PR snapshot taken at the top of this hook, and the docs-only
|
|
# enumeration between there and here is up to forty round trips. A PERSISTENT retarget in that gap
|
|
# needs no ABA and no force-push: every base-dependent decision below would be formed against a
|
|
# branch the PR no longer targets. Checking a stale identifier is not checking — which is the whole
|
|
# of `process.check-and-use-pins-a-version`, so the guard enforcing that rule must not break it.
|
|
#
|
|
# This re-read first landed inside the scheduled-auto-merge branch only, which fixed the branch-
|
|
# protection lookup and left the #632 retarget DETECTION below still reading the stale snapshot. Cold
|
|
# review demonstrated the consequence with this repo's own fixture: scheduled+retarget denied, while
|
|
# immediate+retarget auto-GRANTED. That is the twin-missed shape — a fix applied to the path where it
|
|
# was noticed — so the re-read is hoisted above every consumer rather than duplicated into each.
|
|
prjson_now=$(gq "repos/$owner/$repo/pulls/$pr")
|
|
if [ -z "${prjson_now//[[:space:]]/}" ] || ! printf '%s' "$prjson_now" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
|
decide ask "H10 merge gate: could not re-read PR #$pr to confirm it still targets '$base_ref' before checking the verdict against it. Confirm the target branch, then merge."
|
|
fi
|
|
base_now=$(printf '%s' "$prjson_now" | jq -r '.base.ref // ""' 2>/dev/null || true)
|
|
if [ -z "$base_now" ]; then
|
|
decide ask "H10 merge gate: PR #$pr reports no base branch (.base.ref), so the verdict cannot be checked against the branch it was formed for (ersatztv#632). Confirm the PR still targets the branch it was reviewed against before merging."
|
|
fi
|
|
if [ -n "$base_ref" ] && [ "$base_now" != "$base_ref" ]; then
|
|
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr was retargeted from '$base_ref' to '$base_now' while this gate was evaluating. Every check formed against '$base_ref', including the changed-file enumeration and the review verdict, describes a merge that is no longer the one being requested (ersatztv#632). Re-review against '$base_now' and run: scripts/post-review-verdict.sh $pr MERGEABLE"
|
|
fi
|
|
# From here on both names are the freshly-confirmed base; they are equal by the check above.
|
|
base_ref=$base_now
|
|
live_base=$base_now
|
|
|
|
# THE HEAD IS RE-READ AT THE SAME HOIST, FROM THE SAME RESPONSE (ersatztv#803).
|
|
#
|
|
# `$sha` comes from the PR snapshot at the top of this hook, and until 2026-08-28 every later check
|
|
# consumed that captured value: the CI combined status, the `review-verdict/h10` status, and the
|
|
# verdict-comment classification were all evaluated against `/commits/$sha/status` and `--head $sha`.
|
|
# A push landing in the gap — which includes the docs-only enumeration's up-to-forty round trips —
|
|
# was therefore checked against the commit it had just replaced, and the hook would report "a
|
|
# positive Review-verdict references the current head" about a head that was no longer current.
|
|
#
|
|
# This is the SAME defect the base had until #778 hoisted the re-read above, and it is fixed the same
|
|
# way rather than a different way. Reading `.head.sha` off `$prjson_now` — the response the base
|
|
# check already fetched — costs NO extra round trip, and it keeps the two axes on ONE snapshot, so
|
|
# they cannot disagree about which moment they describe. Two separate reads would answer about two
|
|
# different instants while reading as one check.
|
|
#
|
|
# DENY, not ask, and for the same reason the `stale` verdict class denies: a head that moved means
|
|
# the verdict this hook is about to accept covers an OLDER commit, which is a state we have
|
|
# positively established rather than failed to establish. An UNREADABLE `.head.sha` is the different
|
|
# case and asks.
|
|
#
|
|
# WHAT THIS DOES NOT CLOSE, said here rather than left to be inferred. A push landing after this
|
|
# check still passes, exactly as a retarget does — the file's rule against a second re-read applies
|
|
# unchanged (see the branch-protection block below), because two reads only move the window rather
|
|
# than closing it. That residual is bounded server-side and this hook is not what bounds it: the new
|
|
# head has no `review-verdict/h10` status, and that context is REQUIRED on `main`, so Gitea refuses
|
|
# the merge (#622). The hook's job here is to stop CLAIMING a head is reviewed when it can see that
|
|
# it is not — an advisory gate that states something false is worse than one that asks.
|
|
if [ -n "$sha" ]; then
|
|
sha_now=$(printf '%s' "$prjson_now" | jq -r '.head.sha // ""' 2>/dev/null || true)
|
|
if [ -z "$sha_now" ]; then
|
|
decide ask "H10 merge gate: PR #$pr reports no head commit (.head.sha) on re-read, so whether the review verdict still covers the current head could not be confirmed. Check the PR, then merge."
|
|
fi
|
|
if [ "$sha_now" != "$sha" ]; then
|
|
decide deny "H6/H10 merge gate: BLOCKED — PR #$pr's head moved from ${sha:0:7} to ${sha_now:0:7} while this gate was evaluating. Every check formed against ${sha:0:7} — the changed-file enumeration, the CI status and the review verdict — describes a commit that is no longer the one being merged (ersatztv#803). Re-review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE"
|
|
fi
|
|
fi
|
|
if [ -n "$sha" ]; then
|
|
# This is the THIRD read of this endpoint in a worst-case hook run (the ordinary-CI branch and the
|
|
# scheduled-auto-merge branch each do their own). Sharing one snapshot would close a narrow
|
|
# same-run window where two reads disagree, but the later branches derive different decisions from
|
|
# a failed read than this one does, so threading a shared response through them is a change to
|
|
# pre-existing logic rather than to ersatztv#632's. Left deliberately, noted so it is not
|
|
# rediscovered as an oversight: every `decide` exits immediately, so the reads cannot produce a
|
|
# single self-contradictory message — only a later decision made on a fresher snapshot.
|
|
vjson_base=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
|
# Same jq-1.6 rule as everywhere else in this file: check emptiness in SHELL first, never via
|
|
# `jq -e`'s exit status over empty input.
|
|
# VALIDATE EVERY FIELD THE EXTRACTION CONSUMES, on EVERY row — the same rule the file-enumeration
|
|
# guard learned the hard way. Checking only that `.statuses` is an array left a hole one level
|
|
# down: `{"statuses":[1]}` passes a top-level type check, then `.context` on a number errors, and
|
|
# a `|| true` on the extraction turned that error into an empty `vdesc` — i.e. straight back onto
|
|
# the graceful-adoption path this block exists to distinguish from. That is the identical
|
|
# swallow-the-error shape fixed a few lines up, surviving one level deeper.
|
|
if [ -z "${vjson_base//[[:space:]]/}" ] \
|
|
|| ! printf '%s' "$vjson_base" \
|
|
| jq -e '.statuses | type == "array"
|
|
and all(.[]; type == "object"
|
|
and (.context | type == "string")
|
|
and (.description == null or (.description | type == "string")))' \
|
|
>/dev/null 2>&1; then
|
|
decide ask "H10 merge gate: could not read the commit statuses for PR #$pr head ${sha:0:7}, so the verdict could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
|
|
fi
|
|
# No `|| true` here. The validation above makes an error unreachable, but a swallowed error would
|
|
# be indistinguishable from "no base recorded" — the exact confusion this block removes — so the
|
|
# failure is handled explicitly rather than left to a fallback that reads as a benign result.
|
|
if ! vdesc=$(printf '%s' "$vjson_base" \
|
|
| jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .description // ""' \
|
|
2>/dev/null); then
|
|
decide ask "H10 merge gate: the commit statuses for PR #$pr head ${sha:0:7} could not be parsed to find the review verdict, so it could not be checked against the PR's base branch (ersatztv#632). Confirm the review covered the branch this PR currently targets ('$live_base') before merging."
|
|
fi
|
|
# The field is written by scripts/post-review-verdict.sh as a trailing `(base: <ref>)`. Its
|
|
# ABSENCE is the one benign case: a verdict posted before ersatztv#632 could not have carried it,
|
|
# and denying those would block every in-flight PR the day this lands. The window closes on its
|
|
# own, since verdicts are per-head and short-lived.
|
|
recorded_base=$(printf '%s' "$vdesc" | sed -n 's/.*(base: \(.*\))$/\1/p')
|
|
if [ -n "$recorded_base" ] && [ "$recorded_base" != "$live_base" ]; then
|
|
decide deny "H10 merge gate: BLOCKED — the review verdict on head ${sha:0:7} was formed while PR #$pr targeted '$recorded_base', but it now targets '$live_base'. Retargeting a base does not move the head sha, so the per-sha verdict status still reads green even though the effective diff has changed (ersatztv#632). Re-review against the new base and run: scripts/post-review-verdict.sh $pr MERGEABLE"
|
|
fi
|
|
fi
|
|
|
|
# --- Linked issue: Gitea auto-close keywords in the PR body. ---
|
|
issues=$(printf '%s' "$body" | grep -ioE '(close[sd]?|fix(e[sd])?|resolve[sd]?) +#[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
|
|
[ -n "$issues" ] || decide ask "H6 merge gate: PR #$pr has no linked issue (no 'fixes #N' / 'closes #N' in its body), so there is no Done-when checklist to derive consent from. Confirm the work is complete + reviewed, then approve."
|
|
|
|
# --- (b) Done-when checkboxes: every linked issue must have an all-ticked section. ---
|
|
for n in $issues; do
|
|
ibody=$(gq "repos/$owner/$repo/issues/$n" | jq -r '.body // ""' 2>/dev/null || true)
|
|
[ -n "$ibody" ] || decide ask "H6 merge gate: could not fetch linked issue #$n. Verify its Done-when checklist manually before merging."
|
|
# Slice the "## Done-when" section: from that header to the next "## " (or EOF).
|
|
section=$(printf '%s\n' "$ibody" | awk '
|
|
/^##[[:space:]]+[Dd]one-when/ {grab=1; next}
|
|
grab && /^##[[:space:]]/ {grab=0}
|
|
grab {print}')
|
|
if [ -z "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
|
decide ask "H6 merge gate: linked issue #$n has no '## Done-when' checklist section (the merge-consent convention — see CLAUDE.md Task Completion Protocol). Add one, or confirm completion manually and approve."
|
|
fi
|
|
unchecked=$(printf '%s\n' "$section" | grep -cE '^[[:space:]]*[-*][[:space:]]+\[[[:space:]]\]' || true)
|
|
if [ "${unchecked:-0}" -gt 0 ]; then
|
|
decide deny "H6 merge gate: BLOCKED — linked issue #$n has $unchecked unticked box(es) in its ## Done-when checklist. Finish (or explicitly tick) every completion criterion — including the adversarial-review box — before merging PR #$pr."
|
|
fi
|
|
done
|
|
|
|
# --- (a) CI combined status must be green (unless deferring to Gitea's own check-gate). ---
|
|
if [ "$mwcs" != "true" ]; then
|
|
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check CI. Verify CI is green before merging."
|
|
cistatus=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
|
state=$(printf '%s' "$cistatus" | jq -r '.state // ""' 2>/dev/null || true)
|
|
case "$state" in
|
|
success) : ;;
|
|
"") decide ask "H6 merge gate: could not read CI status for PR #$pr ($sha). Verify CI is green before merging." ;;
|
|
*)
|
|
# `review-verdict/h10` is itself one of the contexts folded into the COMBINED state, so a PR
|
|
# awaiting its verdict reports combined 'pending' and would otherwise be reported as a CI
|
|
# problem — sending the reader to build logs when the missing thing is the review. Name the
|
|
# real blocker when the verdict is the only thing outstanding.
|
|
#
|
|
# "Not green" is anything that is not `success`, NOT just pending/failure: Gitea also has
|
|
# `error` (and `warning`), and omitting those would let an errored build hide behind the
|
|
# verdict and produce the flatly false claim "every CI check is green". `skipped` IS treated
|
|
# as green — the image-push job skips on every PR (ersatztv#593: a skipped context is not red).
|
|
nongreen=$(printf '%s' "$cistatus" \
|
|
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
|
|
| map("\(.context)=\(.status)") | join(", ")' 2>/dev/null || true)
|
|
# The verdict's OWN state decides the wording: absent/pending means nobody has reviewed this
|
|
# head, while failure/error means someone reviewed it and said no. Telling a reviewer to "post
|
|
# a verdict" when they already posted a BLOCKED one would be actively misleading.
|
|
vonly=$(printf '%s' "$cistatus" \
|
|
| jq -r '[.statuses[]? | select(.status != "success" and .status != "skipped")]
|
|
| if (length == 1 and .[0].context == "review-verdict/h10") then .[0].status else "" end' 2>/dev/null || true)
|
|
case "$vonly" in
|
|
pending)
|
|
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green; the only outstanding context is 'review-verdict/h10' on head ${sha:0:7}, i.e. this head has no review verdict yet. Review it and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
failure|error)
|
|
decide deny "H6/H10 merge gate: BLOCKED — every CI check on PR #$pr is green, but 'review-verdict/h10' is '$vonly' on head ${sha:0:7}: this head was reviewed and REJECTED. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
esac
|
|
decide deny "H6 merge gate: BLOCKED — PR #$pr CI status is '$state', not 'success' (not green: ${nongreen:-unknown}). Wait for a green build (or pass merge_when_checks_succeed to let Gitea gate it) before merging."
|
|
;;
|
|
esac
|
|
else
|
|
# --- SCHEDULED auto-merge: everything this hook proves is a SNAPSHOT (ersatztv#622). ----------
|
|
# With merge_when_checks_succeed, Gitea performs the merge later, against whatever head is green
|
|
# at THAT moment — but (b) and (c) below are evaluated against the head that exists right now.
|
|
# Any commit pushed in between would merge with no verdict covering it. Demonstrated as a
|
|
# controlled A/B (#622): with a slow CI check pending so Gitea waits, an unreviewed commit pushed
|
|
# after scheduling MERGED without the required verdict context and was REFUSED with it.
|
|
#
|
|
# The durable fix is server-side and lives outside this hook: `review-verdict/h10` is a REQUIRED
|
|
# status check on `main`, and a commit status belongs to exactly ONE sha, so a later commit cannot
|
|
# inherit it and Gitea's own gate refuses to merge until that head is re-reviewed.
|
|
#
|
|
# What we add HERE is the matching precondition at SCHEDULING time: refuse to arm an auto-merge
|
|
# unless the sha-bound status already exists on this head. Checking the comment alone (condition
|
|
# (c) below) is not enough for this path — the comment is what a human reads, the status is what
|
|
# the server enforces, and only the latter survives a new push. Deny rather than ask: the remedy
|
|
# is a single documented command, so there is nothing here for a human to adjudicate.
|
|
[ -n "$sha" ] || decide ask "H6 merge gate: could not resolve PR #$pr head sha to check the review-verdict status. Verify the review covered the latest commit before scheduling an auto-merge."
|
|
# Read the COMBINED endpoint, not `/statuses/{sha}`: the latter returns one row per status POST
|
|
# rather than per context and pages at 50, so a head with a few CI reruns can push the verdict off
|
|
# the first page and read as absent — a confusing false deny. The combined endpoint returns
|
|
# latest-per-context, which is exactly the question being asked.
|
|
vjson=$(gq "repos/$owner/$repo/commits/$sha/status?limit=100")
|
|
# Same portability point as the file-pagination guard above: do not let jq's empty-input exit
|
|
# status decide this. Here the fallthrough happens to land on `vstate=""` -> deny (fail-CLOSED,
|
|
# so this was never a hole), but it would have surfaced the wrong message — a "BLOCKED, no
|
|
# verdict" deny instead of the "could not read the status" ask this branch exists to give.
|
|
# Validate the MEMBERS, not just the array. `.statuses | type == "array"` passes for
|
|
# `{"statuses":[1]}`, and the extraction below then errors with "Cannot index number with string"
|
|
# and exits 5 — which, under `set -e`, aborts this hook with NO JSON on stdout at all. A consent
|
|
# hook that emits nothing has violated its own contract: it neither grants, denies nor asks. Same
|
|
# one-level-down swallow as the #632 base-change guard and the branch-protection shape check
|
|
# below; the validation domain must match the CONSUMPTION domain (ersatztv#778).
|
|
if [ -z "${vjson//[[:space:]]/}" ] \
|
|
|| ! printf '%s' "$vjson" \
|
|
| jq -e '(.statuses | type == "array")
|
|
and all(.statuses[]; type == "object"
|
|
and ((.context | type) == "string")
|
|
and ((.status | type) == "string"))' >/dev/null 2>&1; then
|
|
decide ask "H6/H10 merge gate: could not read the 'review-verdict/h10' status for PR #$pr head ${sha:0:7} (Gitea unreachable, or a response whose status rows are not the expected shape). Confirm the current head is reviewed before scheduling an auto-merge."
|
|
fi
|
|
vstate=$(printf '%s' "$vjson" | jq -r '[.statuses[] | select(.context == "review-verdict/h10")] | first | .status // ""')
|
|
case "$vstate" in
|
|
success) : ;;
|
|
"") decide deny "H6/H10 merge gate: BLOCKED — PR #$pr has no 'review-verdict/h10' commit status on head ${sha:0:7}, so scheduling an auto-merge would freeze consent at a head Gitea may not be the one to merge (ersatztv#622). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
pending) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is still pending on PR #$pr head ${sha:0:7} (no verdict posted for this commit yet). Review the current head and run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
*) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is '$vstate' on PR #$pr head ${sha:0:7}. Resolve the findings, then run: scripts/post-review-verdict.sh $pr MERGEABLE" ;;
|
|
esac
|
|
|
|
# --- The mitigation this path RESTS on, verified instead of asserted (ersatztv#778). -----------
|
|
# Everything above proves a property of the head that exists NOW. What makes that safe under
|
|
# merge_when_checks_succeed is stated in the paragraph opening this branch: `review-verdict/h10`
|
|
# is a REQUIRED status check on the base, a commit status belongs to exactly ONE sha, so a commit
|
|
# pushed after scheduling cannot inherit it and Gitea's own gate refuses the merge.
|
|
#
|
|
# That guarantee is branch-protection CONFIG. It lives outside this repo, no code here owned it,
|
|
# and until #778 nothing compared the two — so the grant reason handed to a human cited a
|
|
# protection that could have been switched off with no signal anywhere. The comment above and the
|
|
# grant string below are claims about the past; a dated claim is not a check.
|
|
#
|
|
# This is the hook's OWN defect class (#778 / `process.check-and-use-pins-a-version`): a check
|
|
# ("a later push clears the status") authorizes an action ("arm an auto-merge that Gitea completes
|
|
# later") over state that can change in between, with nothing pinning it. The read here does not
|
|
# pin anything either — branch protection can still be edited after this call — but it converts an
|
|
# ASSUMPTION that was never observed into a precondition that is, which is the honest ceiling for
|
|
# a config whose API offers no version, ETag or conditional read.
|
|
#
|
|
# Tri-state, matching this file's idiom throughout: unreadable -> ask (a human adjudicates),
|
|
# present -> proceed, ABSENT -> deny. Absence is not a degraded read; it is #622's hole reopened,
|
|
# and the whole point of that issue is that the failure is silent from the merge caller's side.
|
|
# Belt-and-braces: `$base_ref` was proven non-empty and re-confirmed at the hoisted check above,
|
|
# so this cannot fire today. Kept because it is the precondition this block's URL depends on, and
|
|
# a future edit that moves either piece should fail loudly here rather than request a URL with an
|
|
# empty path segment.
|
|
[ -n "$base_ref" ] || decide ask "H6/H10 merge gate: could not resolve PR #$pr's base branch, so the 'review-verdict/h10' required-check protection that makes a scheduled auto-merge safe (ersatztv#622) can't be confirmed. Verify branch protection on the base, or merge immediately instead of scheduling."
|
|
# The base was re-read and confirmed unchanged above, for every path — see the hoist comment
|
|
# there. It is deliberately NOT re-read a second time here: two reads would create a window
|
|
# between them for no gain, and the hoisted check already covers the enumeration gap that made
|
|
# this necessary.
|
|
# A read failure here is NOT evidence about the branch. The deleted by-name endpoint answered 404
|
|
# for "no rule with this name", which was a finding; the LIST endpoint's 404 means the repo was not
|
|
# found or is invisible to this credential, which is a read failure. Absence is now established by
|
|
# the classifier returning `nomatch` over a list that WAS read, never by an HTTP status.
|
|
# ALWAYS enumerate the rule LIST; never look a rule up by name. The by-name endpoint
|
|
# (`branch_protections/{name}`) is an exact DB lookup — `GetProtectedBranchRuleByName` — which
|
|
# performs no matching and knows nothing about precedence, so a 200 from it means only "a rule
|
|
# with this NAME exists and lists this context", never "this context is required on this branch".
|
|
#
|
|
# It was used first, with the list consulted only on a 404, and cold review found what that left
|
|
# behind: the precedence argument below guarded the 404 path while the 200 path — the one this
|
|
# repo actually takes — granted without it. Given a rule `main` requiring `review-verdict/h10` and
|
|
# a rule `m*` with better Priority that does not, Gitea applies `m*`, and the by-name hit on
|
|
# `main` granted anyway. The hardened path was dead code and the unhardened one was live. Deleting
|
|
# the twin rather than documenting it is the point: one fetch, one classifier, one argument, and
|
|
# no second path to keep in step. The ref no longer reaches a URL segment, so it needs no
|
|
# encoding either.
|
|
bp_file=$(mktemp) || decide ask "H6/H10 merge gate: could not allocate a temp file to read branch protection for '$base_ref'. Confirm the 'review-verdict/h10' required check manually before scheduling an auto-merge."
|
|
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
|
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
|
else
|
|
bp_code=$(curl -s -o "$bp_file" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
|
fi
|
|
bp_list=$(cat "$bp_file" 2>/dev/null || true)
|
|
bp=""
|
|
if [ "$bp_code" = "200" ] && printf '%s' "$bp_list" | jq -e 'type == "array"' >/dev/null 2>&1; then
|
|
# DO NOT claim parity with Gitea's matcher — this code cannot have it, and asserting it would
|
|
# be the exact defect this PR records (a mitigation outside the code, asserted rather than
|
|
# verified). Gitea compiles a rule name with gobwas/glob and a `/` separator, so its `*` does
|
|
# NOT cross a slash, `?`/`[…]`/`{a,b}` are wildcards, and a plain name is folded case-
|
|
# insensitively. Reimplementing that here would be a second copy of somebody else's parser.
|
|
#
|
|
# So the classification is deliberately THREE-way, and each arm is safe without knowing the
|
|
# dialect:
|
|
# exact — no glob rule could apply, AND some rule name has no glob metacharacter and
|
|
# equals the base case-insensitively. Only then is a single rule decidable.
|
|
#
|
|
# UNDECIDABLE IS EVALUATED FIRST, and the order is the point. Gitea picks the
|
|
# governing rule with `GetFirstMatched` over a list sorted by Priority, THEN
|
|
# by plain-name-ness — so a glob rule with a better Priority outranks an
|
|
# exactly-named one. Preferring `exact` would therefore inspect a rule Gitea
|
|
# might not be applying: if the exact rule requires `review-verdict/h10` and a
|
|
# higher-priority glob rule does not, the gate auto-grants on a base where the
|
|
# check is not enforced. Asking whenever ANY glob rule could apply is sound
|
|
# without knowing the precedence rules at all, which is the only claim this
|
|
# code is entitled to make about somebody else's resolver.
|
|
#
|
|
# Case folding is ASCII-only here, while Gitea's `EqualFold` is
|
|
# Unicode-aware — so a rule `ünstable` and a base `Ünstable` fold equal there
|
|
# and not here. ASCII-fold equality implies EqualFold equality, so the gap can
|
|
# only MISS a match, never invent one; but a miss lands on `none`, which
|
|
# DENIES with the stated cause that no rule can govern the base. The backslash
|
|
# paragraph below rejects "nearly unreachable" as a standard for that arm, and
|
|
# the same standard has to apply here, so a rule name carrying any non-ASCII
|
|
# byte is `undecidable` rather than fold-compared. Two fold-equal plain names
|
|
# are undecidable too: this code picks by list order while Gitea picks by
|
|
# Priority, and guessing which one is enforced is the defect the arm order
|
|
# above exists to avoid.
|
|
# undecidable — some glob rule COULD govern this base. Tested with a provable SUPERSET of any
|
|
# glob dialect: literal prefix before the first metacharacter, `.*`, literal
|
|
# suffix after the last. If even that does not match, no dialect can, because
|
|
# every dialect requires the literal head and tail to match literally.
|
|
#
|
|
# BACKSLASH counts as a metacharacter for that purpose, and it is the one case that breaks the
|
|
# superset proof if it does not. gobwas/glob reads `\{` as a LITERAL brace, so a rule `a\{b`
|
|
# governs the base `a{b` — while a superset that treated `\` as literal would build `a\.*b`,
|
|
# fail to match, and answer `none`, i.e. deny a base that IS protected. Git ref rules make this
|
|
# nearly unreachable (a branch name may not contain `*`, `?`, `[` or `\`, though it MAY contain
|
|
# `{`), but `none` is the arm that authorises a DENY on the stated grounds "nothing can govern
|
|
# this base", so its premise has to hold unconditionally rather than usually.
|
|
# none — nothing can possibly govern the base, so it is genuinely unprotected.
|
|
#
|
|
# `undecidable` asks rather than granting or denying. Over-matching would auto-grant on a base
|
|
# whose protection we never established (#622's hole, reached through the block written to
|
|
# close it); under-matching would deny with a stated cause that is false, which this block's
|
|
# own comment calls the worse outcome. Asking is the only answer that is honest in both
|
|
# directions, and it is rare in practice: as of 2026-08-19 this repo's only rule is the plain
|
|
# name `main`, which the classifier resolves to `exact` on every run. That is a dated
|
|
# observation about mutable remote config, not a property to rely on.
|
|
# The classifier is a FILE now (ersatztv#787), so its absence is a new failure mode: `jq -f` on a
|
|
# missing program exits 2 with empty stdout, which reaches the `*)` arm below and asks that "this
|
|
# repo's branch-protection rules came back in a shape this hook could not parse" — blaming the
|
|
# payload for a missing local file. That is precisely the states-a-cause-that-did-not-happen defect
|
|
# the two comments beside that arm were written to fix, so it is checked here rather than inherited.
|
|
classifier="$repo_root/scripts/lib/branch-rule-classifier.jq"
|
|
if [ ! -r "$classifier" ]; then
|
|
rm -f "$bp_file"
|
|
decide ask "H6/H10 merge gate: the shared branch-protection rule classifier is missing or unreadable at $classifier, so which rule governs '$base_ref' — and therefore whether 'review-verdict/h10' is required on it — could not be derived (ersatztv#787). Restore the file, or confirm the required checks manually."
|
|
fi
|
|
bp_verdict=$(printf '%s' "$bp_list" | jq --arg b "$base_ref" -c -f "$classifier" 2>/dev/null || true)
|
|
case $(printf '%s' "$bp_verdict" | jq -r '.verdict // ""' 2>/dev/null || true) in
|
|
exact) bp=$(printf '%s' "$bp_verdict" | jq -c '.rule' 2>/dev/null || true); bp_code=200 ;;
|
|
undecidable) rm -f "$bp_file"
|
|
decide ask "H6/H10 merge gate: no branch-protection rule on this repo governs '$base_ref' decidably — a GLOB rule could govern it, or two rule names fold-equal, or a name is non-ASCII. This hook deliberately does not reimplement Gitea's glob matcher, so whether 'review-verdict/h10' is required on this base cannot be derived here (ersatztv#778). Confirm it in the repo's branch-protection settings, or merge immediately instead of scheduling." ;;
|
|
none) bp_code=nomatch; bp="" ;;
|
|
*) bp_code=unreadable-rules; bp="" ;;
|
|
esac
|
|
else
|
|
# A 200 whose body is NOT an array never reaches the classifier — it is diverted by the array
|
|
# gate above — so it needs the same sentinel, or the generic ask below reports
|
|
# "HTTP '200' — Gitea unreachable" about a read that plainly succeeded. Same defect as the
|
|
# throw-inside-the-classifier arm, one branch earlier; fixing only the arm where it was noticed
|
|
# is the twin-missed shape this PR is largely about.
|
|
if [ "$bp_code" = "200" ]; then
|
|
bp_code=unreadable-rules
|
|
else
|
|
bp_code=${bp_code:-000} # a real transport/HTTP failure -> the ask arm below
|
|
fi
|
|
bp=""
|
|
fi
|
|
rm -f "$bp_file"
|
|
# `nomatch` is the CLASSIFIER's verdict, deliberately not an HTTP code. Reusing 404 for it made
|
|
# this deny reachable from an HTTP 404 on the list read too — repo not found, or invisible to the
|
|
# credential, which Gitea also answers 404 — and then the reason claimed "the full rule list was
|
|
# read and none matches" about a read that never happened. A transport failure must reach the ask
|
|
# below, not a deny stating a finding.
|
|
if [ "$bp_code" = "nomatch" ]; then
|
|
decide deny "H6/H10 merge gate: BLOCKED — no branch-protection rule on this repo can govern '$base_ref' (the full rule list was read and none matches), so 'review-verdict/h10' is not a required check on it. A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622). Restore branch protection on '$base_ref', or merge immediately (without merge_when_checks_succeed) once CI is green."
|
|
fi
|
|
# `unreadable-rules` is the CLASSIFIER failing on a 200 it could not parse — a numeric
|
|
# `branch_name` makes jq throw, and `//` does not catch it because it fires only on null/false.
|
|
# It gets its own sentinel for the same reason `nomatch` does: reporting "HTTP '000' — Gitea
|
|
# unreachable" about a successful 200 read states a cause that did not happen, which is the defect
|
|
# fixed one arm over for the deny.
|
|
if [ "$bp_code" = "unreadable-rules" ]; then
|
|
decide ask "H6/H10 merge gate: this repo's branch-protection rules came back in a shape this hook could not parse, so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Check the rules manually, or merge immediately instead of scheduling."
|
|
fi
|
|
if [ "$bp_code" != "200" ] || [ -z "${bp//[[:space:]]/}" ] || ! printf '%s' "$bp" | jq -e 'type == "object"' >/dev/null 2>&1; then
|
|
decide ask "H6/H10 merge gate: could not read this repo's branch-protection rules (HTTP '${bp_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether 'review-verdict/h10' is required on '$base_ref' is unknown. Scheduling an auto-merge is only safe while 'review-verdict/h10' is a REQUIRED check there (ersatztv#622) — confirm that manually, or merge immediately instead of scheduling."
|
|
fi
|
|
# The membership test is `any(.[]; . == …)` over a value FIRST PROVEN to be an array of strings —
|
|
# never `index()`. `index` on a STRING is substring search, so a `status_check_contexts` that
|
|
# arrived as the string "prefix-review-verdict/h10-suffix" would answer "yes" and auto-grant a
|
|
# merge on a base where no such context is required. That is a FALSE-OPEN in the gate, reachable
|
|
# from any payload shape drift, and it is the direction that matters: a false-closed costs a
|
|
# prompt, a false-open costs an unreviewed merge.
|
|
#
|
|
# Validating `$bp` as an object does not make its MEMBERS well-formed, which is the same
|
|
# one-level-down swallow that survived the first fix in the #632 base-change guard — the
|
|
# validation domain has to match the CONSUMPTION domain, not stop at the top-level type. So the
|
|
# shape is checked explicitly and anything else becomes "unknown" rather than a decision.
|
|
#
|
|
# `null` and `[]` are legitimate (an unprotected-in-practice branch) and answer "no", not
|
|
# "unknown": absent IS the finding here, not a read failure. The word is then matched
|
|
# exhaustively, because "" is not a third synonym for "no".
|
|
# `// []` defaults on FALSE as well as on null, because jq's alternative operator fires for both.
|
|
# So `"status_check_contexts": false` — a malformed shape — became `[]` and answered "no", i.e. a
|
|
# confident DENY derived from a payload that was never understood. Absent and null are defaulted
|
|
# explicitly; every other non-array is "unknown".
|
|
# `enable_status_check` is validated as a BOOLEAN before it is trusted, for the same reason the
|
|
# contexts list is: `"true"` (the string) is not `true`, and comparing it to `true` yields a
|
|
# confident "no" -> deny derived from a payload never understood. Every malformed shape on this
|
|
# endpoint has to reach the same "unknown" -> ask arm, or the tri-state is only two states.
|
|
guarded=$(printf '%s' "$bp" \
|
|
| jq -r 'def ctxs: if (has("status_check_contexts") | not) or .status_check_contexts == null
|
|
then [] else .status_check_contexts end;
|
|
if (.enable_status_check | type) != "boolean" then "unknown"
|
|
elif (ctxs | type) != "array" or any(ctxs[]; type != "string") then "unknown"
|
|
elif (.enable_status_check == true) and any(ctxs[]; . == "review-verdict/h10") then "yes"
|
|
else "no" end' 2>/dev/null || true)
|
|
case "$guarded" in
|
|
yes) : ;;
|
|
no) decide deny "H6/H10 merge gate: BLOCKED — 'review-verdict/h10' is NOT a required status check on '$base_ref' (branch protection reports enable_status_check/status_check_contexts without it). A scheduled auto-merge is safe ONLY because that per-sha required check stops a commit pushed after scheduling from merging unreviewed (ersatztv#622); without it, arming merge_when_checks_succeed freezes consent at a head Gitea may not be the one to merge. Restore it in branch protection, or merge immediately (without merge_when_checks_succeed) once CI is green." ;;
|
|
*) decide ask "H6/H10 merge gate: branch protection for '$base_ref' came back in an unexpected shape, so the 'review-verdict/h10' required check that makes a scheduled auto-merge safe (ersatztv#622) could not be confirmed either way. Check it manually, or merge immediately instead of scheduling." ;;
|
|
esac
|
|
fi
|
|
|
|
# --- (c) Review-verdict freshness (ersatztv#303 H10): a review-verdict comment must reference the
|
|
# CURRENT head sha, so the latest commit is proven-reviewed (ersatztv#242: re-review the fix
|
|
# commit, not just the initial diff). Graceful adoption mirrors (b): a verdict comment that
|
|
# references head must be positive -> allow; one that exists only for an OLDER commit -> deny
|
|
# (the stale-review failure mode); NO verdict comment at all -> ask (convention not yet used).
|
|
[ -n "$sha" ] || decide ask "H10 merge gate: could not resolve PR #$pr head sha to verify a review verdict. Confirm the review covered the latest commit before merging."
|
|
short=${sha:0:7}
|
|
comments=$(gq "repos/$owner/$repo/issues/$pr/comments?limit=100")
|
|
if [ -z "$comments" ]; then
|
|
decide ask "H10 merge gate: could not fetch PR #$pr comments to verify a head-referencing review verdict ($short). Confirm the adversarial/Codex review covered the latest commit before merging."
|
|
fi
|
|
# Classification is delegated to `scripts/check-review-verdict.sh` — the single source of truth for
|
|
# the H10 grammar, extracted in #629 so it could be TESTED. While it lived here it had none, and three
|
|
# false-opens survived in it: a prefix-matched token (`MERGEABLE-LATER` graded positive), a verdict
|
|
# inside a fenced code block (documentation showing the convention counted as a real verdict), and a
|
|
# sha taken from the first `@<hex>` anywhere on the line (a markdown link could supply it). Every
|
|
# decision the classifier makes is documented there; this file only maps a class onto a hook decision.
|
|
verdict_script="${CLAUDE_PROJECT_DIR:-.}/scripts/check-review-verdict.sh"
|
|
if [ ! -x "$verdict_script" ]; then
|
|
decide ask "H10 merge gate: verdict classifier not found at $verdict_script, so the review state can't be derived. Confirm the review covered the latest commit before merging."
|
|
fi
|
|
# An input error (exit 2) is NOT a classification — fall through to a human rather than guessing.
|
|
if ! class=$(printf '%s' "$comments" | "$verdict_script" --head "$sha" 2>/dev/null); then
|
|
decide ask "H10 merge gate: could not classify the review verdicts on PR #$pr (malformed comments payload or unreadable head). Confirm the review covered the latest commit ($short) before merging."
|
|
fi
|
|
|
|
case "$class" in
|
|
negative)
|
|
# A negative verdict on head wins over a positive one (a later BLOCKED retracts an earlier
|
|
# MERGEABLE on the SAME head; if the head were fixed the sha would change, so this can't
|
|
# wrongly block).
|
|
decide deny "H10 merge gate: BLOCKED — a review verdict for the current head ($short) is negative (BLOCKED/NOT-MERGEABLE). Resolve the findings and post a fresh 'Review-verdict: MERGEABLE @ $short' before merging PR #$pr." ;;
|
|
stale)
|
|
decide deny "H10 merge gate: BLOCKED — a review-verdict comment references an older commit, not the current head ($short). The latest commit(s) are unreviewed (ersatztv#242: re-review the fix commit, not just the initial diff). Re-review the head and post 'Review-verdict: MERGEABLE @ $short'." ;;
|
|
unknown)
|
|
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr uses an unrecognized verdict token (not MERGEABLE/APPROVED/LGTM/BLOCKED/NOT-MERGEABLE). It is deliberately NOT read as approval. Post a verdict using the documented vocabulary — e.g. 'Review-verdict: MERGEABLE @ $short'." ;;
|
|
no-sha)
|
|
# Marker(s) exist but reference no sha at all -> ask (don't mislabel as a stale older-commit review).
|
|
decide ask "H10 merge gate: a 'Review-verdict:' comment on PR #$pr references no commit sha in its own '@ <sha>' field. Post one referencing the current head ($short) — e.g. 'Review-verdict: MERGEABLE @ $short' — or confirm the review covered the latest commit and approve." ;;
|
|
absent)
|
|
decide ask "H10 merge gate: no 'Review-verdict:' comment found on PR #$pr referencing head $short. Post the adversarial/Codex verdict (e.g. 'Review-verdict: MERGEABLE @ $short'), or confirm the review covered the latest commit and approve." ;;
|
|
positive) : ;;
|
|
*)
|
|
decide ask "H10 merge gate: unrecognized verdict classification '$class' for PR #$pr. Confirm the review covered the latest commit ($short) before merging." ;;
|
|
esac
|
|
|
|
# --- (d) Guard-scope freshness (ersatztv#787): the committed mirror of `main`'s required status
|
|
# checks must still match the server. ------------------------------------------------------
|
|
# ORDERED LAST, and that is a severity argument rather than a stylistic one. Every check above
|
|
# can DENY; this one can only ever downgrade an otherwise-satisfied auto-grant to a prompt. Run
|
|
# earlier it would preempt those verdicts and report a stale guard scope at a reader whose merge
|
|
# is blocked for a completely different and more serious reason, and it would ask on payloads the
|
|
# checks above are about to reject anyway. Placed here it is also PAST the point where the two
|
|
# merge paths converge, so it covers both without duplicating anything.
|
|
# `scripts/tests/test_ci_dropped_step_guard.py` DERIVES which jobs must carry per-step execution
|
|
# markers from `.gitea/required-status-contexts.json`, because its CI job checks out with
|
|
# `persist-credentials: false` and cannot ask Gitea. That makes the snapshot the single
|
|
# hand-maintained input in the chain: a fourth required context added on the server leaves the
|
|
# snapshot — and therefore the guard's scope — silently behind, which is the whole of #787.
|
|
#
|
|
# THIS RUNS ON BOTH MERGE PATHS, deliberately, and it is placed here rather than beside the
|
|
# branch-protection read in the scheduled-auto-merge branch for that reason.
|
|
#
|
|
# WHAT IT DOES NOT COVER, said here rather than left to be discovered: a PR whose changed files are
|
|
# all docs/process — `.gitea/` included — exits at the docs-only passthrough far above, so this arm
|
|
# never runs for it. A PR that edits ONLY `.gitea/required-status-contexts.json` is docs-only BY
|
|
# CONSTRUCTION, and that is exactly the snapshot-NARROWING direction the decision record names as
|
|
# this design's residual. Excluding that path from the allow-list would not buy the protection it
|
|
# looks like it would: this arm compares the live server against the snapshot in the LOCAL CHECKOUT,
|
|
# not against the version the PR proposes, so it cannot see a narrowing that has not landed yet.
|
|
# What does hold is that the passthrough is a passthrough — a human prompt, never an auto-grant —
|
|
# which is the `.gitea/` treatment ersatztv#317 asked for. That read is inside
|
|
# `else` (mwcs = true) and never executes on an immediate merge, which is the common case; hanging
|
|
# the freshness check off it would fire it only when an auto-merge is armed. This file already
|
|
# records that exact defect one section up — the base re-read "first landed inside the
|
|
# scheduled-auto-merge branch only", and cold review found scheduled+retarget denied while
|
|
# immediate+retarget auto-GRANTED. Same shape, so it is not repeated here.
|
|
#
|
|
# It reads `main` (the branch the snapshot names), NOT `$base_ref`. That is a DIFFERENT question
|
|
# from the one the scheduled branch asks — "is review-verdict/h10 required on the base I am merging
|
|
# into" — so this is not a second copy of that classifier and the two cannot drift into disagreeing:
|
|
# they consume different fields of different rules for different decisions.
|
|
#
|
|
# ASK, NEVER DENY. Drift does not make THIS merge unsafe: Gitea enforces the live required set
|
|
# server-side, so a newly required context with no status blocks the merge on its own. What has gone
|
|
# stale is a guard's scope — a different artifact, on a different clock. Denying would state
|
|
# something false about the change in front of the reader. Every non-`match` class asks, so a
|
|
# comparison that could not be made is surfaced rather than skipped (`unknown` is not `fine`).
|
|
# ONE base for both the checker and the snapshot, and it is `$repo_root` — derived from this file's
|
|
# own location — rather than `$CLAUDE_PROJECT_DIR`. Two reasons, and the second is the load-bearing
|
|
# one. Resolving them from different roots would let the hook classify one checkout's snapshot with
|
|
# another checkout's script, mismatched halves of a comparison whose whole job is to detect a
|
|
# mismatch. And an ENV VAR is not a sound input to a security decision: a wrong value pointing at a
|
|
# tree that happens to contain an executable checker returns `match` about a different checkout
|
|
# entirely, which silently authorizes the grant. A missing path only asks, so the failure is quiet
|
|
# exactly where it is worst.
|
|
ctx_base="$repo_root"
|
|
ctx_snapshot="$ctx_base/.gitea/required-status-contexts.json"
|
|
ctx_script="$ctx_base/scripts/check-required-contexts.sh"
|
|
|
|
# THIS ARM IS ABOUT ONE REPO, and the merge tool is not. Every other check here reads
|
|
# `$owner/$repo` from the tool input and is repo-agnostic; this one compares a HARDCODED branch
|
|
# against a snapshot committed in THIS checkout. Merging a PR in another repo from a session opened
|
|
# here would otherwise weigh that repo's live contexts against this repo's mirror and report a
|
|
# confident, flatly false finding about it — measured: server-management returns `[]`, which
|
|
# classifies as `nomatch`. So the snapshot names the repo it describes and the arm runs only for it.
|
|
# An unreadable snapshot cannot answer "is this my repo?" either, so it asks rather than skipping.
|
|
ctx_repo=$(jq -r 'if (.repo | type) == "string" then .repo else "" end' "$ctx_snapshot" 2>/dev/null || true)
|
|
if [ -z "$ctx_repo" ]; then
|
|
decide ask "H6 merge gate: $ctx_snapshot is missing, unreadable, or names no \`repo\`, so the dropped-step guard's scope could not be checked against branch protection — nor could it be established whether this snapshot even describes $owner/$repo (ersatztv#787). Restore the file, or check the required checks manually."
|
|
fi
|
|
# CASE-FOLDED, because Gitea resolves owner/repo case-insensitively: verified live, both
|
|
# `/repos/timothy/ersatztv` and `/repos/TIMOTHY/ErsatzTV` answer 200. A byte-exact compare would let
|
|
# any case variant sail through every other arm and SKIP this one, so drift would go unreported with
|
|
# no ask — the gate failing open on a spelling. The hook already treats case folding as
|
|
# decision-relevant one section up, where `MAIN` vs `main` makes the governing rule undecidable.
|
|
ctx_repo_fold=$(printf '%s' "$ctx_repo" | tr '[:upper:]' '[:lower:]')
|
|
target_repo_fold=$(printf '%s' "$owner/$repo" | tr '[:upper:]' '[:lower:]')
|
|
if [ "$ctx_repo_fold" = "$target_repo_fold" ]; then
|
|
if [ ! -x "$ctx_script" ]; then
|
|
decide ask "H6 merge gate: the required-contexts checker is missing or not executable at $ctx_script, so whether the dropped-step guard's scope still matches branch protection on 'main' could not be derived (ersatztv#787). Check it manually, or restore the script."
|
|
fi
|
|
bpf=$(mktemp) || decide ask "H6 merge gate: could not allocate a temp file to read branch protection for the guard-scope freshness check (ersatztv#787)."
|
|
if [ -n "${ETV_GITEA_TOKEN:-}" ]; then
|
|
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -H "Authorization: token $ETV_GITEA_TOKEN" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
|
else
|
|
ctx_code=$(curl -s -o "$bpf" -w '%{http_code}' -u "$ETV_GITEA_BASICAUTH" "$base_url/repos/$owner/$repo/branch_protections" 2>/dev/null || true)
|
|
fi
|
|
if [ "$ctx_code" = "200" ]; then
|
|
# stderr is KEPT, not sent to /dev/null. The checker exits 2 with a diagnostic on a usage error —
|
|
# an unreadable snapshot, a branch mismatch, a missing classifier — and discarding it made all of
|
|
# those arrive at the operator as the catch-all's "returned 'nothing'", which names no cause. That
|
|
# is the same states-a-cause-that-did-not-happen shape this arm was careful about elsewhere.
|
|
ctx_class=$("$ctx_script" --branch main --snapshot "$ctx_snapshot" < "$bpf" 2>"$bpf.err" || true)
|
|
ctx_diag=$(tr '\n' ' ' < "$bpf.err" 2>/dev/null | cut -c1-300 || true)
|
|
else
|
|
ctx_class=readfail
|
|
ctx_diag=""
|
|
fi
|
|
rm -f "$bpf" "$bpf.err"
|
|
case "$ctx_class" in
|
|
match) : ;;
|
|
drift)
|
|
decide ask "H6 merge gate: the required status checks on 'main' no longer match .gitea/required-status-contexts.json (ersatztv#787). scripts/tests/test_ci_dropped_step_guard.py derives its marked-job scope from that snapshot, so until it is reconciled a required context may have NO dropped-step guard — a step the runner drops would conclude success and take that check green having done no work (ersatztv#756). Re-read the live list and update the snapshot in a PR (the guard will then demand markers for any newly required job, or an ACCOUNTED_ELSEWHERE entry naming what covers it). This does not make the merge in front of you unsafe — Gitea enforces the live required set server-side — so approve if you have judged it unrelated." ;;
|
|
nomatch)
|
|
decide ask "H6 merge gate: no branch-protection rule governs 'main' at all, so the required status checks the dropped-step guard scopes itself to could not be confirmed (ersatztv#787). Branch protection on 'main' is what makes 'review-verdict/h10' load-bearing (ersatztv#743) — check it before merging." ;;
|
|
undecidable)
|
|
decide ask "H6 merge gate: a glob branch-protection rule could govern 'main', so which rule's required contexts to compare against .gitea/required-status-contexts.json is not derivable without reimplementing Gitea's matcher (ersatztv#787). Confirm the required checks manually." ;;
|
|
unreadable)
|
|
decide ask "H6 merge gate: branch protection for 'main', or .gitea/required-status-contexts.json itself, came back in a shape the required-contexts checker could not consume, so whether the dropped-step guard's scope is still current is unknown (ersatztv#787). Check the rules and the snapshot manually." ;;
|
|
readfail)
|
|
decide ask "H6 merge gate: could not read branch protection for the guard-scope freshness check (HTTP '${ctx_code:-none}' — Gitea unreachable, or these credentials lack the repo-admin scope that endpoint needs), so whether .gitea/required-status-contexts.json is still current is unknown (ersatztv#787). Confirm the required checks on 'main' manually." ;;
|
|
*)
|
|
decide ask "H6 merge gate: the required-contexts checker returned '${ctx_class:-nothing}', which is not a class this hook understands, so the dropped-step guard's scope could not be confirmed against branch protection (ersatztv#787).${ctx_diag:+ It said: ${ctx_diag}}Check scripts/check-required-contexts.sh." ;;
|
|
esac
|
|
fi # end of the guard-scope freshness arm (opened at `if [ "$ctx_repo_fold" = ... ]` above). The
|
|
# body is left unindented to match the rest of this file, which is flat throughout; the marker
|
|
# is here because the block is long enough that its extent is otherwise easy to misread.
|
|
|
|
if [ "$class" = "positive" ]; then
|
|
# (a) CI + (b) all Done-when ticked + (c) positive verdict @ current head -> SATISFIED. Auto-grant.
|
|
# The reason string must not claim more than was actually checked: on the merge_when_checks_succeed
|
|
# path this hook never read the CI status at all (it is delegated to Gitea), so saying "CI green"
|
|
# there was a plain falsehood in the one message a human reads to decide whether to trust the gate.
|
|
if [ "$mwcs" = "true" ]; then
|
|
decide grant "H6/H10 merge gate: satisfied — all Done-when boxes ticked, and both a positive Review-verdict comment and the 'review-verdict/h10' status cover the current head ($short). CI is gated by Gitea (merge_when_checks_succeed). A commit pushed before Gitea merges clears the sha-bound verdict status and is blocked by the 'review-verdict/h10' required check (ersatztv#622) — which this hook has just CONFIRMED is still required on '$base_ref' — read from the repo's full rule list and matched with Gitea's own plain-vs-glob split, refusing rather than guessing wherever precedence or folding is not derivable. That guarantee holds while that branch protection stands; if it is weakened after this check, nothing here would see it (ersatztv#778). Auto-granted."
|
|
fi
|
|
decide grant "H6/H10 merge gate: satisfied — CI green, all Done-when boxes ticked, and a positive Review-verdict references the current head ($short). Auto-granted (no separate confirmation needed)."
|
|
fi
|
|
|
|
# Unreachable: the `case` above exits on every class, and `positive` exits in the block above. Kept as
|
|
# a fail-safe so a future class added to the classifier without a branch here cannot fall off the end
|
|
# of the script (which would exit 0 = silent passthrough, the one outcome a gate must never produce).
|
|
decide ask "H10 merge gate: verdict classification for PR #$pr produced no decision. Confirm the review covered the latest commit ($short) before merging."
|