CI: daily check for a republished Mojang version JSON (#3333)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
JoansitohandClaude Sonnet 5.5 committed 2026-11-01 02:47:32 +01:00
1 parent 14262d0595
commit 4647ce7af7
3 files changed
+228

No files matched your search

+31
View File
@@ -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' || '' }}
+76
View File
@@ -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()
+121
View File
@@ -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())