mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
1 parent
475d096fe2
commit
c527d60b75
5 files changed
+64
-91
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user