diff --git a/schemas/pack-manifest.schema.json b/schemas/pack-manifest.schema.json index 4b2a9910f..435c1c540 100644 --- a/schemas/pack-manifest.schema.json +++ b/schemas/pack-manifest.schema.json @@ -54,6 +54,14 @@ "type": "array", "uniqueItems": true, "items": { "enum": ["systems", "cores", "regions"] } + }, + "install_list": { + "description": "The install manifest built with the same choices as the pack, as install/.json holds it. With the pack alone, the installer narrows by system, core and region from it. validate_schemas.py checks it against install-manifest.schema.json.", + "type": "object" + }, + "targets": { + "description": "The platform's cores per hardware target, as install/targets/.json publishes them. With the pack alone, the installer applies the card's board and accepts --target from it. validate_schemas.py checks it against target-manifest.schema.json.", + "type": "object" } } } diff --git a/scripts/validate_schemas.py b/scripts/validate_schemas.py index 42d8590a8..3430d6cb3 100644 --- a/scripts/validate_schemas.py +++ b/scripts/validate_schemas.py @@ -230,6 +230,12 @@ def _validate_pack_manifests(dist: Path) -> list[str]: def _scan_pack_manifests(dist: Path) -> list[str]: validator = _validator("pack-manifest.schema.json") + # The lists a pack carries for the installer, each under its own + # contract: the schemas name no other file, so they are checked here. + carried = { + "install_list": _validator("install-manifest.schema.json"), + "targets": _validator("target-manifest.schema.json"), + } def _label(path: Path) -> str: resolved = path.resolve() @@ -255,6 +261,9 @@ def _scan_pack_manifests(dist: Path) -> list[str]: out.append(f"{label}: {exc}") continue out.extend(_errors(validator, document, label)) + for key, inner in carried.items(): + if isinstance(document, dict) and key in document: + out.extend(_errors(inner, document[key], f"{label}:{key}")) except (OSError, zipfile.BadZipFile) as exc: out.append(f"{_label(archive)}: {exc}") return out diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 2701f5a00..f273e85c2 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -2139,6 +2139,42 @@ class InstallerBoundaryRegressions(unittest.TestCase): self.assertTrue(errors, "an invalid in-archive manifest must be reported") self.assertTrue(any("sha1" in message for message in errors), errors) + @unittest.skipUnless(HAS_JSONSCHEMA, "validating a schema needs jsonschema") + def test_a_pack_carries_its_install_list_and_board_lists(self): + """pack_systems puts the install list and the per-board core lists + in the pack's manifest.json, which install.py reads with the pack + alone. The pack schema refused both keys, and each is checked under + its own contract.""" + import generate_pack + import validate_schemas + + install_list = json.loads((ROOT / "install" / "romm.json").read_text(encoding="utf-8")) + targets = {"browser": None, "rpi4": {"cores": ["mgba"], "images": ["rpi4"]}} + document = { + "schema_version": 1, "version": 1, + "generator": "retrobios generate_pack.py", + "generated": "2026-08-10T00:00:00Z", "files": [], + "summary": {"total_files": 0, "verified": 0, "untracked": 0, "errors": 0}, + "errors": [], + } + generate_pack.annotate_pack_systems( + document, generate_pack.pack_systems(install_list, targets)) + self.assertIn("install_list", document) + self.assertIn("targets", document) + with tempfile.TemporaryDirectory(dir=TMP_ROOT) as directory: + dist = Path(directory) + with zipfile.ZipFile(dist / "Pack.zip", "w") as archive: + archive.writestr("manifest.json", json.dumps(document)) + self.assertEqual(validate_schemas._validate_pack_manifests(dist), []) + + broken = {**document, "targets": {"rpi4": [1]}, + "install_list": {**install_list, "files": "none"}} + with zipfile.ZipFile(dist / "Pack.zip", "w") as archive: + archive.writestr("manifest.json", json.dumps(broken)) + errors = validate_schemas._validate_pack_manifests(dist) + self.assertTrue(any(":targets:" in message for message in errors), errors) + self.assertTrue(any(":install_list:" in message for message in errors), errors) + def test_null_core_list_keeps_the_other_targets(self): """A target without a core inventory must not void the manifest.