diff --git a/CLAUDE.md b/CLAUDE.md index f50b701..8489d19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,36 @@ sensitive path, so the policy cannot be widened or narrowed without a human. That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a missing credential is a lock-out, here a skip would be a bypass. + +**The administrator override (2026-10-01).** The rule above was unsatisfiable for the one human +who runs this repo: Gitea refuses to let an author approve their own PR, and `jakman85` is the +only collaborator — so a sensitive-path PR the operator opened could never be merged, *including +the PR that would fix that.* An administrator may therefore waive the review by posting this on +the PR: + + /approve-override + +**Post it as a PR *review* of type "Comment", not in the plain comment box.** CI's +`SETTINGS_API_TOKEN` is minted `[read:repository, read:admin]`, and `/issues/{n}/comments` sits +behind Gitea's *issue* scope — so an override typed into the comment box is **invisible to CI**, +and the stage reports it as a missing approval rather than as a permission problem (measured on +PR #38, 2026-10-02). `/pulls/{n}/reviews` is readable, so a review body works today. Both sources +are accepted and the gate names which it used; the comment box starts working once +`infra/gitea/set-ci-token.sh` is re-run to pick up `read:issue`. + +The directive must **start a line**, the **reason is mandatory**, the poster's `admin`/`owner` +permission is re-checked **live** when the gate runs, and it must be **newer than the +head commit** — push again and you must override again. The overrider, the timestamp and the +reason are printed in the gate log and the job summary. For an administrator, "a change to the +gate cannot be gated by the gate" is therefore **advisory, not structural**; what is kept is an +attributed record of each waiver. Note that the agent account `gitea-admin` also holds `owner` +here, so an agent *can* clear this on its own PR — **do not**: posting that directive to unblock +your own agent work is exactly the self-approval the stage exists to prevent. Get the operator +to post it, or move the change out of the sensitive paths. + +**`required_approvals` on `main` is `0`**, not 1 — the gate is the approver. That drops the +approval *count* only: `block_admin_merge_override` and `enable_status_check` both remain `true`, +so **a red gate is unmergeable by everyone, administrators included.** - **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`; anything applied by hand is drift that the next reconcile reverts or, worse, adopts. @@ -53,12 +83,19 @@ Verified against the live Gitea on 2026-09-29: | `main` protected, direct push rejected | ✅ `protected: true`, `user_can_push: false` — **verified by a real rejected push**, not by the API's word | | PR blocked until the gate passes | ✅ `enable_status_check: true`, context `gate / gate (pull_request)` | | Gate runs on every PR | ✅ runner `incluster-dind` online; ~1m35s | -| Approval required before merge | ✅ `required_approvals: 1` | +| Gate cannot be overridden, by anyone | ✅ `block_admin_merge_override: true` | +| Human approval required before merge | ⬜ **No — `required_approvals: 0` since 2026-10-01.** The gate is the approver; sensitive paths are gated by `ci/approval-policy.sh` instead. See §1 | -All four were FALSE as recently as 2026-09-29, while this repo's own log asserted they were +The first three were FALSE as recently as 2026-09-29, while this repo's own log asserted they were true. They are true now because each was measured, and the push rejection was provoked rather than assumed. **Re-measure before relying on any of them** — see §2b. +The fourth row deliberately changed on 2026-10-01. `required_approvals: 1` was a **lock-out**, not +a protection: with a single collaborator and Gitea refusing author self-approval, nothing could +satisfy it (measured on PR #35 — green gate, zero reviews, unmergeable). It also contradicted the +model already written into `ci/approval-policy.sh` and `ci/gate.sh`. Rationale and the live-change +script: `infra/gitea/allow-admin-self-merge.sh`. + Still not covered: `vars.CLUSTER_AVAILABLE` is unset, so **L2/L3 conformance does not run in CI**. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour. diff --git a/ci/approval-override.py b/ci/approval-override.py new file mode 100755 index 0000000..400221a --- /dev/null +++ b/ci/approval-override.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""ci/approval-override.py — select the admin-override on a PR, if there is a valid one. + +This script decides NOTHING about authority. It parses a PR's comments and reviews and returns +the single override *candidate* that ci/approval-policy.sh should then try to authorise against +the live Gitea permission API. Keeping the two apart is the point: + + * parsing is pure, offline, and therefore TESTABLE (ci/tests/approval-override.test.sh); + * authorisation needs the API and must stay in the stage that fails closed. + +A candidate is a comment OR a review whose body contains, AT THE START OF A LINE, the directive +followed by a non-empty reason: + + /approve-override the gate cannot gate itself and I am the only human reviewer + +TWO SOURCES, AND WHY THAT IS NOT BELT-AND-BRACES. Measured 2026-10-02 on PR #38: the override was +posted as an ordinary PR comment, was well-formed, was newer than the head commit — and the stage +still blocked, because CI could not READ it. `SETTINGS_API_TOKEN` is minted with scopes +`[read:repository, read:admin]`, and Gitea puts /issues/{n}/comments behind the *issue* scope, so +the request came back as an error OBJECT rather than an array. `/pulls/{n}/reviews` is under +read:repository and was readable in the same run. So: + + * a REVIEW body works with the token CI has today (leave a review of type "Comment"); + * a COMMENT body works once the token carries read:issue (see infra/gitea/set-ci-token.sh). + +Accepting both means the hatch is not hostage to one API scope. A source that cannot be read +contributes no candidates and is reported on stderr; it can only cause a false BLOCK, never a +false pass, which is why degrading it is safe and blocking on it was not necessary. + +WHY EACH RULE IS HERE + + * START OF A LINE, not "anywhere in the body". Otherwise quoting someone else's override + ("you could just /approve-override this") re-arms it. Lines beginning with '>' are DROPPED + rather than unquoted, for exactly that reason. + * A REASON IS MANDATORY. A bare directive records that a review was waived but not why, which + is the difference between an audit trail and a rubber stamp. + * STALE OVERRIDES ARE DISCARDED. An override earned at commit A must not silently cover commit + B. Any candidate created at or before the head commit's timestamp is dropped — the same + semantics Gitea's own `dismiss_stale_approvals` applies to approvals. Push new commits and + you must re-override. + * A PENDING REVIEW IS NOT SUBMITTED. It is a private draft visible only to its author, so it is + not an act of anything and is dropped. + * THE LATEST CANDIDATE WINS, across both sources, so a later override supersedes an earlier one. + +Output: one TSV line (login, timestamp, source, reason) on stdout, or nothing at all when there is +no valid candidate. Never exits non-zero for "no override" — absence is a normal answer and the +caller must not be able to confuse it with a parse crash. + +Input, all via env so nothing lands in `ps`: + COMMENTS_JSON the Gitea /issues/{n}/comments array (or an error object; or absent) + REVIEWS_JSON the Gitea /pulls/{n}/reviews array (or an error object; or absent) + HEAD_TIME ISO-8601 timestamp of the PR head commit + DIRECTIVE the override directive, e.g. /approve-override +""" +import json +import os +import re +import sys +from datetime import datetime, timezone + + +def parse_ts(value): + """Gitea emits RFC3339. Return an aware datetime, or None if it is unparseable. + + Unparseable is NOT 'treat as fresh': the caller drops such a candidate, because a + timestamp we cannot read is a staleness check we cannot perform. + """ + if not value: + return None + text = str(value).strip() + # Python < 3.11 cannot read a trailing 'Z' in fromisoformat. + if text.endswith(("Z", "z")): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def extract_reason(body, directive): + """Return the reason from the first line that STARTS with the directive, else None.""" + pattern = re.compile( + r"^[ \t]*" + re.escape(directive) + r"(?=[ \t:]|$)[ \t:]*(.*)$" + ) + for raw in (body or "").splitlines(): + # A quoted line is somebody repeating an override, not casting one. + if raw.lstrip().startswith(">"): + continue + match = pattern.match(raw) + if match: + reason = match.group(1).strip() + return reason or None + return None + + +def load_source(env_name, label): + """Return a list of items for one source, or [] with a stderr note if it is unusable.""" + raw = os.environ.get(env_name) + if not raw or not raw.strip(): + return [] + try: + parsed = json.loads(raw) + except (ValueError, TypeError): + print( + "approval-override: %s is not valid JSON — ignoring that source" % label, + file=sys.stderr, + ) + return [] + if isinstance(parsed, list): + return parsed + # An error object, typically {"message": "token does not have ... scope"}. Say which, + # because "no override found" and "could not read the overrides" look identical from + # the outside and the first wasted a real debugging session. + detail = "" + if isinstance(parsed, dict): + detail = str(parsed.get("message") or parsed.get("errors") or "")[:200] + print( + "approval-override: %s is not a list (Gitea returned %s%s) — ignoring that source" + % (label, type(parsed).__name__, ": " + detail if detail else ""), + file=sys.stderr, + ) + return [] + + +def candidates_from(items, source, directive, head_time): + """Yield (when, login, reason, raw_when, source) for every valid override in one source.""" + found = [] + for item in items: + if not isinstance(item, dict): + continue + login = ((item.get("user") or {}).get("login") or "").strip() + reason = extract_reason(item.get("body"), directive) + if not login or not reason: + continue + # A PENDING review is an unsubmitted private draft — not an act of anything. + state = str(item.get("state") or "").upper() + if source == "review" and state == "PENDING": + print( + "approval-override: dropping PENDING (unsubmitted) review by '%s'" % login, + file=sys.stderr, + ) + continue + raw_when = ( + item.get("submitted_at") or item.get("created_at") or item.get("updated_at") or "" + ) + when = parse_ts(raw_when) + if when is None: + print( + "approval-override: dropping %s override by '%s' — unreadable timestamp %r" + % (source, login, raw_when), + file=sys.stderr, + ) + continue + # An override must post-date the commit it covers. + if head_time is not None and when <= head_time: + print( + "approval-override: dropping STALE %s override by '%s' (%s) — not newer than " + "head commit (%s). Re-post it to override the current commit." + % (source, login, when.isoformat(), head_time.isoformat()), + file=sys.stderr, + ) + continue + found.append((when, login, reason, raw_when, source)) + return found + + +def main(): + directive = os.environ.get("DIRECTIVE", "/approve-override").strip() + head_time = parse_ts(os.environ.get("HEAD_TIME", "")) + + found = [] + found += candidates_from( + load_source("COMMENTS_JSON", "COMMENTS_JSON"), "comment", directive, head_time + ) + found += candidates_from( + load_source("REVIEWS_JSON", "REVIEWS_JSON"), "review", directive, head_time + ) + + if not found: + return 0 + + # Latest wins, across both sources: a later override supersedes an earlier one. + when, login, reason, raw_when, source = max(found, key=lambda c: c[0]) + # Keep the reason on one line — this becomes a TSV field. + reason = " ".join(reason.split()) + print("%s\t%s\t%s\t%s" % (login, raw_when, source, reason)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/approval-policy.sh b/ci/approval-policy.sh index 8b1d978..9855ebb 100755 --- a/ci/approval-policy.sh +++ b/ci/approval-policy.sh @@ -25,10 +25,45 @@ # true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a # BYPASS, not a gap. So: cannot determine -> BLOCK. # +# THE ADMIN OVERRIDE (added 2026-10-01), and the lock-out it clears. +# +# The rule above was UNSATISFIABLE for the one human who runs this repo. Gitea refuses to let a +# pull request's author approve their own PR, and `jakman85` is the only collaborator — so "an +# APPROVED review from someone who is not its author" is a condition the sole administrator can +# never meet. Every sensitive-path PR they opened was permanently unmergeable, INCLUDING the PR +# that would fix it. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, blocked. +# +# So an administrator may waive the review by COMMENTING on the PR: +# +# /approve-override +# +# Authority comes from the LIVE Gitea permission API, never from the comment — the commenter must +# hold a permission listed in `.override.authorized_permissions` (admin/owner) at the moment the +# gate runs, so an old comment from a since-demoted account is worth nothing. The directive must +# start a line, a reason is mandatory, and the comment must POST-DATE the head commit: an override +# earned at commit A does not cover commit B. Choosing the candidate comment is the job of the +# pure, offline, unit-tested ci/approval-override.py; AUTHORISING it is the job of this stage, +# which fails closed. +# +# WHAT THIS COSTS, STATED PLAINLY. For an administrator, "a change to the gate cannot be gated by +# the gate" stops being structural and becomes advisory. That is the unavoidable consequence of +# there being one human; the alternative is a repository its owner cannot change. What is bought +# back is a RECORD — the waiver is an explicit act, attributed to an account, carrying a stated +# reason, in the PR history and in this stage's output. A silent bypass keyed on "is the author an +# admin?" would have been less code and is exactly the fail-open shape this repo keeps finding. +# +# AND THE PART THAT IS NOT COMFORTABLE. Authorisation is by permission ALONE, with no allowlist of +# human accounts (operator decision, 2026-10-01, taken with this consequence on the table). The +# AGENT account `gitea-admin` also reports permission `owner` here, so an agent can clear the +# sensitive-path review on its own PR by posting the directive — and agents author most PRs in +# this repo, so that is the common case, not an edge case. To narrow it without touching this +# logic, add an `override_admins` list to infra/gitea/approval-policy.json: the check below +# already requires membership whenever that key is present. +# # EXIT CODES -# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a -# non-author APPROVED review is present -# 1 sensitive paths touched without such a review, OR the policy could not be evaluated +# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and +# either a non-author APPROVED review or a valid admin override is present +# 1 sensitive paths touched with neither, OR the policy could not be evaluated set -euo pipefail CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)" @@ -65,6 +100,17 @@ api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; } FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)" PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)" REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)" +# PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review +# (diff) comments ONLY, so an override posted in the normal comment box would never appear in it. +# Verified against this Gitea (1.24.3) rather than assumed. +# +# This one is NOT in the must-parse loop below, and that is deliberate. /issues/{n}/comments sits +# behind Gitea's *issue* token scope, which `SETTINGS_API_TOKEN` does not carry (it is minted +# `[read:repository, read:admin]`) — so it returns an error OBJECT, not an array. Blocking on that +# turned "CI cannot read your override" into a message that said "you did not post one", and cost a +# real debugging session on PR #38. It is handled explicitly, and loudly, further down: an +# unreadable source can only cause a false BLOCK, never a false pass. +COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)" for v in FILES_JSON PR_JSON REVIEWS_JSON; do jq -e . >/dev/null 2>&1 <<<"${!v}" || { err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming." @@ -107,11 +153,122 @@ if [ -n "$APPROVER" ]; then exit 0 fi +# ───────────────────────────────────────────────────────────────────────────────────────── +# No non-author approval exists. Before refusing, look for an administrator's explicit +# override. See the header for why this hatch exists and what it costs. +# ───────────────────────────────────────────────────────────────────────────────────────── +OVERRIDE_ENABLED="$(jq -r '.override.enabled // false' "$DECL")" +DIRECTIVE="$(jq -r '.override.comment_directive // "/approve-override"' "$DECL")" +HEAD_SHA="$(jq -r '.head.sha // empty' <<<"$PR_JSON")" + +if [ "$OVERRIDE_ENABLED" = "true" ]; then + # The head commit's timestamp anchors staleness. Read it from GITEA, not from the local + # tree: CI checks out a merge commit, whose date is not the head commit's date. + HEAD_TIME="" + if [ -n "$HEAD_SHA" ]; then + HEAD_COMMIT_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/git/commits/${HEAD_SHA}" || true)" + HEAD_TIME="$(jq -r '.commit.committer.date // .created // empty' <<<"${HEAD_COMMIT_JSON:-null}" 2>/dev/null || true)" + fi + if [ -z "$HEAD_TIME" ]; then + err "could not read the head commit's timestamp for PR #${PR} — BLOCKING." + err " Without it an override cannot be aged, and an unaged override is one that silently" + err " covers commits nobody waived review on." + summary "- approval-policy: **BLOCKED — head commit timestamp unreadable** (cannot age an override)" + exit 1 + fi + + # Is the comments source actually readable? Say so plainly if not — a scope problem and "you + # never posted an override" are indistinguishable from the outside, and conflating them is what + # made PR #38 look like a code bug. + if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${COMMENTS_JSON:-}"; then + SCOPE_MSG="$(jq -r '.message // empty' <<<"${COMMENTS_JSON:-null}" 2>/dev/null || true)" + warn "cannot read PR COMMENTS from Gitea${SCOPE_MSG:+ — ${SCOPE_MSG}}." + warn " /issues/${PR}/comments needs the *issue* token scope; SETTINGS_API_TOKEN is minted" + warn " [read:repository, read:admin] (infra/gitea/set-ci-token.sh). An override posted as an" + warn " ordinary PR COMMENT is therefore invisible to CI — post it as a REVIEW of type" + warn " 'Comment' instead, which /pulls/${PR}/reviews exposes under read:repository." + summary "- approval-policy: note — PR comments unreadable (token lacks the issue scope); use a **review** to override" + COMMENTS_JSON='[]' + fi + + # Pure, offline, unit-tested selection: which comment or review, if any, is a live override? + OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" REVIEWS_JSON="$REVIEWS_JSON" \ + HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \ + python3 "${CI_DIR}/approval-override.py")" || { + err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override." + summary "- approval-policy: **BLOCKED — override parser failed**" + exit 1; } + + if [ -n "$OVERRIDE_TSV" ]; then + OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")" + OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")" + OV_SRC="$(cut -f3 <<<"$OVERRIDE_TSV")" + OV_WHY="$(cut -f4- <<<"$OVERRIDE_TSV")" + + # AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git. + PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)" + OV_PERM="$(jq -r '.permission // empty' <<<"${PERM_JSON:-null}" 2>/dev/null || true)" + if [ -z "$OV_PERM" ]; then + err "'${OV_USER}' posted ${DIRECTIVE}, but their permission on ${GITEA_REPO} could not be" + err " read — BLOCKING. An override whose authority is unverified is not an override." + summary "- approval-policy: **BLOCKED — cannot verify \`${OV_USER}\`'s permission**" + exit 1 + fi + + # Authorised when the LIVE permission is one of the declared ones and — only if an + # `override_admins` allowlist exists at all — the account is on it. No allowlist means + # permission alone, which is the operator's standing decision; adding the key narrows + # this with no change to any code. + AUTHORISED="$(DECL="$DECL" OV_PERM="$OV_PERM" OV_USER="$OV_USER" python3 -c ' +import json, os +d = json.load(open(os.environ["DECL"])).get("override", {}) +perms = d.get("authorized_permissions") or ["admin", "owner"] +allow = d.get("override_admins") +ok_perm = os.environ["OV_PERM"] in perms +ok_list = True if allow is None else os.environ["OV_USER"] in allow +print("yes" if (ok_perm and ok_list) else "no") +')" + + if [ "$AUTHORISED" = "yes" ]; then + if [ "$OV_USER" = "$AUTHOR" ]; then + warn "ADMIN OVERRIDE — SELF-WAIVED. '${OV_USER}' authored this PR and waived the" + warn " non-author review requirement on their own change. Permitted (permission" + warn " '${OV_PERM}'), deliberate, and shouted about here precisely because it is not free." + else + warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'." + fi + warn " via: PR ${OV_SRC}" + warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})" + warn " reason: ${OV_WHY}" + warn " review waived on: $(printf '%s ' $SENSITIVE)" + ok "sensitive paths cleared by administrator override — this stage does not block." + SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**" + summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`, via PR ${OV_SRC}${SELFNOTE})" + summary " - reason: ${OV_WHY}" + summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)" + exit 0 + fi + + err "'${OV_USER}' posted ${DIRECTIVE} but is NOT authorised to override: live permission is" + err " '${OV_PERM}'. The authorised set is declared in infra/gitea/approval-policy.json." + summary "- approval-policy: **BLOCKED — \`${OV_USER}\` may not override** (permission \`${OV_PERM}\`)" + exit 1 + fi +fi + err "This PR changes the pipeline or the security posture it rests on, and has NO approving" err " review from anyone other than its author ('${AUTHOR}')." err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot" err " honestly approve for itself. Get a human review, or move the change out of:" printf ' %s\n' $SENSITIVE >&2 err " The list lives in infra/gitea/approval-policy.json, with a reason per entry." -summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**" +if [ "$OVERRIDE_ENABLED" = "true" ]; then + err " Or, if you hold admin/owner on this repository, post this on the PR:" + err " ${DIRECTIVE} " + err " as a REVIEW of type 'Comment' (not the plain comment box — CI's token cannot read" + err " /issues/${PR}/comments without the issue scope; see infra/gitea/set-ci-token.sh)." + err " The reason is mandatory, the directive must START a line, and it must be NEWER than" + err " head commit ${HEAD_SHA:0:8} — push again and you must override again." +fi +summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**" exit 1 diff --git a/ci/tests/approval-override.test.sh b/ci/tests/approval-override.test.sh new file mode 100755 index 0000000..4d3364e --- /dev/null +++ b/ci/tests/approval-override.test.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# ci/tests/approval-override.test.sh — proves ci/approval-override.py selects the override it +# claims to select, and REJECTS the ones it claims to reject. +# +# This parser decides whether a human review requirement gets waived. Every rule in it exists +# because the obvious implementation ("does the body contain the directive?") is wrong in a way +# that is invisible until abused: a quoted directive would re-arm an override, a bare directive +# would waive review with no stated reason, and an override posted against commit A would go on +# covering commit B forever. A guard is worth nothing until somebody has watched it refuse, which +# is the same reasoning as ci/tests/decisions-log.test.sh one layer up. +# +# The parser is pure and offline BY DESIGN — authorisation (the live permission lookup) lives in +# ci/approval-policy.sh, which cannot be tested without Gitea. That split is what makes this file +# possible, and it is also why a green run here does NOT prove the stage authorises correctly. +set -uo pipefail +CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SUT="${CI_DIR}/approval-override.py" + +PASS=0 FAIL=0 +ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); } +bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); } + +HEAD_TIME="2026-10-01T12:00:00Z" +AFTER="2026-10-01T12:30:00Z" +BEFORE="2026-10-01T11:30:00Z" + +# run -> stdout of the parser (stderr is diagnostic only) +run() { + COMMENTS_JSON="$1" REVIEWS_JSON="" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \ + python3 "$SUT" 2>/dev/null +} + +# run_rev — the same, but through the REVIEWS source. +run_rev() { + COMMENTS_JSON="" REVIEWS_JSON="$1" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \ + python3 "$SUT" 2>/dev/null +} + +# comment +comment() { + python3 -c ' +import json, sys +print(json.dumps([{"user": {"login": sys.argv[1]}, "created_at": sys.argv[2], "body": sys.argv[3]}])) +' "$1" "$2" "$3" +} + +expect_none() { + local label="$1" out + out="$(run "$2")" + if [ -z "$out" ]; then ok "$label"; else bad "$label (got: ${out})"; fi +} + +expect_user() { + local label="$1" json="$2" want="$3" out got + out="$(run "$json")" + got="$(cut -f1 <<<"$out")" + if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted ${want}, got '${got}')"; fi +} + +expect_reason() { + local label="$1" json="$2" want="$3" out got + out="$(run "$json")" + got="$(cut -f4- <<<"$out")" + if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted '${want}', got '${got}')"; fi +} + +# check