chore: merge the handheld launchers

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 16:56:52 +02:00
commit af32a0c6bf
27 files changed
+2099 -259

No files matched your search

+100 -36
View File
@@ -1,14 +1,25 @@
#!/usr/bin/env python3
"""Build the PortMaster port in ports/RetroBIOS into dist/RetroBIOS.zip.
"""Build the handheld launchers in ports/ into their archives in dist/.
The archive has the layout PortMaster's own release build gives a port
RetroBIOS.zip the PortMaster port (ports/RetroBIOS)
RetroBIOS-batocera.zip Batocera and Knulli, from Ports (ports/batocera)
RetroBIOS.muxapp muOS, from Applications (ports/muos)
RetroBIOS-tools.zip the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS
and AmberELEC (ports/tools)
Each carries ports/shared (tui.txt, which runs the full-screen interface,
rbevents.py and the controller mapping) beside its own launcher. The
PortMaster archive has the layout PortMaster's own release build gives a port
(PortMaster-New tools/build_release.py): the launch script at the top, and
one folder holding port.json, gameinfo.xml, the README as <name>.md, the
licenses and the port's files. install.py is copied from the repository
root, and only when it is the installer the bootstraps pin, so the port
runs the same bytes as the one-line command of the same commit. Members are
written as the packs write theirs: fixed timestamps, sorted names, the
executable bit read from the source, so the same tree gives the same zip.
licenses and the port's files. A muOS application archive is extracted into
the applications folder, so its top folder is the application.
install.py is copied from the repository root, and only when it is the
installer the bootstraps pin, so every launcher runs the same bytes as the
one-line command of the same commit. Members are written as the packs write
theirs: fixed timestamps, sorted names, the executable bit read from the
source, so the same tree gives the same archives.
Usage:
python scripts/build_port.py
@@ -27,10 +38,14 @@ from pathlib import Path
from artifacts import ArtifactLockBusy, artifact_lock
REPO_ROOT = Path(__file__).resolve().parent.parent
PORT_SOURCE = REPO_ROOT / "ports" / "RetroBIOS"
PORT_DIR = "retrobios"
PORT_SCRIPT = "RetroBIOS.sh"
PORT_ZIP = "RetroBIOS.zip"
BATOCERA_ZIP = "RetroBIOS-batocera.zip"
MUOS_APP = "RetroBIOS.muxapp"
TOOLS_ZIP = "RetroBIOS-tools.zip"
# Every archive a release attaches, in the order the release notes list them.
ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP)
_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE)
@@ -42,66 +57,115 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str:
return found.group(1)
def port_members(root: Path = REPO_ROOT) -> list[tuple[str, Path]]:
"""(name in the archive, source file) for every member, sorted by name."""
source = root / "ports" / "RetroBIOS"
def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]:
"""What every launcher runs: the installer, the interface launcher, the
event reader, the licenses, and the gptokeyb mapping where one is read."""
shared = root / "ports" / "shared"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_DIR}/port.json", source / "port.json"),
(f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"),
(f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"),
(f"{PORT_DIR}/install.py", root / "install.py"),
(f"{PORT_DIR}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
(f"{PORT_DIR}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
(f"{folder}/install.py", root / "install.py"),
(f"{folder}/tui.txt", shared / "tui.txt"),
(f"{folder}/rbevents.py", shared / "rbevents.py"),
(f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
(f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
]
members += [
(f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path)
for path in sorted((source / PORT_DIR).rglob("*"))
if path.is_file() and "__pycache__" not in path.parts
]
names = [name for name, _path in members]
if mapping:
members.append((f"{folder}/retrobios.gptk", shared / "retrobios.gptk"))
return members
def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]:
"""(name in the archive, source file) for every member of *name*, sorted."""
ports = root / "ports"
if name == PORT_ZIP:
source = ports / "RetroBIOS"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_DIR}/port.json", source / "port.json"),
(f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"),
(f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"),
*_engine(PORT_DIR, root),
]
members += [
(f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path)
for path in sorted((source / PORT_DIR).rglob("*"))
if path.is_file() and "__pycache__" not in path.parts
]
elif name == BATOCERA_ZIP:
source = ports / "batocera"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_SCRIPT}.keys", source / f"{PORT_SCRIPT}.keys"),
(f"{PORT_DIR}/README.md", source / "README.md"),
# evmapy reads RetroBIOS.sh.keys: no gptokeyb there.
*_engine(PORT_DIR, root, mapping=False),
]
elif name == MUOS_APP:
source = ports / "muos"
members = [
("RetroBIOS/mux_launch.sh", source / "mux_launch.sh"),
("RetroBIOS/muxterm.gptk", source / "muxterm.gptk"),
("RetroBIOS/README.md", source / "README.md"),
*_engine("RetroBIOS", root),
]
elif name == TOOLS_ZIP:
source = ports / "tools"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_DIR}/README.md", source / "README.md"),
*_engine(PORT_DIR, root),
]
else:
raise ValueError(f"no launcher archive is named {name}")
names = [member for member, _path in members]
if len(set(names)) != len(names):
raise ValueError("two port members share a name")
raise ValueError(f"two members of {name} share a name")
return sorted(members)
def build_port(output: Path, root: Path = REPO_ROOT) -> Path:
"""Write the port archive to *output* and return it."""
def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path:
"""Write the launcher archive *name* to *output* and return it."""
pinned = pinned_installer_sha256(root)
actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest()
if actual != pinned:
raise ValueError(
f"install.py is {actual[:12]}, install.sh pins {pinned[:12]}: "
"re-pin the bootstraps before building the port"
"re-pin the bootstraps before building the launchers"
)
# The packs' member writer: imported here, as it loads the whole
# pack builder, which a caller reading PORT_ZIP does not need.
# pack builder, which a caller reading ARCHIVES does not need.
from generate_pack import _add_pack_member # noqa: PLC0415
members = archive_members(name, root)
output.parent.mkdir(parents=True, exist_ok=True)
scratch = output.with_name(f".{output.name}.tmp")
with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for name, path in port_members(root):
_add_pack_member(zf, str(path), name)
for member, path in members:
_add_pack_member(zf, str(path), member)
scratch.replace(output)
return output
def build_port(output: Path, root: Path = REPO_ROOT) -> Path:
"""Write the PortMaster port archive to *output* and return it."""
return build_archive(PORT_ZIP, output, root)
def main() -> int:
parser = argparse.ArgumentParser(
allow_abbrev=False, description="Build the RetroBIOS PortMaster port"
allow_abbrev=False, description="Build the RetroBIOS handheld launchers"
)
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist",
help="where RetroBIOS.zip is written (default: dist/)")
help="where the archives are written (default: dist/)")
args = parser.parse_args()
try:
with artifact_lock(str(args.output_dir)):
path = build_port(args.output_dir / PORT_ZIP)
paths = [build_archive(name, args.output_dir / name) for name in ARCHIVES]
except (ArtifactLockBusy, OSError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
digest = hashlib.sha256(path.read_bytes()).hexdigest()
print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})")
for path in paths:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})")
return 0
+6 -6
View File
@@ -3,8 +3,8 @@
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 the handheld port (RetroBIOS.zip) when
the list names it, the parts of a pack must add up to the download
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
size the README prints, and nothing else may be attached. 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.
@@ -27,7 +27,7 @@ from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
from build_port import PORT_ZIP
from build_port import ARCHIVES
from check_release_assets import digests_of
from largefiles import LARGE_FILES_REPO
from release_record import RECORD, load_record
@@ -93,9 +93,9 @@ 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 port is a release file but no pack: release.json records
# packs, and the checksum list alone vouches for the port.
for name in sorted(set(sums) - set(recorded) - {PORT_ZIP}):
# 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)):
findings.append(Finding("unrecorded", name, f"listed in {SUMS}, absent from release.json"))
wanted = {**sums, **(local_files or {})}