Nightly parity audit on the Mac mini, with an issue per regressed harness

nightly.yml runs every harness from scratch on main at 03:00 Paris time on the Mac mini's runner, after
preparing the resources again if the preparer changed. tools/ci/parity_issues.py compares the results with
the previous audit: a harness that passed and now fails gets one issue labelled parity-regression (an open
one is commented on), and an issue closes when its harness passes again. The bugs session picks them up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-14 18:54:17 +02:00
parent 98ca844f07
commit beac11eaa5
4 changed files with 233 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
name: nightly audit
# Every vanilla harness, from scratch, on main, at 03:00 Paris time on the Mac mini's runner (it holds the
# maintainer's own Minecraft files). The resources are prepared again first when the preparer or its exporters
# changed (tools/ci/vanilla-data.sh). A harness that passed in the previous audit and fails now gets an issue
# labelled parity-regression (one per harness); its issue closes when it passes again
# (tools/ci/parity_issues.py). The bugs session picks those up. See docs/release-flow.md.
# gh workflow run "nightly audit" # now
# gh workflow run "nightly audit" -f dry_run=true # the audit, without touching issues
on:
schedule:
# GitHub's schedules are in UTC: both, and the first step keeps the one that is 03:00 in Paris.
- cron: '7 1 * * *'
- cron: '7 2 * * *'
workflow_dispatch:
inputs:
dry_run:
description: Run the audit, but only print what it would do with issues
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: nightly-audit
cancel-in-progress: false
jobs:
audit:
runs-on: [self-hosted, horizon-game]
timeout-minutes: 150
steps:
- name: 03:00 in Paris
id: when
run: |
hour=$(TZ=Europe/Paris date +%H)
if [[ "${{ github.event_name }}" == schedule && "$hour" != 03 ]]; then
echo "It is ${hour}:00 in Paris: the other schedule runs the audit."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v4
if: steps.when.outputs.run == 'true'
with:
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, the previous audit) and target/.
clean: false
- uses: dtolnay/rust-toolchain@stable
if: steps.when.outputs.run == 'true'
- name: Tools from the runner
if: steps.when.outputs.run == 'true'
run: bash tools/ci/runner-env.sh
- name: Vanilla data, prepared again if the preparer changed
if: steps.when.outputs.run == 'true'
run: bash tools/ci/vanilla-data.sh
- name: Every harness
id: audit
if: steps.when.outputs.run == 'true'
env:
PARITY_HOSTS: ''
run: |
echo "since=$(date +%Y-%m-%dT%H:%M:%S)" >> "$GITHUB_OUTPUT"
set +e
python3 tools/parity.py --all --timeout 1800 2>&1 | tee "$RUNNER_TEMP/audit.log"
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
{ echo '```'; tail -40 "$RUNNER_TEMP/audit.log"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Issues for the harnesses that regressed
if: steps.when.outputs.run == 'true' && always() && steps.audit.outputs.since
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
- name: Logs of the failed harnesses
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
uses: actions/upload-artifact@v4
with:
name: nightly-parity-logs
path: .local/parity-runner/logs/
if-no-files-found: ignore
retention-days: 14
+16
View File
@@ -211,3 +211,19 @@ see the previous one as the newest; launchers that already updated keep running.
With several game releases a day, the newest launcher release soon falls out of the newest 100 releases the
launcher and the website read: both then look the newest `launcher-v` tags up one by one
(`git/matching-refs/tags/launcher-v`, then `releases/tags/<tag>`).
## Nightly audit
`.github/workflows/nightly.yml` runs every harness from scratch (`tools/parity.py --all`) on main at 03:00 Paris
time, on the Mac mini's runner, after preparing the resources again if the preparer or its exporters changed.
`tools/ci/parity_issues.py` then compares the results with the previous audit
(`.local/parity-runner/audit.json` in the runner's checkout):
- a harness that passed last night and fails now gets an issue labelled `parity-regression`, titled
`Parity regression: <harness>`, with the commit, the run and the end of its log (one issue per harness: an open
one gets a comment instead);
- a harness with an open issue that passes again gets its issue closed.
The bugs session picks the issues up. The audit's logs are the run's `nightly-parity-logs` artifact; harnesses
listed `linux_only` are skipped there (see above). By hand: `gh workflow run "nightly audit"` (add
`-f dry_run=true` to leave issues alone).
+27
View File
@@ -0,0 +1,27 @@
import unittest
from tools.ci import parity_issues as issues
class ParityIssuesTests(unittest.TestCase):
def test_results_merge_shards_and_skip_earlier_runs(self):
state = {"harnesses": {
"food": {"status": "pass", "finished": "2026-10-15T03:10:00"},
"datafix#1": {"status": "pass", "finished": "2026-10-15T03:20:00"},
"datafix#2": {"status": "fail", "finished": "2026-10-15T03:21:00"},
"light": {"status": "fail", "finished": "2026-10-14T03:30:00"},
"copied": {"status": "pass", "finished": "2026-10-15T03:30:00", "shared": True},
}}
self.assertEqual(issues.results(state, "2026-10-15T03:00:00"), {"food": "pass", "datafix": "fail"})
def test_plan_opens_comments_and_closes(self):
now = {"a": "fail", "b": "fail", "c": "pass", "d": "fail", "e": "pass"}
previous = {"a": "pass", "b": "fail", "c": "fail", "e": "pass"}
self.assertEqual(issues.plan(now, previous, {"b": 7, "c": 8}),
[("open", "a"), ("comment", "b"), ("close", "c")])
# A harness that never passed in an audit (d) gets no issue; one that keeps passing (e) nothing.
self.assertEqual(issues.plan(now, {}, {}), [])
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Turns the nightly audit's results into GitHub issues (.github/workflows/nightly.yml).
python3 tools/ci/parity_issues.py --since 2026-10-15T03:00:00 [--dry-run]
Reads tools/parity.py's state (.local/parity-runner/state.json) for the harnesses that finished since --since,
shards merged into their harness, and compares them with the previous audit (.local/parity-runner/audit.json):
- a harness that fails now and passed in the previous audit gets one open issue labelled parity-regression,
titled "Parity regression: <harness>" (an open one is commented on instead of opening another);
- a harness with an open issue that still fails gets a comment on it;
- a harness with an open issue that passes again gets its issue closed.
The first audit only records the results. Needs the gh CLI; --dry-run prints what it would do.
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
STATE = ROOT / ".local" / "parity-runner" / "state.json"
AUDIT = ROOT / ".local" / "parity-runner" / "audit.json"
LOGS = ROOT / ".local" / "parity-runner" / "logs"
LABEL = "parity-regression"
TITLE = "Parity regression: "
def results(state, since):
"""{harness: "pass" | "fail"} of the harnesses that finished since `since`; a harness fails if a shard does."""
found = {}
for name, record in state.get("harnesses", {}).items():
if record.get("finished", "") < since or record.get("shared"):
continue
harness = name.split("#")[0]
found[harness] = "fail" if record["status"] != "pass" or found.get(harness) == "fail" else "pass"
return found
def plan(now, previous, open_issues):
"""The actions to take: (kind, harness) with kind open, comment or close. open_issues: {harness: number}."""
actions = []
for harness, status in sorted(now.items()):
if status == "fail" and harness in open_issues:
actions.append(("comment", harness))
elif status == "fail" and previous.get(harness) == "pass":
actions.append(("open", harness))
elif status == "pass" and harness in open_issues:
actions.append(("close", harness))
return actions
def log_tail(harness, lines=60):
logs = sorted(LOGS.glob(f"{harness}*.log"))
text = "\n".join(path.read_text(errors="replace") for path in logs)
return "\n".join(text.splitlines()[-lines:])
def gh(*arguments, dry_run=False):
if dry_run:
print("would run: gh " + " ".join(arguments[:4]) + " ...")
return ""
return subprocess.run(["gh", *arguments], cwd=ROOT, check=True, capture_output=True, text=True).stdout
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--since", required=True, help="the audit's start (the state's local time format)")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
now = results(json.loads(STATE.read_text()), args.since)
previous = json.loads(AUDIT.read_text()) if AUDIT.exists() else None
listed = json.loads(gh("issue", "list", "--label", LABEL, "--state", "open", "--limit", "200",
"--json", "number,title") or "[]")
open_issues = {issue["title"][len(TITLE):]: issue["number"] for issue in listed if issue["title"].startswith(TITLE)}
commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip()
run = f"{os.environ.get('GITHUB_SERVER_URL', 'https://github.com')}/{os.environ.get('GITHUB_REPOSITORY', '')}" \
f"/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}"
failing = sorted(harness for harness, status in now.items() if status == "fail")
print(f"{len(now)} harnesses ran: {len(now) - len(failing)} passed, {len(failing)} failed"
+ (f" ({', '.join(failing)})" if failing else ""))
if previous is None:
print("First audit: results recorded, no issues opened.")
actions = plan(now, {}, open_issues)
else:
actions = plan(now, previous, open_issues)
for kind, harness in actions:
details = (f"Nightly audit of `{commit[:12]}` ({run}).\n\n"
f"Reproduce: `python3 tools/parity.py {harness}`\n\n<details><summary>End of the log</summary>\n\n"
f"```\n{log_tail(harness)}\n```\n</details>\n")
print(f"{kind}: {harness}")
if kind == "open":
gh("issue", "create", "--label", LABEL, "--title", TITLE + harness, "--body",
f"`{harness}` passed in the previous nightly audit and fails now.\n\n" + details, dry_run=args.dry_run)
elif kind == "comment":
gh("issue", "comment", str(open_issues[harness]), "--body", "Still failing.\n\n" + details, dry_run=args.dry_run)
else:
gh("issue", "close", str(open_issues[harness]), "--comment",
f"Passes again in the nightly audit of `{commit[:12]}` ({run}).", dry_run=args.dry_run)
if not args.dry_run:
merged = dict(previous or {})
merged.update(now)
AUDIT.write_text(json.dumps(merged, indent=1, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())