mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
CI: daily check for a republished Mojang version JSON (#3333)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
14262d0595
commit
4647ce7af7
3 files changed
+228
No files matched your search
@@ -0,0 +1,31 @@
|
||||
name: mojang republish
|
||||
# Daily: does Mojang's version manifest list a pinned-version JSON that research-lock.json does not know?
|
||||
# If so, one issue "Mojang republished <version>: <sha8>" with the hashes and a lock snippet
|
||||
# (tools/ci/mojang_republish.py). Reads only Mojang's public manifest.
|
||||
# gh workflow run "mojang republish" -f dry_run=true
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Only print what it would do with issues
|
||||
type: boolean
|
||||
default: false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
concurrency:
|
||||
group: mojang-republish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check the manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: python3 tools/ci/mojang_republish.py ${{ inputs.dry_run && '--dry-run' || '' }}
|
||||
@@ -0,0 +1,76 @@
|
||||
import copy
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "ci"))
|
||||
import mojang_republish as mr # noqa: E402
|
||||
|
||||
LOCK = {"minecraft": {
|
||||
"version": "26.3", "manifest_sha1": "aaaa1111", "accepted_manifest_sha1": ["bbbb2222"],
|
||||
"accepted_asset_index_sha1": ["a1a1a1a1"], "client_sha1": "c1", "server_sha1": "s1", "java_major": 25}}
|
||||
|
||||
|
||||
def manifest(sha1):
|
||||
return {"versions": [{"id": "25.0", "sha1": "zzzz", "url": "u0"},
|
||||
{"id": "26.3", "sha1": sha1, "url": f"https://x/{sha1}.json"}]}
|
||||
|
||||
|
||||
def detail(client="c1", server="s1", java=25, asset="new11111"):
|
||||
return {"downloads": {"client": {"sha1": client}, "server": {"sha1": server}},
|
||||
"javaVersion": {"majorVersion": java}, "assetIndex": {"sha1": asset}}
|
||||
|
||||
|
||||
class Plan(unittest.TestCase):
|
||||
def run_plan(self, sha1, info=None, issues=None):
|
||||
def fetch(url):
|
||||
self.assertIsNotNone(info, "known manifests must not be downloaded")
|
||||
return info
|
||||
return mr.plan(copy.deepcopy(LOCK), manifest(sha1), fetch, issues or {})
|
||||
|
||||
def test_pinned_and_accepted_are_quiet(self):
|
||||
self.assertIsNone(self.run_plan("aaaa1111"))
|
||||
self.assertIsNone(self.run_plan("bbbb2222"))
|
||||
|
||||
def test_unknown_with_same_jars_opens_an_issue_with_a_snippet(self):
|
||||
action = self.run_plan("cccc3333", detail())
|
||||
self.assertEqual(action["kind"], "open")
|
||||
self.assertEqual(action["title"], "Mojang republished 26.3: cccc3333")
|
||||
self.assertTrue(action["jars_unchanged"])
|
||||
self.assertIn("Jars unchanged", action["body"])
|
||||
self.assertIn("NOT checked", action["body"])
|
||||
for text in ('"cccc3333"', '"bbbb2222"', '"new11111"', '"a1a1a1a1"', "accepted_asset_index_sha1"):
|
||||
self.assertIn(text, action["body"])
|
||||
|
||||
def test_unknown_with_different_jars_says_do_not_accept(self):
|
||||
for info, key in ((detail(client="c2"), "client_sha1"), (detail(server="s2"), "server_sha1"),
|
||||
(detail(java=26), "java_major")):
|
||||
action = self.run_plan("cccc3333", info)
|
||||
self.assertFalse(action["jars_unchanged"])
|
||||
self.assertIn("JARS CHANGED", action["body"])
|
||||
self.assertIn(key, action["body"])
|
||||
self.assertNotIn("```json", action["body"])
|
||||
|
||||
def test_existing_issue_is_skipped(self):
|
||||
issues = {"Mojang republished 26.3: cccc3333": 7, "Mojang republished 26.3: dddd4444": 8}
|
||||
action = self.run_plan("cccc3333", detail(), issues)
|
||||
self.assertEqual((action["kind"], action["number"]), ("skip", 7))
|
||||
|
||||
def test_missing_version_is_an_error(self):
|
||||
with self.assertRaises(ValueError):
|
||||
mr.plan(LOCK, {"versions": []}, None, {})
|
||||
|
||||
def test_accepted_matches_lab(self):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import lab
|
||||
for sha1 in ("aaaa1111", "bbbb2222", "cccc3333"):
|
||||
self.assertEqual(mr.accepted(LOCK["minecraft"], sha1), lab.manifest_accepted(LOCK, sha1))
|
||||
|
||||
def test_open_issues_filters_by_title_prefix(self):
|
||||
listed = '[{"number":1,"title":"Mojang republished 26.3: cccc3333"},{"number":2,"title":"Other"}]'
|
||||
self.assertEqual(mr.open_issues_titled("26.3", runner=lambda *a, **k: listed),
|
||||
{"Mojang republished 26.3: cccc3333": 1})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daily check: did Mojang republish the pinned version JSON (.github/workflows/mojang-republish.yml)?
|
||||
|
||||
python3 tools/ci/mojang_republish.py [--dry-run]
|
||||
|
||||
Fetches Mojang's public version manifest and looks at the pinned version's entry. If its sha1 is the lock's
|
||||
`manifest_sha1` or one of `accepted_manifest_sha1` (the rule of tools/lab.py `manifest_accepted`), it prints one
|
||||
line and stops. Otherwise it downloads that version JSON and compares the client and server jar sha1s and the Java
|
||||
major version with the lock, then opens an issue "Mojang republished 26.3: <sha8>" with the sha1s, a snippet to
|
||||
paste into research-lock.json, and whether the jars are unchanged. An issue with that title, open or closed, is not opened again or commented on (no daily noise).
|
||||
|
||||
The lock holds no library hashes, so libraries cannot be verified from it: "jars unchanged" covers the two jars
|
||||
and the Java version only. Nothing from Mojang is stored; only hashes go in the issue. Needs the gh CLI;
|
||||
--dry-run prints what it would do.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
LOCK = ROOT / "research-lock.json"
|
||||
MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||||
TITLE = "Mojang republished {version}: "
|
||||
|
||||
|
||||
def fetch_json(url):
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def accepted(game, checksum):
|
||||
"""Same rule as tools/lab.py manifest_accepted."""
|
||||
return checksum == game["manifest_sha1"] or checksum in game.get("accepted_manifest_sha1", [])
|
||||
|
||||
|
||||
def plan(lock, manifest, fetch, open_issues):
|
||||
"""The action to take: None when the manifest is known, else {kind: open|skip, number, title, body, jars_unchanged}.
|
||||
|
||||
open_issues: {title: number} of the issues, open or closed (a known one is not reported again). fetch(url) -> the JSON at url."""
|
||||
game = lock["minecraft"]
|
||||
version = game["version"]
|
||||
entry = next((v for v in manifest.get("versions", []) if v.get("id") == version), None)
|
||||
if entry is None:
|
||||
raise ValueError(f"{version} is not in Mojang's version manifest")
|
||||
checksum = entry["sha1"]
|
||||
if accepted(game, checksum):
|
||||
return None
|
||||
detail = fetch(entry["url"])
|
||||
downloads = detail.get("downloads", {})
|
||||
found = {
|
||||
"client_sha1": downloads.get("client", {}).get("sha1"),
|
||||
"server_sha1": downloads.get("server", {}).get("sha1"),
|
||||
"java_major": detail.get("javaVersion", {}).get("majorVersion"),
|
||||
}
|
||||
changed = sorted(key for key, value in found.items() if value != game[key])
|
||||
asset_index = detail.get("assetIndex", {}).get("sha1")
|
||||
verdict = ("Jars unchanged (client, server, Java major match the lock; libraries are not in the lock and were NOT "
|
||||
"checked): the version JSON differs only elsewhere, likely the asset index."
|
||||
if not changed else f"JARS CHANGED ({', '.join(changed)}): do not accept.")
|
||||
lock_snippet = {"accepted_manifest_sha1": game.get("accepted_manifest_sha1", []) + [checksum]}
|
||||
if asset_index and asset_index not in game.get("accepted_asset_index_sha1", []):
|
||||
lock_snippet["accepted_asset_index_sha1"] = game.get("accepted_asset_index_sha1", []) + [asset_index]
|
||||
body = (f"Mojang's manifest now lists a {version} version JSON the lock does not know.\n\n"
|
||||
f"- manifest sha1: `{checksum}`\n- url: {entry['url']}\n- assetIndex sha1: `{asset_index}`\n"
|
||||
f"- client sha1: `{found['client_sha1']}`, server sha1: `{found['server_sha1']}`, "
|
||||
f"Java major: `{found['java_major']}`\n\n**{verdict}**\n\n")
|
||||
if not changed:
|
||||
body += ("To accept it, update `minecraft` in `research-lock.json` (append these to the existing lists):\n\n"
|
||||
f"```json\n{json.dumps(lock_snippet, indent=2)}\n```\n")
|
||||
title = TITLE.format(version=version) + checksum[:8]
|
||||
number = open_issues.get(title)
|
||||
return {"kind": "skip" if number else "open", "number": number, "title": title, "body": body,
|
||||
"jars_unchanged": not changed}
|
||||
|
||||
|
||||
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 open_issues_titled(version, dry_run=False, runner=gh):
|
||||
"""{title: number} of issues, open or closed, whose title starts with the republish prefix (no label needed)."""
|
||||
prefix = TITLE.format(version=version)
|
||||
listed = json.loads(runner("issue", "list", "--state", "all", "--limit", "200", "--search",
|
||||
f'"{prefix.strip()}" in:title', "--json", "number,title") or "[]")
|
||||
return {issue["title"]: issue["number"] for issue in listed if issue["title"].startswith(prefix)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
lock = json.loads(LOCK.read_text())
|
||||
manifest = fetch_json(MANIFEST_URL)
|
||||
version = lock["minecraft"]["version"]
|
||||
try:
|
||||
listed = open_issues_titled(version)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
if not args.dry_run:
|
||||
raise
|
||||
listed = {}
|
||||
action = plan(lock, manifest, fetch_json, listed)
|
||||
if action is None:
|
||||
print(f"{version}: Mojang's version JSON is known to the lock, nothing to do.")
|
||||
return 0
|
||||
print(f"{action['kind']}: {action['title']} (jars unchanged: {action['jars_unchanged']})")
|
||||
if action["kind"] == "open":
|
||||
gh("issue", "create", "--title", action["title"], "--body", action["body"], dry_run=args.dry_run)
|
||||
if args.dry_run:
|
||||
print(action["body"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user