diff --git a/scripts/check_release.py b/scripts/check_release.py index 8e16288fa..41b422336 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -3,13 +3,13 @@ Run on the draft before publishing it, and on the published release at any time. Every pack asset release.json names must be on the release at the sha256 -SHA256SUMS.txt lists for it, as must each handheld launcher (RetroBIOS.zip -and the others build_port.py writes) the list names, the parts of a pack must add up to the download +SHA256SUMS.txt lists for it, the parts of a pack must add up to the download size the README prints, and nothing else may be attached. The record holds one pack for each platform the registry marks release_pack, and no other -pack. The installer and the file lists are not release files: a package pins -them from the tag, so a reader of the release page sees the packs and nothing -to puzzle over. An upload that +pack. The installer, the file lists and the handheld launchers are not +release files: a package pins the first two from the tag, and the site +serves the launchers built from its own commit (sitehandheld.py), so a +reader of the release page sees the packs and nothing to puzzle over. An upload that failed in the loop, or a part re-uploaded from an older build, otherwise publishes a release that `sha256sum --check` rejects for every user. @@ -31,7 +31,6 @@ from dataclasses import dataclass from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) -from build_port import ARCHIVES from check_release_assets import digests_of from common import list_release_pack_platforms, require_directory from largefiles import LARGE_FILES_REPO @@ -112,9 +111,7 @@ def compare_release( findings.append(Finding("extra", name, "attached but neither recorded nor listed")) for name in sorted(set(recorded) - set(sums)): findings.append(Finding("unlisted", name, f"absent from {SUMS}")) - # The handheld launchers are release files but no packs: release.json - # records packs, and the checksum list alone vouches for the launchers. - for name in sorted(set(sums) - set(recorded) - set(ARCHIVES)): + for name in sorted(set(sums) - set(recorded)): findings.append(Finding("unrecorded", name, f"listed in {SUMS}, absent from release.json")) wanted = {**sums, **(local_files or {})} diff --git a/tests/test_check_release.py b/tests/test_check_release.py index c821b7bab..8555eaf39 100644 --- a/tests/test_check_release.py +++ b/tests/test_check_release.py @@ -15,6 +15,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import build_port # noqa: E402 from check_release import SIGNATURE, SUMS, Asset, compare_release, parse_sums A = "a" * 64 @@ -97,20 +98,21 @@ class ReleaseMatchesWhatWasBuilt(unittest.TestCase): self.assertIn(("unrecorded", "Other_BIOS_Pack.zip"), kinds) self.assertIn(("missing", "Other_BIOS_Pack.zip"), kinds) - def test_the_handheld_port_is_vouched_for_by_the_checksum_list(self): - sums = {**SUMS_LIST, "RetroBIOS.zip": A} - assets = _assets() - self.assertEqual( - _kinds(compare_release(RECORD, sums, assets, "v2026.10.10")), - {("missing", "RetroBIOS.zip")}, - ) - assets["RetroBIOS.zip"] = Asset(70, A) - self.assertEqual(compare_release(RECORD, sums, assets, "v2026.10.10"), []) - assets["RetroBIOS.zip"] = Asset(70, B) - self.assertEqual( - _kinds(compare_release(RECORD, sums, assets, "v2026.10.10")), - {("content", "RetroBIOS.zip")}, - ) + def test_a_handheld_launcher_is_no_release_file(self): + """The site serves the launchers built from its own commit: one in + the checksum list or among the assets is refused.""" + for name in build_port.ARCHIVES: + with self.subTest(name=name): + sums = {**SUMS_LIST, name: A} + assets = _assets() + assets[name] = Asset(70, A) + self.assertEqual(_kinds(compare_release(RECORD, sums, assets, "v2026.10.10")), + {("unrecorded", name)}) + assets = _assets() + assets[name] = Asset(70, A) + self.assertEqual( + _kinds(compare_release(RECORD, SUMS_LIST, assets, "v2026.10.10")), + {("extra", name)}) def test_parts_must_add_up_to_the_printed_download_size(self): assets = _assets() diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 68f530793..bf77786c5 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -374,10 +374,16 @@ class TheArchives(unittest.TestCase): with self.subTest(archive=name), self.assertRaises(ValueError): build_port.build_archive(name, self.out / name, root) - def test_the_release_process_attaches_each(self): + def test_the_site_serves_each_and_the_release_none(self): + """Each archive carries install.py: the site serves it from its own + commit (sitehandheld.py), and the release steps never build one.""" text = (REPO_ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8") + self.assertNotIn("scripts/build_port.py", text) + installer = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") for name in build_port.ARCHIVES: - self.assertIn(name, text) + with self.subTest(archive=name): + self.assertNotIn(name, text) + self.assertIn(f"](../get/handheld/{name})", installer) FAKE_INSTALL = r''' diff --git a/tests/test_site_wiki.py b/tests/test_site_wiki.py index 4bb1e8378..57684cde9 100644 --- a/tests/test_site_wiki.py +++ b/tests/test_site_wiki.py @@ -33,6 +33,7 @@ from __future__ import annotations import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache +import fnmatch import json import re import sys @@ -48,6 +49,7 @@ sys.path.insert(0, str(REPO_ROOT)) import sitediagrams # noqa: E402 from siteguides import INSTALL_SH # noqa: E402 from tests import sitepages # noqa: E402 +import build_port # noqa: E402 from check_release import SUMS, Asset, compare_release # noqa: E402 @@ -870,15 +872,33 @@ class PinnedInstaller(unittest.TestCase): kinds = {(f.kind, f.name) for f in compare_release(record, sums, assets, "v1")} self.assertEqual(kinds, {("unrecorded", "install.py"), ("unrecorded", "retroarch.json")}) + def test_a_launcher_attached_to_the_release_is_refused(self): + """The handheld launchers carry install.py: the site serves them + from its own commit, the release never does.""" + record = {"tag": "v1", "packs": {"P.zip": {"download_size": 5, "assets": ["P.zip"]}}} + sums = _sums_with(["P.zip", *build_port.ARCHIVES]) + assets = {name: Asset(5, digest) for name, digest in sums.items()} + assets[SUMS] = Asset(1, None) + kinds = {(f.kind, f.name) for f in compare_release(record, sums, assets, "v1")} + self.assertEqual(kinds, {("unrecorded", name) for name in build_port.ARCHIVES}) + def test_the_release_steps_ship_none(self): text = _wiki("release-process.md") self.assertNotIn("cp install.py", text) sums = re.search(r"sha256sum [^>]+> SHA256SUMS.txt", text, re.S).group(0) upload = re.search(r"for f in dist/SHA256SUMS.txt.*?; do", text, re.S).group(0) - for name in ("install.py", "install.sh", "install.ps1", ".json"): + for name in ("install.py", "install.sh", "install.ps1", ".json", "RetroBIOS", + ".muxapp", "build_port", " *.zip", "/*.zip"): with self.subTest(name=name): self.assertNotIn(name, sums) self.assertNotIn(name, upload) + # A launcher built into dist/ is never caught by the glob either. + for archive in build_port.ARCHIVES: + with self.subTest(archive=archive): + self.assertFalse(fnmatch.fnmatch(archive, "*_BIOS_Pack*.zip")) + self.assertIn("*_BIOS_Pack*.zip", sums) + self.assertIn("*_BIOS_Pack*.zip", upload) + self.assertNotIn("scripts/build_port.py", text) def test_the_installer_page_pins_one_commit(self): # v2026.10.10 carries no install.py: until a release does, the diff --git a/tests/test_split_pack.py b/tests/test_split_pack.py index 48983f8fc..6d2d0a144 100644 --- a/tests/test_split_pack.py +++ b/tests/test_split_pack.py @@ -278,7 +278,7 @@ class ReleaseProcessPublishesArchives(unittest.TestCase): def test_the_checksums_are_those_of_the_published_files(self): self.assertLess( self.text.index("scripts/split_pack.py"), - self.text.index("sha256sum *.zip"), + self.text.index("sha256sum *_BIOS_Pack*.zip"), ) diff --git a/wiki/release-process.md b/wiki/release-process.md index d910bfe48..c23ca6089 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -248,25 +248,18 @@ python scripts/pipeline.py # which install.py --check reads to name a part missing from a folder. python scripts/split_pack.py dist/ -# 2b. The handheld launchers, each carrying the installer the bootstraps pin: -# RetroBIOS.zip (the PortMaster port), RetroBIOS-batocera.zip (Batocera -# and Knulli Ports), RetroBIOS.muxapp (muOS Applications) and -# RetroBIOS-tools.zip (the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS, -# AmberELEC). They are release files, listed in the checksums below, and -# check_release.py compares each asset with its line there. -python scripts/build_port.py - -# 2c. The release carries the packs, the handheld launchers and the signed -# checksum list, and nothing else: a beginner reads that page to pick one -# file. A package pins install.py and its file list from the tag -# (installer page, "For packagers"), and check_release.py refuses an -# installer or a file list attached to the release. +# 2b. The release carries the packs and the signed checksum list, and +# nothing else: a beginner reads that page to pick one file. A package +# pins install.py and its file list from the tag (installer page, "For +# packagers"). The handheld launchers carry install.py too, so the site +# builds and serves them from its own commit. check_release.py refuses an +# installer, a file list or a launcher attached to the release. # 3. Checksums of the files as published, then sign the list. The checksums # answer corruption, and the signature answers a rewritten release, which is # the one thing a checksum published beside its own artifacts cannot # answer. -(cd dist && sha256sum *.zip RetroBIOS.muxapp > SHA256SUMS.txt) +(cd dist && sha256sum *_BIOS_Pack*.zip > SHA256SUMS.txt) ssh-keygen -Y sign -f ~/.ssh/retrobios_signing -n file dist/SHA256SUMS.txt # 4. Record what each pack holds, read from the archives just checked: @@ -284,14 +277,16 @@ python scripts/generate_site.py # maintainer's own text in tmp/whats-new.md, appended as written. The # script refuses a text that judges another project ("forum list") or # promises what the gap analysis contradicts ("never stops"): it says -# what changed and how it was checked. +# what changed and how it was checked. The notes end with everyone who +# helped since the start, read with gh (--thanks FILE passes a checked +# list), and the full changelog from the previous release's record. python scripts/release_notes.py --whats-new tmp/whats-new.md --output tmp/notes.md # Create the release as a DRAFT, upload every asset, and only then publish it. # A public release with half its assets is a broken download for everyone # during the whole upload. gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file tmp/notes.md -for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip dist/RetroBIOS.muxapp; do +for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*_BIOS_Pack*.zip; do [ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber done # A count does not see a failed upload beside a stray file, nor a part