Nightly: /nightly no longer needs an approving review

Ludovic decided on 2026-10-30, after the Team asked (levzzz5154: they are often
blocked on the launcher), that a trusted reviewer or Ludovic can pick any open
pull request to main for the Nightly, reviewed or not.

Kept: only Ludovic and the listed reviewers, the `paused` line, the
CODEOWNERS-guarded files, MAX_PRS, MAX_BUILDS_PER_DAY, open and targeting main,
the build without secrets, and Ludovic's `remove`. Each pull request is built at
the head it was picked at.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Sonnet 5.5 committed 2026-10-30 22:04:22 +01:00
1 parent 475d096fe2
commit c527d60b75
5 files changed
+64 -91

No files matched your search

+9 -6
View File
@@ -416,10 +416,13 @@ launcher and the website read: both then look the newest launcher tags up one by
(20:17 UTC) and when a trusted reviewer asks (#2681). It builds main's newest commit where
`checks` and `parity-main` passed, as a release does (or the `base` input), plus the pull requests the trusted
reviewers picked, merged in their order; with none picked, that commit alone (`tools/ci/nightly.py pick`). No green
commit (main's newest `checks` or `parity-main` run failed) builds nothing. A night with nothing new publishes nothing. Since the Nightly is the version every player installs, a pull request goes in only with an
approving review at the exact head that is built, from a trusted reviewer or from Ludovic's account (his own or his
agents' review), never its author's (Ludovic, 2026-10-28). A push after the review needs a new one: until then the
scheduled build keeps the approved head. `/nightly dry-run` needs no review: it publishes nothing.
commit (main's newest `checks` or `parity-main` run failed) builds nothing. A night with nothing new publishes nothing. A pull request goes in without a review: a trusted
reviewer or Ludovic can pick any open pull request to main, reviewed or not (Ludovic, 2026-10-30, at the Team's
request, because they were often blocked on the launcher; from 2026-10-28 it needed an approving review at the
head that is built). The guards stay: the pull request must be open and target main, it must leave the
CODEOWNERS-guarded files alone, the sets and builds stay capped, `paused` stops the reviewers, Ludovic's `remove`
sticks, and the build runs without secrets. Each pull request is built at the head it was picked at; a push after
the pick waits until someone picks it again (`/nightly add`).
Every Nightly ships its exact source. Its notes name the commit; `horizon-source.tar.gz` on the release is that
commit's `git archive`, signed with the builds in `SHA256SUMS`; and `nightly.py keep-source` tags the commit
@@ -473,8 +476,8 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
- **The answer.** The workflow answers in the thread with one comment starting `🤖 Nightly:`: the set, the build's
run, and later the published Nightly (or why it failed).
- **The set** is `set.json` on the `nightly-state` branch: who asked, when, and each pull request at the head it had
then. A head is taken again only when someone asks again or at the scheduled run, never silently in between. The
scheduled run takes every pull request's newest head and drops the merged and closed ones; the notes say so.
then. A head is taken again only when someone asks again, never silently in between. The
scheduled run builds every pull request at that head and drops the merged, closed and refused ones; the notes say so.
`builds.jsonl` there records each build's set (for `nightly_stats.py`).
- **Assembly.** `pick` (main's code) fetches each head and merges them onto main with `git merge-tree
--write-tree`: git objects only, no working tree, so no file, hook or filter of theirs reaches the runner. The
+18 -17
View File
@@ -6,15 +6,11 @@ from tools.ci import nightly
from tools.ci import nightly_set as ns
NOW = "2026-10-28T12:00:00+00:00"
TEAM = {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"}
PATTERNS = ns.guarded_patterns((Path(__file__).resolve().parents[1] / ".github/CODEOWNERS").read_text())
def pr(n, head="h", state="open", files=("crates/horizon-world/src/lib.rs",), base="main", approved_by=("rdbtcvs",),
approved_at=None):
sha = f"{head}{n}"
return ns.PullRequest(n, state, sha, f"Fix {n}", f"dev{n}", base, list(files),
{who: approved_at or sha for who in approved_by})
def pr(n, head="h", state="open", files=("crates/horizon-world/src/lib.rs",), base="main"):
return ns.PullRequest(n, state, f"{head}{n}", f"Fix {n}", f"dev{n}", base, list(files))
def lookup(*prs):
@@ -23,7 +19,7 @@ def lookup(*prs):
def run(state, text, login="kacpherek", *prs):
return ns.apply(state, ns.parse(text), login, NOW, lookup(*prs), PATTERNS, TEAM)
return ns.apply(state, ns.parse(text), login, NOW, lookup(*prs), PATTERNS)
T = dt.datetime(2026, 10, 28, 12, 0, tzinfo=dt.timezone.utc)
@@ -80,22 +76,27 @@ class NightlyTests(unittest.TestCase):
class ScheduledTests(unittest.TestCase):
def test_the_scheduled_nightly_takes_new_heads_and_drops_closed_ones(self):
def test_the_scheduled_nightly_drops_merged_closed_and_refused_ones(self):
state = run({}, "/nightly 1 2 3 4", "kacpherek", pr(1), pr(2), pr(3), pr(4)).state
get = lookup(pr(1, head="new"), pr(2, state="merged"), pr(3, state="closed"),
pr(4, files=[".github/workflows/x.yml"]))
kept, left_out = nightly.refresh(state["prs"], get, PATTERNS, TEAM)
self.assertEqual([(e["number"], e["head"]) for e in kept], [(1, "new1")])
kept, left_out = nightly.refresh(state["prs"], get, PATTERNS)
self.assertEqual([(e["number"], e["head"]) for e in kept], [(1, "h1")])
self.assertEqual(len(left_out), 3)
self.assertIn("merged", left_out[0])
def test_a_new_head_waits_for_its_review_and_unreviewed_ones_drop(self):
state = run({}, "/nightly 1 2", "kacpherek", pr(1), pr(2)).state
get = lookup(pr(1, head="new", approved_at="h1"), pr(2, head="new", approved_by=()))
kept, left_out = nightly.refresh(state["prs"], get, PATTERNS, TEAM)
def test_the_scheduled_nightly_builds_the_head_that_was_picked(self):
state = run({}, "/nightly 1", "kacpherek", pr(1)).state
kept, left_out = nightly.refresh(state["prs"], lookup(pr(1, head="new")), PATTERNS)
self.assertEqual([(e["number"], e["head"]) for e in kept], [(1, "h1")])
self.assertIn("isn't approved yet", left_out[0])
self.assertIn("no approving review", left_out[1])
self.assertEqual(left_out, [])
def test_an_unreviewed_pull_request_stays_in_the_scheduled_set(self):
# No review is looked at: a pull request nobody reviewed is kept as long as nothing else refuses it.
state = run({}, "/nightly 1", "kacpherek", pr(1)).state
kept, left_out = nightly.refresh(state["prs"], lookup(pr(1)), PATTERNS)
self.assertEqual([e["number"] for e in kept], [1])
self.assertEqual(left_out, [])
def test_notes_list_each_pull_request_and_who_picked_it(self):
prs = run({}, "/nightly 1 2", "kacpherek", pr(1), pr(2)).state["prs"]
@@ -113,7 +114,7 @@ WORKFLOWS = Path(__file__).resolve().parents[1] / ".github" / "workflows"
class UntrustedBuildTests(unittest.TestCase):
"""A nightly can hold unreviewed pull requests: nothing its build leaves may reach a release (game-build.yml)."""
"""A nightly can hold pull requests nobody reviewed: nothing its build leaves may reach a release (game-build.yml)."""
def setUp(self):
self.game = (WORKFLOWS / "game-build.yml").read_text()
+23 -25
View File
@@ -8,15 +8,11 @@ from tools.ci import nightly_set as ns
ROOT = Path(__file__).resolve().parents[1]
NOW = "2026-10-28T12:00:00+00:00"
TEAM = {"kacpherek", "rdbtcvs", "online5277", "levzzz5154"}
PATTERNS = ns.guarded_patterns((ROOT / ".github/CODEOWNERS").read_text())
def pr(n, head="h", state="open", files=("crates/horizon-world/src/lib.rs",), base="main", approved_by=("rdbtcvs",),
approved_at=None):
sha = f"{head}{n}"
return ns.PullRequest(n, state, sha, f"Fix {n}", f"dev{n}", base, list(files),
{who: approved_at or sha for who in approved_by})
def pr(n, head="h", state="open", files=("crates/horizon-world/src/lib.rs",), base="main"):
return ns.PullRequest(n, state, f"{head}{n}", f"Fix {n}", f"dev{n}", base, list(files))
def lookup(*prs):
@@ -25,30 +21,32 @@ def lookup(*prs):
def run(state, text, login="kacpherek", *prs):
return ns.apply(state, ns.parse(text), login, NOW, lookup(*prs), PATTERNS, TEAM)
return ns.apply(state, ns.parse(text), login, NOW, lookup(*prs), PATTERNS)
class ApprovalTests(unittest.TestCase):
"""A Nightly reaches every player: only pull requests the Team or Ludovic's agents approved at that head."""
class NoReviewNeededTests(unittest.TestCase):
"""Ludovic, 2026-10-30, at the Team's request: a Nightly takes any open pull request to main, reviewed or not."""
def test_an_unreviewed_pull_request_is_refused(self):
with self.assertRaisesRegex(ns.Refused, "no approving review"):
run({}, "/nightly add 1", "kacpherek", pr(1, approved_by=()))
def test_an_unreviewed_pull_request_is_accepted(self):
for text in ("/nightly add 1", "/nightly 1", "/nightly dry-run 1"):
result = run({}, text, "kacpherek", pr(1))
self.assertTrue(result.build, text)
self.assertEqual(run({}, "/nightly add 1", "kacpherek", pr(1)).state["prs"][0]["head"], "h1")
def test_the_authors_own_approval_and_outsiders_dont_count(self):
with self.assertRaisesRegex(ns.Refused, "no approving review"):
run({}, "/nightly add 1", "kacpherek", pr(1, approved_by=("dev1", "someone")))
def test_a_guarded_file_is_still_refused(self):
for text in ("/nightly add 1", "/nightly dry-run 1"):
with self.assertRaisesRegex(ns.Refused, "only Ludovic merges"):
run({}, text, "kacpherek", pr(1, files=["crates/horizon-world/src/lib.rs", ".github/CODEOWNERS"]))
def test_ludovics_agents_and_the_team_count(self):
for who in ("ludovic111", "levzzz5154"):
self.assertEqual(run({}, "/nightly add 1", "kacpherek", pr(1, approved_by=(who,))).state["prs"][0]["number"], 1)
def test_an_approval_on_an_older_head_doesnt_count(self):
with self.assertRaisesRegex(ns.Refused, "no approving review"):
run({}, "/nightly add 1", "kacpherek", pr(1, approved_at="old1"))
def test_a_dry_run_needs_no_approval(self):
self.assertTrue(run({}, "/nightly dry-run 1", "kacpherek", pr(1, approved_by=())).dry_run)
def test_the_other_guards_stay(self):
with self.assertRaisesRegex(ns.Refused, "not main"):
run({}, "/nightly add 1", "kacpherek", pr(1, base="release"))
with self.assertRaisesRegex(ns.Refused, "is closed"):
run({}, "/nightly add 1", "kacpherek", pr(1, state="closed"))
with self.assertRaises(ns.Refused): # who may ask is checked before the set changes (command())
ns.authorize("someone", ns.read_reviewers("kacpherek\n"))
with self.assertRaises(ns.Refused):
ns.authorize("kacpherek", ns.read_reviewers("kacpherek\npaused\n"))
class ParseTests(unittest.TestCase):
+6 -14
View File
@@ -126,9 +126,9 @@ def merged_since(previous: str | None, main: str) -> list[dict]:
return train.merged_between("game", previous, main)
def refresh(entries: list[dict], get, patterns: list[str], team: set[str]) -> tuple[list[dict], list[str]]:
"""The scheduled nightly's set: each pull request at its newest approved head (the Team's or Ludovic's agents'
approval, ns.approvers); a newer head nobody approved yet waits, merged, closed and refused ones are out."""
def refresh(entries: list[dict], get, patterns: list[str]) -> tuple[list[dict], list[str]]:
"""The scheduled nightly's set: each pull request still at the head it was picked at (a push after the pick
waits for someone to pick it again); merged, closed and refused ones are out."""
kept, left_out = [], []
for e in entries:
pr = get(e["number"])
@@ -136,15 +136,8 @@ def refresh(entries: list[dict], get, patterns: list[str], team: set[str]) -> tu
left_out.append(f"#{e['number']} {e['title']}: merged, so it's in main")
elif why := ns.check(pr, e["number"], patterns):
left_out.append(f"{why}: left out")
elif ns.approvers(pr, pr.head, team):
kept.append({**e, "head": pr.head, "title": pr.title})
elif ns.approvers(pr, e["head"], team):
kept.append(e)
left_out.append(f"#{e['number']} {e['title']}: its new head {pr.head[:7]} isn't approved yet, so the "
f"approved {e['head'][:7]} stays")
else:
left_out.append(f"#{e['number']} {e['title']}: no approving review from the Team or Ludovic's agents at "
"its head: left out")
kept.append(e)
return kept, left_out
@@ -240,12 +233,11 @@ def pick(force: bool) -> None:
get = ns.lookup(repo)
def fresh(st: dict, builds: str):
team = ns.read_reviewers(Path(ns.REVIEWERS).read_text() if Path(ns.REVIEWERS).exists() else "").logins
kept, out = refresh(st.get("prs", []), get, patterns, team)
kept, out = refresh(st.get("prs", []), get, patterns)
left_out[:] = out
if kept == st.get("prs", []):
return None
return {**st, "prs": kept}, builds, "the scheduled nightly takes the newest heads"
return {**st, "prs": kept}, builds, "the scheduled nightly drops the merged, closed and refused"
prs = ns.update_state(fresh, token).get("prs", [])
build_set(main, prs, left_out, today, force)
+8 -29
View File
@@ -14,7 +14,7 @@ A comment by Ludovic or by a login in .github/trusted-reviewers.txt, on any issu
/nightly dry-run 2640 builds main + these and signs with a throwaway key; publishes nothing, the set stays
The set (set.json on the nightly-state branch) keeps each pull request at the head it had when someone asked for
it; the scheduled nightly takes their newest heads and drops the merged and closed ones (nightly.py pick).
it; the scheduled nightly builds them at those heads and drops the merged and closed ones (nightly.py pick).
builds.jsonl there records every build's set, for the stats. Nothing here runs a pull request's code: its commits
are fetched and merged with `git merge-tree --write-tree`, which writes git objects and never a working tree, so no
file of theirs reaches the disk and no hook, filter or script of theirs runs.
@@ -157,21 +157,12 @@ class PullRequest:
author: str
base: str
files: list[str]
approvals: dict[str, str] = field(default_factory=dict) # login (lowercase) -> the head their approval is on
def approvers(pr: PullRequest, head: str, team: set[str]) -> list[str]:
"""Who approved `pr` at exactly `head`: a trusted reviewer, or Ludovic (his own review or his agents', which post
as ludovic111), never its author. A Nightly reaches every player (Ludovic, 2026-10-28): nothing goes in that the
Team or the agents haven't reviewed, and a push after the review needs a new one."""
ok = {t.lower() for t in team} | {OWNER}
return sorted(login for login, sha in pr.approvals.items()
if sha == head and login in ok and login != pr.author.lower())
def check(pr: PullRequest | None, number: int, patterns: list[str], team: set[str] | None = None) -> str | None:
"""Why `pr` can't go in a Nightly, or None. `team`: the trusted reviewers, whose approval (or Ludovic's) it needs
at its head; None skips that, for a dry run, which publishes nothing."""
def check(pr: PullRequest | None, number: int, patterns: list[str]) -> str | None:
"""Why `pr` can't go in a Nightly, or None. No review is needed (Ludovic, 2026-10-30, at the Team's request; it
needed an approving review at its head from 2026-10-28): a trusted reviewer or Ludovic picks any open pull
request to main, unreviewed or not, as long as it leaves the files CODEOWNERS guards alone."""
if pr is None:
return f"#{number} isn't a pull request here"
if pr.state != "open":
@@ -182,9 +173,6 @@ def check(pr: PullRequest | None, number: int, patterns: list[str], team: set[st
if bad:
more = f" and {len(bad) - 1} more" if len(bad) > 1 else ""
return f"#{number} changes `{bad[0]}`{more}, which only Ludovic merges (CODEOWNERS)"
if team is not None and not approvers(pr, pr.head, team):
return (f"#{number} has no approving review at its head {pr.head[:7]} from the Team or Ludovic's agents "
"(not its author)")
return None
@@ -216,7 +204,7 @@ def describe(prs: list[dict], heading: str = "The set") -> str:
def apply(state: dict, cmd: Command, login: str, now: str, lookup: Callable[[int], PullRequest | None],
patterns: list[str], team: set[str] = frozenset()) -> Result:
patterns: list[str]) -> Result:
"""What `cmd` by `login` makes of `state` (set.json). Pure but for `lookup`; raises Refused."""
st = copy.deepcopy(state)
prs: list[dict] = st.setdefault("prs", [])
@@ -241,7 +229,7 @@ def apply(state: dict, cmd: Command, login: str, now: str, lookup: Callable[[int
problems, asked = [], []
for n in cmd.prs:
pr = lookup(n)
if why := check(pr, n, patterns, None if cmd.verb == "dry-run" else team):
if why := check(pr, n, patterns):
problems.append(why)
else:
asked.append(entry(pr, login, now))
@@ -462,16 +450,7 @@ def lookup(repo: str) -> Callable[[int], PullRequest | None]:
files = gh("api", "--paginate", f"repos/{repo}/pulls/{n}/files", "--jq",
".[] | .filename, (.previous_filename // empty)").splitlines()
state = "merged" if p.get("merged_at") else p["state"]
approvals: dict[str, str] = {} # each reviewer's last say: an approval, or a change request or dismissal
for r in json.loads(gh("api", "--paginate", "--slurp", f"repos/{repo}/pulls/{n}/reviews") or "[]"):
for review in r if isinstance(r, list) else [r]:
who = (review.get("user") or {}).get("login", "").lower()
if review.get("state") == "APPROVED":
approvals[who] = review.get("commit_id", "")
elif review.get("state") in ("CHANGES_REQUESTED", "DISMISSED"):
approvals.pop(who, None)
return PullRequest(n, state, p["head"]["sha"], p["title"], p["user"]["login"], p["base"]["ref"], files,
approvals)
return PullRequest(n, state, p["head"]["sha"], p["title"], p["user"]["login"], p["base"]["ref"], files)
return get