Compare commits

...
Author SHA1 Message Date
levzzzandClaude Sonnet 5.5 253c37dc45 Remove the committed .pyc and ignore __pycache__
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:18:15 +05:00
levzzzandClaude Sonnet 5.5 57b2a3a785 Merge main into
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:18:00 +05:00
levzzzandClaude Sonnet 5.5 c58ba98d51 Merge an owner's update of their own project without a reviewer
owner-update.yml verifies the form's HMAC trailer, the bot author, a diff of
exactly one new version in mods/<id>.toml, the owner and revoked.toml, waits
for check (and native when it ran), then merges with REGISTRY_MERGE_TOKEN.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:17:48 +05:00
6 changed files with 388 additions and 1 deletions

No files matched your search

+1
View File
@@ -3,4 +3,5 @@
# (Ludovic decides who is in it). /.github/ and takedowns stay Ludovic's.
* @ludovic111 @horizon-rust/horizon-team
/.github/ @ludovic111
/scripts/ @ludovic111
/revoked.toml @ludovic111
+44
View File
@@ -0,0 +1,44 @@
# Merges an owner's update of their own project without a reviewer: the submission form's pull request
# (opened by the form's account, signed with an HMAC trailer in its body, label `owner-update`) whose diff is
# one new [[versions]] table in mods/<id>.toml, by someone in that entry's `owners`. scripts/owner_update.py
# verifies all of it and waits for the other workflows (check, and native when it ran) to pass on the head commit.
# Anything else is a reviewer's, as before. Setup (Ludovic's): README.md, "Owner updates".
#
# pull_request_target: it runs main's copy of this file and script, with secrets, and never checks out or runs the
# pull request's code: the script only reads the pull request through the API, as data. Merging needs the
# secret REGISTRY_MERGE_TOKEN, an account allowed to bypass the review rule; a merge by GITHUB_TOKEN would not
# start publish.yml either.
name: owner-update
on:
pull_request_target:
branches: [main]
types: [opened, reopened, labeled, synchronize]
permissions:
contents: read
actions: read
pull-requests: read
concurrency:
group: owner-update-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
owner-update:
# The label only keeps this off other pull requests; the signed trailer is the proof.
if: contains(github.event.pull_request.labels.*.name, 'owner-update')
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- run: python3 scripts/test_owner_update.py
- name: Verify, wait for the checks, merge
env:
GH_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REGISTRY_MERGE_TOKEN }}
UPDATE_KEY: ${{ secrets.REGISTRY_UPDATE_KEY }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
BOT: ${{ vars.REGISTRY_BOT || 'horizon-mods-bot' }}
SELF: ${{ github.workflow }}
run: python3 scripts/owner_update.py
+1
View File
@@ -3,3 +3,4 @@
/bootstrap/
/native/target/
/native/*/target/
__pycache__/
+33 -1
View File
@@ -42,7 +42,38 @@ cargo horizon-mod check target/horizon-mod/my_mod.hmod
```
**Updates** come the same way: the form adds a version to your entry. Only an entry's `owners` publish its updates
without a reviewer's extra look. A published file never changes: publish a new version.
without a reviewer's extra look (below). A published file never changes: publish a new version.
### Owner updates
When an owner of a project updates it through the form, and **only a new version is added** (the entry's key, owners,
icon, kind, id and page text are exactly as on `main`; the version is signed if the entry has a key), no reviewer is
needed. The flow:
1. The form checks that your GitHub login is in the entry's `owners` on `main` and that nothing else of the entry
changed, and opens the pull request as the registry's account with the label `owner-update` and, at the end of its
body, a trailer holding the id, version, SHA-256, file, your login and an HMAC-SHA256 over them with the secret
`REGISTRY_UPDATE_KEY` (only the form and this repository know it). Anyone else's pull request cannot carry a valid one.
2. [`owner-update.yml`](.github/workflows/owner-update.yml) runs [`scripts/owner_update.py`](scripts/owner_update.py)
from `main` (never the pull request's code) and verifies: the HMAC; the author is the form's account (variable
`REGISTRY_BOT`); the diff is exactly `mods/<id>.toml` with one new `[[versions]]` table matching the signed
id, version, SHA-256 and file, every other field unchanged and `main`'s copy unchanged since; the submitter is in
`owners` on `main`; the id is not in `revoked.toml`.
3. It waits for the other workflows on the pull request's head commit (`check`, and `native` when it ran) to succeed.
4. It merges (a merge commit pinned to the verified head) with `REGISTRY_MERGE_TOKEN`, which is how `publish.yml` runs
on the merge.
A new project, a changed page, key, owners or icon, a missing signature, a failed check or anything that does not verify
leaves the pull request to a reviewer as before (a red `owner-update` run says why). Tests of the verification:
`python3 scripts/test_owner_update.py`.
**Setup** (Ludovic, once; the changes under `.github/` and `scripts/` are his to review):
| What | Where |
| --- | --- |
| `REGISTRY_UPDATE_KEY`: the same random secret (32+ characters) | the secret of this repository, and the variable of the Railway service `horizon-mod-registry` (see its README); unset on either side, nothing merges by itself |
| `REGISTRY_MERGE_TOKEN`: a fine-grained token (or a GitHub App's) of an account that may bypass the review rule, with Contents and Pull requests read and write on this repository | the secret of this repository; add the account to the bypass list of the ruleset (or "Allow specified actors to bypass required pull requests" of the branch protection). Required status checks stay required for everyone |
| `REGISTRY_BOT`: the login of the form's account (`GITHUB_BOT_TOKEN`'s owner) | the variable of this repository (also read by `check.yml`; default `horizon-mods-bot`) |
**Signing** is optional and recommended: put your [minisign](https://jedisct1.github.io/minisign/) public key in your
entry, and every later version must be signed with it, so nobody else can publish an update in your name.
@@ -63,6 +94,7 @@ why.
| `revoked.toml` | Files taken down |
| `.github/workflows/check.yml` | A pull request's checks |
| `.github/workflows/publish.yml` | On merge: releases, then `index.json` on Pages |
| `.github/workflows/owner-update.yml`, `scripts/owner_update.py` | Merges an owner's update of their own project when it verifies |
| `.github/workflows/cleanup.yml` | Weekly: staging files nobody needs any more |
| `.github/workflows/native.yml` | Horizon's native mods: each changed one's tests, `.hnmod` and game check, all of them together, and weekly on the newest Nightly |
| `native/<name>/` | Horizon's own native mods (Zoom, FPS Counter, Keystrokes, Minimap, Recipes, Coordinates HUD): the source of their `.hnmod` files |
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Merges an owner update without a reviewer, when everything proves it is one.
The submission form (crates/horizon-mod-registry in horizon-rust/horizon) ends the body of an
owner's update with a trailer signed with REGISTRY_UPDATE_KEY:
<!-- owner-update
id: coords_hud
version: 0.1.0
sha256: <hex>
file: coords_hud-0.1.0.hmod
submitter: alice
hmac: <hex HMAC-SHA256 of "owner-update-v1\\n<id>\\n<version>\\n<sha256>\\n<file>\\n<submitter>">
-->
This script (run by .github/workflows/owner-update.yml, from main: never the pull request's copy) checks:
a. the trailer's HMAC;
b. the author is the form's bot account, and the diff is exactly mods/<id>.toml gaining one
[[versions]] table that matches the trailer, every other field as it was;
c. the submitter is in the entry's `owners` on main, and the id is not in revoked.toml;
d. every workflow run on the head commit but this one succeeded, `check` among them.
Then it merges (with REGISTRY_MERGE_TOKEN, pinned to the verified commit). Anything wrong: it exits 1 and
the pull request waits for a reviewer as any other.
Environment: GH_TOKEN (read), MERGE_TOKEN, REPO, PR (number), BOT (the form's account),
UPDATE_KEY, SELF (this workflow's name, skipped while waiting).
"""
import hashlib
import hmac
import json
import os
import re
import subprocess
import sys
import time
import tomllib
TRAILER = re.compile(r"<!-- owner-update\r?\n(.*?)\r?\n-->", re.S)
FIELDS = ("id", "version", "sha256", "file", "submitter")
ID = re.compile(r"[a-z0-9_]{1,64}")
VERSION = re.compile(r"[A-Za-z0-9.+_-]+")
HEX64 = re.compile(r"[0-9a-f]{64}")
LOGIN = re.compile(r"[A-Za-z0-9-]{1,39}")
class Refused(Exception):
pass
def trailer(body):
"""The trailer's fields, or None when the body has none (the last one counts)."""
found = TRAILER.findall(body or "")
if not found:
return None
fields = {}
for line in found[-1].splitlines():
key, sep, value = line.partition(": ")
if not sep or key in fields:
raise Refused(f"the trailer's line {line!r} is not `key: value`, or repeated")
fields[key] = value.strip()
if set(fields) != {*FIELDS, "hmac"}:
raise Refused("the trailer has other fields than " + ", ".join((*FIELDS, "hmac")))
return fields
def mac(key, fields):
message = "\n".join(["owner-update-v1", *(fields[f] for f in FIELDS)])
return hmac.new(key.encode(), message.encode(), hashlib.sha256).hexdigest()
def verify_trailer(key, fields):
if not key:
raise Refused("REGISTRY_UPDATE_KEY is not set")
if not hmac.compare_digest(mac(key, fields), fields["hmac"].lower()):
raise Refused("the trailer's HMAC is not valid: it was not made by the registry's form")
if not (ID.fullmatch(fields["id"]) and VERSION.fullmatch(fields["version"])
and HEX64.fullmatch(fields["sha256"]) and LOGIN.fullmatch(fields["submitter"])):
raise Refused("the trailer's fields are malformed")
def check_diff(fields, files, base_text, head_text, main_text, revoked_text):
"""`files`: the pull request's changed files (name, status) against its merge base."""
id_ = fields["id"]
path = f"mods/{id_}.toml"
if [(f["filename"], f["status"]) for f in files] != [(path, "modified")]:
raise Refused(f"the diff is not exactly a modified {path}: {[f['filename'] for f in files]}")
base, head, main = (tomllib.loads(t) for t in (base_text, head_text, main_text))
if base != main:
raise Refused(f"{path} changed on main since the pull request was opened")
if head.get("id") != id_ or base.get("id") != id_:
raise Refused("the entry's id is not the trailer's")
if not any(o.lower() == fields["submitter"].lower() for o in base.get("owners", [])):
raise Refused(f"{fields['submitter']} is not in {path}'s owners on main")
revoked = [r for r in tomllib.loads(revoked_text).get("revoked", []) if r.get("id") == id_]
if revoked:
raise Refused(f"{id_} is in revoked.toml")
rest = lambda e: {k: v for k, v in e.items() if k != "versions"} # noqa: E731
if rest(head) != rest(base):
changed = sorted(k for k in {*rest(head), *rest(base)} if head.get(k) != base.get(k))
raise Refused(f"it changes more than a version: {', '.join(changed)}")
old, new = base.get("versions", []), head.get("versions", [])
added = [v for v in new if v not in old]
if len(new) != len(old) + 1 or len(added) != 1 or any(v not in new for v in old):
raise Refused("it does not add exactly one version and keep the others")
v = added[0]
for field in ("version", "sha256", "file"):
if v.get(field) != fields[field]:
raise Refused(f"the new version's {field} is {v.get(field)!r}, not the signed {fields[field]!r}")
if base.get("key") and not v.get("signature"):
raise Refused("the entry has a key and the version has no signature")
return base.get("kind")
def waiting(runs, own):
"""(finished, failure): the workflow runs but `own`, on the head commit."""
others = [r for r in runs if r["name"] != own]
if not any(r["name"] == "check" for r in others):
return False, None
for r in others:
if r["status"] == "completed" and r["conclusion"] not in ("success", "skipped", "neutral"):
return True, f"{r['name']} ended {r['conclusion']}"
return all(r["status"] == "completed" for r in others), None
def gh(*args, token=None, raw=False):
env = {**os.environ, "GH_TOKEN": token or os.environ["GH_TOKEN"]}
out = subprocess.run(["gh", *args], env=env, capture_output=True, text=True)
if out.returncode:
raise Refused(f"gh {' '.join(args[:2])}: {out.stderr.strip()}")
return out.stdout if raw else json.loads(out.stdout or "null")
def contents(repo, path, ref):
return gh("api", "-H", "Accept: application/vnd.github.raw+json", f"repos/{repo}/contents/{path}?ref={ref}", raw=True)
def pull(repo, n):
return gh("api", f"repos/{repo}/pulls/{n}")
def main():
repo, n, bot = os.environ["REPO"], os.environ["PR"], os.environ["BOT"]
pr = pull(repo, n)
if pr["state"] != "open" or pr["base"]["ref"] != "main":
print("not an open pull request to main")
return 0
fields = trailer(pr["body"])
if fields is None:
print("no owner-update trailer: a reviewer's")
return 0
verify_trailer(os.environ.get("UPDATE_KEY", ""), fields)
if pr["user"]["login"] not in (bot, f"{bot}[bot]"):
raise Refused(f"the author {pr['user']['login']} is not the form's account {bot}")
sha = pr["head"]["sha"]
compare = gh("api", f"repos/{repo}/compare/main...{sha}")
if compare.get("total_commits", 0) < 1 or len(compare.get("files", [])) >= 300:
raise Refused("the comparison is not a small one")
path = f"mods/{fields['id']}.toml"
kind = check_diff(
fields,
compare["files"],
contents(repo, path, compare["merge_base_commit"]["sha"]),
contents(repo, path, sha),
contents(repo, path, "main"),
contents(repo, "revoked.toml", "main"),
)
print(f"{fields['id']} {fields['version']} ({kind}) by {fields['submitter']}: the trailer and the diff hold")
deadline = time.time() + 50 * 60
while True:
runs = gh("api", f"repos/{repo}/actions/runs?head_sha={sha}&per_page=100")["workflow_runs"]
done, failure = waiting(runs, os.environ.get("SELF", "owner-update"))
if failure:
raise Refused(f"a check failed: {failure}")
if done:
break
if time.time() > deadline:
raise Refused("the checks did not finish in 50 minutes")
time.sleep(30)
if pull(repo, n)["head"]["sha"] != sha:
raise Refused("the pull request changed while its checks ran")
gh("pr", "merge", n, "--repo", repo, "--merge", "--match-head-commit", sha, token=os.environ["MERGE_TOKEN"])
print(f"merged {sha}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Refused as e:
print(f"::error::owner update not merged: {e}")
sys.exit(1)
+118
View File
@@ -0,0 +1,118 @@
"""python3 scripts/test_owner_update.py"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(__file__))
import owner_update as ou # noqa: E402
KEY = "a test update key"
SHA = "ab" * 32
FIELDS = {"id": "zoom", "version": "1.1.0", "sha256": SHA, "file": "zoom-1.1.0.hnmod", "submitter": "alice"}
def entry(owners='["alice"]', versions=("1.0.0",), extra="", key='key = "RWK"\n'):
head = f'id = "zoom"\nkind = "native-mod"\nname = "Zoom"\nsummary = "s"\nowners = {owners}\nlicense = "MIT"\nsource = "https://x"\n{key}{extra}\n'
return head + "".join(
f'[[versions]]\nversion = "{v}"\npublished = "2026-10-30"\ngame = "26.3"\nfile = "zoom-{v}.hnmod"\n'
f'sha256 = "{SHA if v == "1.1.0" else "00" * 32}"\nsignature = "sig"\n\n'
for v in versions
)
BASE = entry()
GOOD = entry(versions=("1.1.0", "1.0.0"))
FILES = [{"filename": "mods/zoom.toml", "status": "modified"}]
def run(head=GOOD, files=FILES, main=BASE, base=BASE, revoked="", fields=FIELDS):
return ou.check_diff(fields, files, base, head, main, revoked)
class Trailer(unittest.TestCase):
def body(self, fields=FIELDS, key=KEY):
lines = "\n".join(f"{k}: {v}" for k, v in fields.items())
return f"hello\n\n<!-- owner-update\n{lines}\nhmac: {ou.mac(key, fields)}\n-->"
def test_valid(self):
f = ou.trailer(self.body())
ou.verify_trailer(KEY, f)
self.assertEqual(f["submitter"], "alice")
def test_matches_the_form(self):
# crates/horizon-mod-registry/tests/submit.rs builds the same message.
f = {"id": "coords_hud", "version": "0.1.0", "sha256": "e1" * 32, "file": "coords_hud-0.1.0.hmod", "submitter": "alice"}
import hashlib, hmac
msg = "owner-update-v1\ncoords_hud\n0.1.0\n" + "e1" * 32 + "\ncoords_hud-0.1.0.hmod\nalice"
self.assertEqual(ou.mac(KEY, f), hmac.new(KEY.encode(), msg.encode(), hashlib.sha256).hexdigest())
def test_none(self):
self.assertIsNone(ou.trailer("no trailer"))
self.assertIsNone(ou.trailer(None))
def test_forged(self):
for body in (self.body(key="another"), self.body().replace("alice", "mallory")):
with self.assertRaises(ou.Refused):
ou.verify_trailer(KEY, ou.trailer(body))
with self.assertRaises(ou.Refused):
ou.verify_trailer("", ou.trailer(self.body()))
def test_malformed(self):
with self.assertRaises(ou.Refused):
ou.trailer(self.body().replace("file:", "other:"))
with self.assertRaises(ou.Refused):
ou.trailer(self.body().replace("-->", "id: x\n-->"))
class Diff(unittest.TestCase):
def refused(self, part, **kw):
with self.assertRaises(ou.Refused) as e:
run(**kw)
self.assertIn(part, str(e.exception))
def test_an_owners_new_version(self):
self.assertEqual(run(), "native-mod")
self.assertEqual(run(head=entry(versions=("1.0.0", "1.1.0"))), "native-mod") # appended, as the diff reads
def test_files(self):
self.refused("exactly", files=FILES + [{"filename": "icons/zoom.png", "status": "added"}])
self.refused("exactly", files=[{"filename": "mods/zoom.toml", "status": "added"}])
self.refused("exactly", files=[{"filename": "mods/other.toml", "status": "modified"}])
def test_owners_and_revoked(self):
self.refused("not in", base=entry('["bob"]'), main=entry('["bob"]'), head=entry('["bob"]', ("1.1.0", "1.0.0")))
self.refused("revoked", revoked='[[revoked]]\nid = "zoom"\nreason = "r"\ndate = "2026-10-30"\n')
self.refused("changed on main", main=entry(extra='description = "new"\n'))
def test_only_a_version(self):
self.refused("owners", head=entry('["alice", "mallory"]', ("1.1.0", "1.0.0")))
self.refused("key", head=entry(versions=("1.1.0", "1.0.0"), key='key = "RWOther"\n'))
self.refused("summary", head=GOOD.replace('summary = "s"', 'summary = "z"'))
self.refused("icon", head=entry(versions=("1.1.0", "1.0.0"), extra='icon = "https://x/i.png"\n'))
def test_the_version_is_the_signed_one(self):
self.refused("sha256", head=GOOD.replace(SHA, "cd" * 32))
self.refused("exactly one", head=entry(versions=("1.1.0", "1.0.5", "1.0.0")))
self.refused("exactly one", head=entry(versions=("1.1.0",)))
self.refused("version", fields={**FIELDS, "version": "9.9.9"})
self.refused("no signature", head=GOOD.replace('signature = "sig"\n', "", 1))
def test_a_published_version_is_never_edited(self):
self.refused("exactly one", head=entry(versions=("1.1.0", "1.0.0")).replace("2026-10-30", "2026-10-31", 2))
class Waiting(unittest.TestCase):
def test_it(self):
run_ = lambda name, status="completed", conclusion="success": {"name": name, "status": status, "conclusion": conclusion} # noqa: E731
self.assertEqual(ou.waiting([], "owner-update"), (False, None))
self.assertEqual(ou.waiting([run_("owner-update", "in_progress", None)], "owner-update"), (False, None))
self.assertEqual(ou.waiting([run_("check", "in_progress", None)], "owner-update"), (False, None))
self.assertEqual(ou.waiting([run_("check")], "owner-update"), (True, None))
self.assertEqual(ou.waiting([run_("check"), run_("native", "queued", None)], "owner-update"), (False, None))
self.assertEqual(ou.waiting([run_("check"), run_("native", conclusion="skipped")], "owner-update"), (True, None))
self.assertEqual(ou.waiting([run_("check", conclusion="failure")], "owner-update"), (True, "check ended failure"))
self.assertEqual(ou.waiting([run_("check"), run_("native", conclusion="cancelled")], "owner-update")[1], "native ended cancelled")
if __name__ == "__main__":
unittest.main()