fix(ci): let an administrator merge their own green PR
`required_approvals: 1` on `main` was a lock-out, not a protection. Gitea
refuses to let a PR's author approve their own PR, and `jakman85` is the only
collaborator — so no account in existence could approve a PR the operator
opened, including the PR that would fix that. Measured 2026-10-01 on PR #35:
gate green in 1m41s, reviews `[]`, `required_approvals: 1`, unmergeable.
It was never the documented design either: `ci/approval-policy.sh`'s header and
`ci/gate.sh:194` both already describe the model as "required_approvals drops
to 0 and the GATE decides", and `require-gate.sh`'s own closing note named this
failure and prescribed this remedy. 2026-09-30's entry flagged the 1-vs-0
disagreement as unresolved; this settles it at 0.
Layer 1 — branch protection. required_approvals 1 -> 0 in the declaration, plus
infra/gitea/allow-admin-self-merge.sh to apply it live (target read FROM the
declaration, so the script cannot drift from what the gate checks).
require-gate.sh moves to 0 too; left at 1 it would re-impose the lock-out on its
next run. The narrow choice is deliberate: the other way to unblock an owner is
block_admin_merge_override: false, which would re-open what
block-admin-override.sh was written to close (PRs #5-#9, zero approvals). This
drops the approval COUNT only — block_admin_merge_override and
enable_status_check stay true and are asserted after the PATCH, so a red gate
stays unmergeable by everyone, administrators included.
Layer 2 — ci/approval-policy.sh. Sensitive paths still need a non-author
approval the sole admin cannot get, so an administrator may waive it with an
`/approve-override <reason>` comment: directive must start a line, reason
mandatory, permission re-checked LIVE, invalidated by a new commit, and the
overrider + reason printed in the gate log and job summary. Candidate selection
is the pure, offline ci/approval-override.py (23 unit assertions, all passing);
authorisation stays in the fail-closed stage. Every new failure mode blocks.
The cost, stated not buried: for an administrator, "a change to the gate cannot
be gated by the gate" is now advisory rather than structural. Unavoidable with
one human; what is bought back is an attributed, commit-scoped record instead of
a silent bypass. And the uncomfortable part, accepted as an operator decision:
authorisation is by live permission alone, and the AGENT account `gitea-admin`
also holds `owner` — so an agent can clear its own PR this way. Documented in
approval-policy.json, the stage header, and CLAUDE.md (which tells agents not
to). Narrowing needs no code change: add `override_admins` and the stage already
requires membership.
Adjacent fix, in scope because `make -C ci test` is the only runner for the
suite added here: ci/tests/gate-decision.test.sh hand-maintained a stub list
that "must name EVERY stage gate.sh runs" and omitted `approval-policy` (added
to gate.sh by PR #17), so run_stage hit exit 127 and 15 of 50 assertions failed.
Verified pre-existing on pristine origin/main @ 0c7e8dd. The list is now derived
from gate.sh's own run_stage lines, so the omission cannot recur; the suite goes
15-failed -> 50 passing and `make -C ci test` goes FAILED -> all suites passed.
Toolbox gate: rc 3 UNDETERMINED — every stage green except `gitea-settings`
(no SETTINGS_API_TOKEN locally) and `conformance` (no cluster). NOT a green run.
The live rule is UNCHANGED and still reads 1 — mutating it is an operator
action. Until allow-admin-self-merge.sh is run, ci/gitea-settings.sh will
correctly report DRIFT and this PR will be red.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
948875aad3
commit
8ccfdab180
11 files changed
+836
-17
No files matched your search
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ci/approval-override.py — select the admin-override comment on a PR, if there is a valid one.
|
||||
|
||||
This script decides NOTHING about authority. It parses a PR's comment list 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 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
|
||||
|
||||
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. Markdown quotes are stripped of a
|
||||
leading '>' before matching for the same reason -- a quoted directive must NOT count, so
|
||||
lines beginning with '>' are DROPPED, not unquoted.
|
||||
* 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 reviews. Push new
|
||||
commits and you must re-override.
|
||||
* THE LATEST CANDIDATE WINS, so a second override comment supersedes the first rather than
|
||||
both being live.
|
||||
|
||||
Output: one TSV line (login, created_at, 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
|
||||
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 main():
|
||||
directive = os.environ.get("DIRECTIVE", "/approve-override").strip()
|
||||
head_time = parse_ts(os.environ.get("HEAD_TIME", ""))
|
||||
|
||||
try:
|
||||
comments = json.loads(os.environ.get("COMMENTS_JSON") or "[]")
|
||||
except (ValueError, TypeError):
|
||||
# The caller validated this JSON already; if it is broken here, say so loudly on
|
||||
# stderr and emit no candidate. Silence on stdout means "no override".
|
||||
print("approval-override: COMMENTS_JSON is not valid JSON", file=sys.stderr)
|
||||
return 0
|
||||
if not isinstance(comments, list):
|
||||
print("approval-override: COMMENTS_JSON is not a list", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
candidates = []
|
||||
for comment in comments:
|
||||
if not isinstance(comment, dict):
|
||||
continue
|
||||
login = ((comment.get("user") or {}).get("login") or "").strip()
|
||||
reason = extract_reason(comment.get("body"), directive)
|
||||
if not login or not reason:
|
||||
continue
|
||||
created_raw = comment.get("created_at") or comment.get("updated_at") or ""
|
||||
created = parse_ts(created_raw)
|
||||
if created is None:
|
||||
print(
|
||||
"approval-override: dropping override by '%s' — unreadable timestamp %r"
|
||||
% (login, created_raw),
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
# An override must post-date the commit it covers.
|
||||
if head_time is not None and created <= head_time:
|
||||
print(
|
||||
"approval-override: dropping STALE override by '%s' (%s) — not newer than "
|
||||
"head commit (%s). Re-comment to override the current commit."
|
||||
% (login, created.isoformat(), head_time.isoformat()),
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
candidates.append((created, login, reason, created_raw))
|
||||
|
||||
if not candidates:
|
||||
return 0
|
||||
|
||||
# Latest wins: a later override supersedes an earlier one.
|
||||
created, login, reason, created_raw = max(candidates, 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" % (login, created_raw, reason))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+136
-5
@@ -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 <reason>
|
||||
#
|
||||
# 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,7 +100,11 @@ 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)"
|
||||
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
|
||||
# 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.
|
||||
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 COMMENTS_JSON; do
|
||||
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
||||
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
||||
summary "- approval-policy: **BLOCKED — Gitea unreadable**"
|
||||
@@ -107,11 +146,103 @@ 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
|
||||
|
||||
# Pure, offline, unit-tested selection: which comment, if any, is a live override?
|
||||
OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_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_WHY="$(cut -f3- <<<"$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 " 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}\`${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, comment on the PR:"
|
||||
err " ${DIRECTIVE} <reason>"
|
||||
err " The reason is mandatory, the directive must START a line, and the comment must be"
|
||||
err " NEWER than 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
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/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 <comments-json> -> stdout of the parser (stderr is diagnostic only)
|
||||
run() {
|
||||
COMMENTS_JSON="$1" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
||||
python3 "$SUT" 2>/dev/null
|
||||
}
|
||||
|
||||
# comment <login> <created_at> <body>
|
||||
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 -f3- <<<"$out")"
|
||||
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted '${want}', got '${got}')"; fi
|
||||
}
|
||||
|
||||
echo "== ci/approval-override.py"
|
||||
|
||||
# --- the happy path -------------------------------------------------------------------
|
||||
expect_user "a directive with a reason, posted after the head commit, is selected" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" jakman85
|
||||
|
||||
expect_reason "the reason is returned verbatim" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" \
|
||||
'sole admin, gate is green'
|
||||
|
||||
# --- absence is a normal answer, not an error -----------------------------------------
|
||||
expect_none "no comments at all" '[]'
|
||||
expect_none "an ordinary comment" "$(comment jakman85 "$AFTER" 'looks good to me')"
|
||||
|
||||
# --- a reason is MANDATORY ------------------------------------------------------------
|
||||
expect_none "a bare directive (no reason) is refused" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override')"
|
||||
expect_none "a directive followed by only whitespace is refused" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override ')"
|
||||
|
||||
# --- the directive must START a line --------------------------------------------------
|
||||
expect_none "the directive mid-sentence does not count" \
|
||||
"$(comment jakman85 "$AFTER" 'you could just /approve-override this one')"
|
||||
expect_none "a QUOTED directive does not re-arm an override" \
|
||||
"$(comment jakman85 "$AFTER" '> /approve-override quoting someone else')"
|
||||
expect_user "leading whitespace before the directive is fine" \
|
||||
"$(comment jakman85 "$AFTER" ' /approve-override indented but mine')" jakman85
|
||||
expect_user "the directive on a later line of a longer comment counts" \
|
||||
"$(comment jakman85 "$AFTER" 'some preamble
|
||||
/approve-override the real reason here')" jakman85
|
||||
|
||||
# --- staleness: an override must post-date the commit it waives review on -------------
|
||||
expect_none "an override OLDER than the head commit is dropped as stale" \
|
||||
"$(comment jakman85 "$BEFORE" '/approve-override stale, predates the push')"
|
||||
expect_none "an override exactly AT the head commit time is dropped (strictly newer)" \
|
||||
"$(comment jakman85 "$HEAD_TIME" '/approve-override same instant')"
|
||||
|
||||
# --- malformed input is never a free pass --------------------------------------------
|
||||
expect_none "an unreadable timestamp drops the candidate" \
|
||||
"$(comment jakman85 'not-a-date' '/approve-override bad clock')"
|
||||
expect_none "a comment with no author is dropped" \
|
||||
'[{"user": {}, "created_at": "2026-10-01T12:30:00Z", "body": "/approve-override no login"}]'
|
||||
expect_none "invalid JSON yields no override (and never a traceback into stdout)" \
|
||||
'this is not json'
|
||||
expect_none "a JSON object instead of a list yields no override" \
|
||||
'{"user": "nope"}'
|
||||
|
||||
# --- latest wins ----------------------------------------------------------------------
|
||||
TWO='[
|
||||
{"user": {"login": "early"}, "created_at": "2026-10-01T12:10:00Z", "body": "/approve-override first"},
|
||||
{"user": {"login": "latest"}, "created_at": "2026-10-01T12:40:00Z", "body": "/approve-override second"}
|
||||
]'
|
||||
expect_user "the LATEST override supersedes an earlier one" "$TWO" latest
|
||||
expect_reason "and brings its own reason" "$TWO" second
|
||||
|
||||
# --- offsets, not just Z --------------------------------------------------------------
|
||||
# Gitea emits RFC3339 WITH AN OFFSET (measured: 2026-10-01T23:09:08-05:00), not a trailing Z.
|
||||
# A parser that only handles 'Z' silently drops every real override, so this case is load-bearing.
|
||||
expect_user "an RFC3339 offset timestamp (-05:00), as Gitea actually emits, parses" \
|
||||
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T08:30:00-05:00", "body": "/approve-override offset form"}]' \
|
||||
jakman85
|
||||
expect_none "and an offset timestamp that is genuinely earlier is still stale" \
|
||||
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T06:00:00-05:00", "body": "/approve-override offset, stale"}]'
|
||||
|
||||
# --- the directive is configurable ----------------------------------------------------
|
||||
out="$(run "$(comment jakman85 "$AFTER" '/admin-waive because policy says so')" "$HEAD_TIME" '/admin-waive')"
|
||||
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
|
||||
ok "a custom directive from the declaration is honoured"
|
||||
else
|
||||
bad "a custom directive from the declaration is honoured (got '${out}')"
|
||||
fi
|
||||
expect_none "the default directive does not match a custom-directive comment" \
|
||||
"$(comment jakman85 "$AFTER" '/admin-waive because policy says so')"
|
||||
|
||||
# --- no staleness anchor: do not invent one -------------------------------------------
|
||||
out="$(COMMENTS_JSON="$(comment jakman85 "$BEFORE" '/approve-override no anchor')" \
|
||||
HEAD_TIME="" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
|
||||
ok "with no HEAD_TIME the staleness test is skipped, not faked (caller blocks instead)"
|
||||
else
|
||||
bad "with no HEAD_TIME the candidate should still be returned (got '${out}')"
|
||||
fi
|
||||
|
||||
printf '\n %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -42,7 +42,16 @@ cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env
|
||||
# 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a
|
||||
# PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the
|
||||
# decision logic. `decisions-log` is added here in the same change that adds it to gate.sh.
|
||||
for stage in spec-immutability decisions-log lint l0 render-golden l1 gitea-settings conformance; do
|
||||
#
|
||||
# AND IT HAPPENED AGAIN, with `approval-policy` (fixed 2026-10-01). Same shape, same cost: the
|
||||
# stage was added to gate.sh by PR #17, never added here, so 15 of 50 assertions failed on `main`
|
||||
# — measured on 0c7e8dd before touching anything, to be sure it was not the change under test.
|
||||
# The note above was written about the previous instance and did not prevent the next one, which
|
||||
# says the comment is not the fix. THE FIX IS TO DERIVE THE LIST: it now comes from gate.sh's own
|
||||
# `run_stage` calls, so a stage added there can no longer be silently missing here.
|
||||
mapfile -t STAGES < <(sed -n 's/^run_stage[[:space:]]\+\([a-z0-9-]\+\).*/\1/p' "${REAL_CI_DIR}/gate.sh")
|
||||
[ "${#STAGES[@]}" -ge 8 ] || { echo "could not parse stage names out of gate.sh (got ${#STAGES[@]})" >&2; exit 1; }
|
||||
for stage in "${STAGES[@]}"; do
|
||||
cat > "${CI}/${stage}.sh" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
|
||||
|
||||
Reference in new issue
Block a user