From dc2f9ad9eac3cd78ff25acd975dd93775c5cb9d0 Mon Sep 17 00:00:00 2001 From: "Flemming N. Larsen" Date: Tue, 4 Aug 2026 21:56:31 +0200 Subject: [PATCH] feat: validate optional Rumble client image pin --- README.md | 2 ++ scripts/validate.py | 16 ++++++++++++++-- tests/test_rumble_data.py | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a715584..c97523d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Run the focused suite with `python -m unittest discover -s tests -v`. It uses on `catalog.json` is a local, reviewed copy of the generated Rumble bot catalog. Run `python scripts/sync_catalog.py --root .` to refresh it from its declared HTTPS source; the scheduled workflow performs the same synchronization hourly. +`engine.json.clientImage` is optional installation guidance for the recommended Rumble Client Docker distribution. Once an eligible Tank Royale release and client image exist, it contains an immutable `ghcr.io/...@sha256:...` reference; ranked compatibility continues to use `behaviorVersion`. + ## Submit results Before a client can submit ranked results, its forge account must be registered through a reviewed pull request adding `clients/.json`. The client then creates an issue labelled `result-submission`, with a `[result]` title and exactly one fenced JSON batch envelope. See [CONTRIBUTING.md](CONTRIBUTING.md) for the contract and limits. diff --git a/scripts/validate.py b/scripts/validate.py index 47c79d7..808db30 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import re import uuid from dataclasses import dataclass from datetime import datetime @@ -29,6 +30,7 @@ "thirdPlaces", ) PLACE_FIELDS = ("firstPlaces", "secondPlaces", "thirdPlaces") +CLIENT_IMAGE = re.compile(r"ghcr\.io/[a-z0-9._/-]+@sha256:[0-9a-f]{64}") class ValidationError(ValueError): @@ -90,9 +92,19 @@ def active_bots(root: Path) -> dict[tuple[str, str], dict[str, Any]]: return {(str(bot.get("name")), str(bot.get("version"))): bot for bot in bots if bot.get("status") == "active"} +def engine_pin(root: Path) -> dict[str, Any]: + """Return the engine pin after validating its additive client image guidance.""" + engine = read_json(root / "engine.json") + require(engine.get("schemaVersion") == SCHEMA_VERSION, "engine.json has an unsupported schemaVersion") + client_image = engine.get("clientImage") + require(client_image is None or (isinstance(client_image, str) and CLIENT_IMAGE.fullmatch(client_image) is not None), + "engine.json.clientImage must be an immutable GHCR SHA-256 reference") + return engine + + def game_settings(root: Path, game_type: str) -> tuple[dict[str, Any], int]: """Return validated V1 settings and the number of bots represented by each result entry.""" - engine = read_json(root / "engine.json") + engine = engine_pin(root) games = engine.get("gameTypes") require(isinstance(games, dict) and game_type in games, f"unsupported gameType `{game_type}`") settings = games[game_type] @@ -143,7 +155,7 @@ def validate_result(root: Path, record: Any, *, account: str, client_ids: set[st engine = record["engine"] require(isinstance(engine, dict), "engine must be an object") behavior_version = require_int32(engine.get("behaviorVersion"), "engine.behaviorVersion must be a positive signed 32-bit integer", minimum=1) - configured_engine = read_json(root / "engine.json") + configured_engine = engine_pin(root) require(behavior_version == configured_engine.get("behaviorVersion"), "engine.behaviorVersion does not match engine.json") game_type = require_string(record["gameType"], "gameType must be a non-empty string") diff --git a/tests/test_rumble_data.py b/tests/test_rumble_data.py index b29f618..e78ccd3 100644 --- a/tests/test_rumble_data.py +++ b/tests/test_rumble_data.py @@ -16,6 +16,7 @@ from compact import compact from ingest import ingest from sync_catalog import synchronized_catalog +from validate import ValidationError, engine_pin class RumbleDataTests(unittest.TestCase): @@ -64,6 +65,21 @@ def testRDA001_IntegrationPositive_valid_batch_becomes_immutable_fact_and_projec needed = json.loads((self.root / "matchmaking/matches_needed-1v1.json").read_text(encoding="utf-8")) self.assertIn(["Alpha 1.0", "Charlie 1.0"], [pair["bots"] for pair in needed["priorityPairs"]]) + def testUnitPositive_engine_pin_accepts_optional_immutable_client_image(self) -> None: + configured = json.loads((self.root / "engine.json").read_text(encoding="utf-8")) + configured["clientImage"] = "ghcr.io/robocode-dev/rumble-client@sha256:" + "a" * 64 + self.write("engine.json", configured) + + self.assertEqual(configured["clientImage"], engine_pin(self.root)["clientImage"]) + + def testUnitNegative_engine_pin_rejects_mutable_client_image_tag(self) -> None: + configured = json.loads((self.root / "engine.json").read_text(encoding="utf-8")) + configured["clientImage"] = "ghcr.io/robocode-dev/rumble-client:latest" + self.write("engine.json", configured) + + with self.assertRaisesRegex(ValidationError, "immutable GHCR"): + engine_pin(self.root) + def testRDA001_IntegrationPositive_valid_records_survive_invalid_batch_neighbors(self) -> None: envelope = self.envelope() invalid = self.envelope(battle_id="a290f1ee-6c54-4b01-90e6-d701748f0851")["results"][0]