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
This commit is contained in:
@@ -59,7 +59,34 @@ agent remove the rule that stops it merging.
|
||||
**Do this before the agents hold tokens**, so there is no window in which they
|
||||
can push to `main`.
|
||||
|
||||
Repo → Settings → Branches → *Protected Branches* → add rule for `main`:
|
||||
**Apply it through the API, not the form** — `tools/gitea-protect`. Six settings
|
||||
of which two are load-bearing, and both of those were missing from the first
|
||||
draft of this phase: that is the shape of thing that gets mis-clicked. An API
|
||||
call is reviewable in a diff and repeatable, and the same file re-checks it later.
|
||||
|
||||
```bash
|
||||
tools/gitea-protect --dry-run # the exact rule, no credential read
|
||||
tools/gitea-protect # create or update, then verify
|
||||
tools/gitea-protect --verify # assert it still holds; exit 1 if not
|
||||
```
|
||||
|
||||
📌 **Run it on the agent box, not the Pi.** Branch protection is a
|
||||
repository-scope endpoint, so `~/.sylph-gitea-api-token` cannot do it — that
|
||||
token is deliberately issue-only. The credential that can is the one already
|
||||
sitting on that machine, `~/.sylph-git-credentials`, which the tool reads. Doing
|
||||
it there means no new credential, and no second machine holding push rights just
|
||||
to close a one-time setup step.
|
||||
|
||||
🔴 The tool sets `block_admin_merge_override: false`, deliberately. Turning it on
|
||||
would lock **you** out of your 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 could never merge. The admin override is what keeps that
|
||||
door open, and it is not a hole in the agent gate for exactly one reason: the
|
||||
agents are **Write, not Admin**. That is what Phase 1.2 is buying, and this is
|
||||
where it gets spent.
|
||||
|
||||
Or by hand — Repo → Settings → Branches → *Protected Branches* → add rule for
|
||||
`main`:
|
||||
|
||||
| setting | value | why |
|
||||
|---|---|---|
|
||||
@@ -275,8 +302,10 @@ Not blockers for Phase 7, but the workflow is not finished without them:
|
||||
existing refusals stay: no `main`, no force-push.
|
||||
* **an attachment uploader** — the MCP exposes `attachment_read` only, so putting
|
||||
a screenshot on an issue needs a direct `POST /repos/{owner}/{repo}/issues/{index}/assets`.
|
||||
* **`gitea-verify`** — asserts protection is still on and the agents are still
|
||||
Write-not-Admin. Phase 2 is checked once; this checks it every day.
|
||||
* ~~**`gitea-verify`**~~ — done, as `tools/gitea-protect --verify`: asserts every
|
||||
field of the rule *independently* of what the apply path sends, and that both
|
||||
agents are still Write-not-Admin. What is still missing is only the *every
|
||||
day* part — nothing runs it on a timer yet.
|
||||
* **wiki landing page** — bundles in flight and what each agent is on. There is
|
||||
currently no view of what is happening except container logs.
|
||||
|
||||
|
||||
231
tools/gitea-protect
Executable file
231
tools/gitea-protect
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user