Files
Sylpheed/tools/gitea-protect
Claude (Pi session) 799fa93383 tools: apply and re-check the branch protection rule, rather than clicking it
Phase 2 as a file. Six settings where two are load-bearing and both were missing
from the first draft is the shape of thing that gets mis-clicked at 1am, so it
goes through the API: what was applied is readable in a diff, and `--verify`
can re-check it later instead of it being checked once.

--verify states its expectations INDEPENDENTLY of what the apply path sends.
A check derived from "whatever we posted" cannot fail -- it re-derives the
expectation from the thing under test, which is the same instrument-shaped
failure as a check that passes on an instance with no rule at all.

It also asserts both agents are still Write and not Admin, because an agent
promoted to Admin can edit the rule and then merge, so a green rule proves
nothing on its own. That is the `gitea-verify` card from "Still to build";
what is left of it is only putting it on a timer.

`block_admin_merge_override` stays false on purpose, and the reasoning is in
the file: approvals are whitelisted to `fabi`, and Gitea will not let `fabi`
approve a `fabi` PR -- so with the override blocked, a human-authored PR could
never reach one approval and could never merge at all. The override is not a
hole in the agent gate because the agents are Write, not Admin. Phase 1.2 pays
for that; this is where it is spent.

Reads the repository-scoped credential that already exists on the agent box
(~/.sylph-git-credentials) rather than the issue-only ~/.sylph-gitea-api-token,
which every branch-protection endpoint refuses. That keeps the setup needing no
new credential, and keeps push rights on one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McNbzUeq1KRBWs4G6X2YVj
2026-09-04 16:49:58 +02:00

232 lines
10 KiB
Python
Executable File

#!/usr/bin/env python3
"""Apply and verify branch protection on `main` -- Phase 2 of GITEA-SETUP.md.
tools/gitea-protect --dry-run print the exact rule it would send; no token
tools/gitea-protect create or update the rule (idempotent)
tools/gitea-protect --verify assert the live rule still holds; exit 1 if not
Six settings, of which two were missing from the first draft of the runbook and
both of those are the ones that close the gate. That is the shape of thing that
gets mis-clicked in a web form at 1am, so it goes through the API instead: what
was applied is reviewable in a diff, and `--verify` re-checks it every day
rather than once.
── Why each field is what it is ─────────────────────────────────────────────
Read out of Gitea's own models/git/protected_branch.go, not inferred:
EnableMergeWhitelist=false merging falls back on "whether the user has
write permission" -- and both agents have
Write. This is THE gate; without it every
other row is decoration.
EnableApprovalsWhitelist=false "anyone with write access is considered
official reviewer". Gitea refuses to let an
author approve their OWN pull request and does
nothing about sylph-decoder approving
sylph-port's, so without this the two agents
satisfy the human gate between themselves.
enable_push=false blocks PUSHES to main. It has no effect on
merging whatsoever, which is the assumption
that made the first version of this phase read
as protection while being none.
🔴 block_admin_merge_override stays FALSE, deliberately. Turning it on locks the
human out of their own work: approvals are whitelisted to `fabi`, Gitea will not
let `fabi` approve a `fabi` PR, so a human-authored PR could never reach one
approval and -- with the override blocked -- could never be merged at all. The
admin override is what keeps that door open, and it is not a hole in the agent
gate because the agents are Write, not Admin. That is what "Write, not Admin" in
Phase 1.2 is buying, and this is where it gets spent.
── Where the token comes from ───────────────────────────────────────────────
Branch protection is a REPOSITORY-scope endpoint, so `~/.sylph-gitea-api-token`
(write:issue, read:repository) cannot do it -- that token exists precisely so the
issue work needs no repository rights.
The credential that CAN is one you already have: `~/.sylph-git-credentials`, on
the agent box, scoped write:repository. Reusing it means this needs no new
credential and no second machine holding push rights, which is the whole reason
to run this here rather than on the Pi.
"""
import argparse, json, os, sys, urllib.error, urllib.parse, urllib.request
HOST = os.environ.get("SYLPH_GITEA_HOST", "git.mc02.dev")
REPO = os.environ.get("SYLPH_GITEA_REPO", "fabi/Sylpheed")
HUMAN = os.environ.get("SYLPH_GITEA_HUMAN", "fabi")
BRANCH = os.environ.get("SYLPH_GITEA_BRANCH", "main")
AGENTS = os.environ.get("SYLPH_GITEA_AGENTS", "sylph-decoder,sylph-port").split(",")
RULE = {
"rule_name": BRANCH,
"enable_push": False,
"required_approvals": 1,
"dismiss_stale_approvals": True,
"block_on_rejected_reviews": True,
"enable_merge_whitelist": True,
"merge_whitelist_usernames": [HUMAN],
"enable_approvals_whitelist": True,
"approvals_whitelist_username": [HUMAN],
"block_admin_merge_override": False, # see the module docstring
}
# What --verify asserts. Kept separate from RULE because a check that is written
# as "whatever we sent" cannot fail: it would re-derive the expectation from the
# thing under test. These are stated independently, on purpose.
EXPECTED = {
"enable_push": (lambda v: v is False, "pushes to the branch are blocked"),
"required_approvals": (lambda v: v >= 1, "at least one approval required"),
"dismiss_stale_approvals": (lambda v: v is True, "stale approvals dismissed"),
"block_on_rejected_reviews": (lambda v: v is True, "rejected reviews block the merge"),
"enable_merge_whitelist": (lambda v: v is True, "MERGE WHITELIST ON -- the gate"),
"merge_whitelist_usernames": (lambda v: v == [HUMAN], f"only {HUMAN} may merge"),
"enable_approvals_whitelist": (lambda v: v is True, "APPROVALS WHITELIST ON"),
"approvals_whitelist_username": (lambda v: v == [HUMAN], f"only {HUMAN}'s approval counts"),
}
def token():
"""The first credential that can plausibly do this, and a clear no otherwise."""
explicit = os.environ.get("SYLPH_GITEA_ADMIN_TOKEN")
if explicit and os.path.exists(explicit):
return open(explicit).read().strip(), explicit
cred = os.path.expanduser(os.environ.get("SYLPH_GIT_CREDENTIALS",
"~/.sylph-git-credentials"))
if os.path.exists(cred):
for line in open(cred):
line = line.strip()
if HOST in line and "@" in line:
parsed = urllib.parse.urlsplit(line)
if parsed.password:
return urllib.parse.unquote(parsed.password), cred
sys.exit(
f"gitea-protect: no repository-scoped credential found.\n\n"
f" Looked in $SYLPH_GITEA_ADMIN_TOKEN and {cred}.\n\n"
f" NOT ~/.sylph-gitea-api-token: that one is write:issue + read:repository\n"
f" by design, and every branch-protection endpoint refuses it. Run this on\n"
f" the machine that already holds the push credential rather than issuing a\n"
f" repository-scoped token to a second box.\n"
)
def api(method, path, tok, body=None):
url = f"https://{HOST}/api/v1/repos/{REPO}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"token {tok}",
"Content-Type": "application/json",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
if e.code in (401, 403) and "scope" in raw:
sys.exit(f"🔴 that credential lacks repository scope:\n {raw.strip()}")
return e.code, raw
except urllib.error.URLError as e:
sys.exit(f"🔴 no response from {url} -- host or network: {e.reason}")
def apply_rule(tok):
status, existing = api("GET", f"/branch_protections/{BRANCH}", tok)
if status == 200:
status, out = api("PATCH", f"/branch_protections/{BRANCH}", tok,
{k: v for k, v in RULE.items() if k != "rule_name"})
verb = "updated"
elif status == 404:
status, out = api("POST", "/branch_protections", tok, RULE)
verb = "created"
else:
sys.exit(f"🔴 unexpected {status} reading the existing rule: {existing}")
if status not in (200, 201):
sys.exit(f"🔴 {verb.rstrip('d')} failed ({status}): {out}")
print(f" {verb} the protection rule on {BRANCH}")
return out
def verify(tok):
"""Assert, one line per property, and say which one failed rather than 'no'."""
ok = True
status, rule = api("GET", f"/branch_protections/{BRANCH}", tok)
if status == 404:
print(f"🔴 NO PROTECTION RULE on {BRANCH}. Anyone with Write can push to it.")
return False
if status != 200:
sys.exit(f"🔴 could not read the rule ({status}): {rule}")
for key, (pred, why) in EXPECTED.items():
got = rule.get(key)
good = pred(got)
ok &= good
print(f" {'' if good else '🔴'} {why:<42} {key}={got!r}")
# The other half of what a daily check is for: Phase 1.2's "Write, not
# Admin". An agent promoted to Admin could edit the rule above and then
# merge, so a green rule proves nothing on its own.
for agent in AGENTS:
status, perm = api("GET", f"/collaborators/{agent}/permission", tok)
if status == 404:
print(f"{agent:<42} not a collaborator (yet)")
continue
if status != 200:
print(f" 🔴 {agent:<42} permission unreadable ({status})")
ok = False
continue
role = perm.get("permission")
good = role == "write"
ok &= good
print(f" {'' if good else '🔴'} {agent + ' is Write, not Admin':<42} permission={role!r}")
return ok
def main():
p = argparse.ArgumentParser(add_help=True, description=__doc__.split("\n")[0])
g = p.add_mutually_exclusive_group()
g.add_argument("--dry-run", action="store_true",
help="print the rule that would be sent; needs no credential")
g.add_argument("--verify", action="store_true",
help="check the live rule against what this file asserts")
a = p.parse_args()
print(f"repo https://{HOST}/{REPO}")
print(f"branch {BRANCH}\n")
if a.dry_run:
print(f"would PUT this rule (no credential read, nothing sent):\n")
print(json.dumps(RULE, indent=2))
print(f"\ndry run -- nothing was changed.")
return 0
tok, where = token()
print(f"credential from {where}\n")
if a.verify:
ok = verify(tok)
print()
print("protection holds." if ok else
"🔴 PROTECTION DOES NOT HOLD -- stop the agents until it does.")
return 0 if ok else 1
apply_rule(tok)
print()
ok = verify(tok)
print()
if ok:
print("Now run the check that a settings page cannot give you, from")
print("GITEA-SETUP.md Phase 2 -- especially step 4: approve the throwaway")
print("PR yourself, then confirm sylph-port STILL has no merge button.")
print("Steps 1-3 pass on an instance with no rule at all.")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())