chore: merge the launchers served by the site

This commit is contained in:
Abdessamad Derraz committed 2026-11-10 12:03:29 +01:00
commit fba9a0b511
17 files changed
+837 -80

No files matched your search

+5
View File
@@ -15,6 +15,7 @@ on:
- "scripts/archive_build.py"
- "scripts/artifacts.py"
- "scripts/auto_fetch.py"
- "scripts/build_port.py"
- "scripts/common.py"
- "scripts/cross_reference.py"
- "scripts/crypto_verify.py"
@@ -52,6 +53,7 @@ on:
- "scripts/siteagents.py"
- "scripts/site_markdown.py"
- "scripts/sitebootstrap.py"
- "scripts/sitehandheld.py"
- "scripts/sitecollection.py"
- "scripts/sitediagrams.py"
- "scripts/siteentries.py"
@@ -89,6 +91,9 @@ on:
- "install.py"
- "install.sh"
- "install.ps1"
# The handheld launchers it serves are built from ports/ at the same
# commit (scripts/sitehandheld.py).
- "ports/**"
- "database.json"
- "release.json"
- "derivable.json"
+9 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Build the handheld launchers in ports/ into their archives in dist/.
"""Build the handheld launchers in ports/ into their archives.
RetroBIOS.zip the PortMaster port (ports/RetroBIOS)
RetroBIOS-batocera.zip Batocera and Knulli, from Ports (ports/batocera)
@@ -17,13 +17,16 @@ 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
one-line command of the same commit. The site builds them at each deploy
and serves them under get/handheld/ (scripts/sitehandheld.py): they are no
release files, and are written to tmp/handheld/ by default, away from the
packs of dist/. 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
python scripts/build_port.py --output-dir tmp/port
python scripts/build_port.py --output-dir tmp/port # default: tmp/handheld
"""
from __future__ import annotations
@@ -44,7 +47,7 @@ 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.
# Every launcher archive the site serves.
ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP)
# Which release pack serves each platform, read by tui.txt offline.
PACKS_TABLE = "packs.txt"
@@ -201,8 +204,8 @@ def main() -> int:
parser = argparse.ArgumentParser(
allow_abbrev=False, description="Build the RetroBIOS handheld launchers"
)
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist",
help="where the archives are written (default: dist/)")
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "tmp" / "handheld",
help="where the archives are written (default: tmp/handheld/)")
args = parser.parse_args()
try:
with artifact_lock(str(args.output_dir)):
+6 -9
View File
@@ -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 {})}
+7 -1
View File
@@ -8,7 +8,8 @@ Each table sits between `<!-- generated: NAME -->` and
`<!-- /generated: NAME -->`. What install.py knows is written from it: the
options and their arguments from its parser, the event kinds and their keys
from EVENT_KEYS, the exit codes from its sys.exit calls, the RETROBIOS_PAD
values and each route's keys from TUI_ROUTES. The prose cells (what an option
values and each route's keys from TUI_ROUTES, the handheld launchers from
sitehandheld.LAUNCHERS. The prose cells (what an option
does, when an event comes) are kept as written, matched by their row. A row
install.py gains has no prose yet: the generator names it and writes nothing,
and the row is added by hand inside the markers, its first cell holding the
@@ -45,6 +46,7 @@ import zipfile
from pathlib import Path, PurePosixPath
from typing import Callable
import sitehandheld
from artifacts import write_text_atomic
ROOT = Path(__file__).resolve().parent.parent
@@ -622,6 +624,10 @@ def render(page: str) -> str:
"card-markers": lambda _body: _table(["On the card", "System", "Files go to"],
card_marker_rows()),
"line-menu": lambda _body: line_menu(),
# Where each handheld launcher goes, written from the site's own
# description of it (sitehandheld.LAUNCHERS).
"handheld": lambda _body: "\n".join(sitehandheld.table_rows()),
"handheld-downloads": lambda _body: "\n".join(sitehandheld.download_rows()),
}
seen = []
with contextlib.ExitStack() as stack:
+1 -1
View File
@@ -5819,7 +5819,7 @@ def _pack_archives(output_dir: str) -> list[str]:
fail conformance, and injecting a manifest would change published bytes.
Every name the builder gives holds `_BIOS_Pack`, then the system tag of
a --system platform pack (`_BIOS_Pack_Playstation.zip`): build_port.py
writes its launcher archives (RetroBIOS.zip) to the same dist/, and
wrote its launcher archives (RetroBIOS.zip) to the same dist/, and
taking every ZIP rewrote them with a manifest after build_port had
printed their hashes.
"""
+15
View File
@@ -127,6 +127,7 @@ from siteguides import (
import archive_build
import release_record
import sitebootstrap
import sitehandheld
import sitemeasurements
# The Starlight project, and where its pages go inside it.
@@ -859,6 +860,7 @@ def generate_get_page(platform_name: str) -> str:
f"[Why no ZIP?](../{NO_ZIP_FAQ})",
"",
]
lines += sitehandheld.platform_block(platform_name, _REPO_ROOT)
lines += _next_block([
f"[{display}](../platforms/{platform_name}.md#install): the folder, "
"and how to run the command on the device",
@@ -886,6 +888,11 @@ def generate_get_index() -> str:
for name in names:
display = load_platform_config(name, str(siteinputs.current().platforms)).get("platform", name)
lines.append(f"- [{get_page_title(name, display)}]({name}.md)")
# The handheld app sits with the group whose tile leads to the
# handheld guide.
if any(HANDHELD_GUIDE and tile.link == HANDHELD_GUIDE.path
for tile in GROUP_TILES.get(group, ())):
lines.append(f"- [{sitehandheld.GROUP_TITLE}]({sitehandheld.GROUP_PAGE}.md)")
lines.append("")
return "\n".join(lines)
@@ -4512,6 +4519,10 @@ def generate_handheld_guide(guide: HandheldGuide) -> str:
"",
*_guide_download(guide.uses, display, uses, None, "## Download { #download }"),
*_offline_line(guide.uses),
f"On {sitehandheld.joined(sitehandheld.group_systems(), 'or')}: "
f"[{sitehandheld.GROUP_TITLE}]"
f"(../get/{sitehandheld.GROUP_PAGE}.md).",
"",
"## Fill the card from a computer { #install }",
"",
"On Windows, note the card's letter, like **E:**. Put its BIOS folder after "
@@ -12472,6 +12483,8 @@ def _generate(args: argparse.Namespace) -> None:
(public / "robots.txt").unlink(missing_ok=True)
# The one-line commands' bootstraps, at the site's root.
sitebootstrap.write_bootstraps(public, _REPO_ROOT)
# The handheld launchers, built from the same commit (sitehandheld).
sitehandheld.write_launchers(public, _REPO_ROOT)
images_dest = public / "assets" / "images"
images_dest.mkdir(parents=True, exist_ok=True)
@@ -12820,6 +12833,8 @@ def _generate(args: argparse.Namespace) -> None:
get_dir.mkdir(parents=True, exist_ok=True)
for name in platform_names:
write_if_changed(str(get_dir / f"{name}.md"), generate_get_page(name))
write_if_changed(str(get_dir / f"{sitehandheld.GROUP_PAGE}.md"),
sitehandheld.group_page(_REPO_ROOT))
write_if_changed(str(get_dir / "index.md"), generate_get_index())
print("Generating contributing page...")
+141 -12
View File
@@ -11,6 +11,14 @@ idea per line, plain words (owner, 2026-11-10). The maintainer writes
what changed, and that text is appended as given once it holds none of the
REFUSED phrases.
The notes end with the people to thank and the full changelog, from the
previous release's record commit (the first commit release.json gave the
previous tag) to the new tag. The thanks name everyone who helped since the
repository started, read with gh: a login is only ever the one GitHub links
to the work, never one guessed from a git author name. The owner, bots and
spam are left out (LEFT_OUT). --thanks FILE passes a checked list instead
of the one gh gives.
Usage:
python scripts/release_notes.py --whats-new tmp/whats-new.md --output notes.md
"""
@@ -18,8 +26,11 @@ Usage:
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
import release_record
@@ -34,7 +45,12 @@ from common import (
from siteguides import install_command
REPO = "Abdess/retrobios"
OWNER = "Abdess"
SITE = "https://abdess.github.io/retrobios/"
# The handheld app is served by the site, built from its own commit, never
# attached to a release. The installer's handheld section opens on the
# download of every system.
HANDHELD_PAGE = "wiki/installer/#on-a-handheld-the-retrobios-port"
def _size(count: int) -> str:
@@ -79,14 +95,16 @@ def without_pack(platforms_dir: str) -> list[str]:
def no_pack_paragraph(platforms_dir: str) -> list[str]:
"""One paragraph naming the setups served by the command alone."""
"""One paragraph naming the setups served by the command alone. The
packs published are the most downloaded ones (wiki/release-process.md,
"Which platforms get a pack")."""
names = without_pack(platforms_dir)
if not names:
return []
listed = ", ".join(names[:-1]) + f" and {names[-1]}" if len(names) > 1 else names[0]
return [
(f"No ZIP for {listed}. The command above installs them."
" Add `--dry-run` to see what it would do."),
("Only the most used setups have a pack, to keep the release small."
f" For {listed}, use the command above."),
"",
]
@@ -119,31 +137,126 @@ def refused_words(text: str) -> list[str]:
return [phrase for phrase in REFUSED if phrase in lowered]
def render(record: dict, platforms_dir: str, whats_new: str) -> str:
Run = Callable[[list[str]], str]
def _run(argv: list[str]) -> str:
return subprocess.run(argv, capture_output=True, text=True, check=True).stdout
def previous_record_commit(tag: str, run: Run | None = None) -> str:
"""The commit that recorded the release before *tag*: the oldest commit
of the run of release.json changes that name the previous tag."""
run = run or _run
shas = run(["git", "log", "--format=%H", "--", "release.json"]).split()
previous, base = None, None
for sha in shas:
recorded = json.loads(run(["git", "show", f"{sha}:release.json"])).get("tag")
if previous is None:
if recorded != tag:
previous, base = recorded, sha
elif recorded == previous:
base = sha
else:
break
if base is None:
raise ValueError(f"release.json records no release before {tag}")
return base
# Accounts that never helped: the owner's own, GitHub's placeholder for a
# deleted account, and spam (issue 32 by Bappo0 and pull request 67 by
# prakashmagat50-byte, both empty or garbage).
LEFT_OUT = frozenset(("Abdess", "Abdess76", "ghost", "Bappo0", "prakashmagat50-byte"))
def _person(login: str | None) -> str | None:
"""The login to thank, None for the owner, a bot, a deleted account,
spam, or work GitHub links to no account."""
if not login or login in LEFT_OUT or login.endswith("[bot]") or login.startswith("app/"):
return None
return login
def _logins(text: str) -> set[str]:
"""One login per line, as `gh api --paginate --jq` prints them."""
return {person for line in text.splitlines() if (person := _person(line.strip()))}
def _authors(text: str) -> set[str]:
"""The authors of a `gh pr list` or `gh issue list --json author`."""
return {person for item in json.loads(text)
if not (item.get("author") or {}).get("is_bot")
and (person := _person((item.get("author") or {}).get("login")))}
def gather_thanks(run: Run | None = None, repo: str = REPO) -> list[str]:
"""Everyone who helped since the repository started, each named once:
commit authors as the commits API links them to an account and every
pull request author, merged or not, then issue authors, then whoever
commented on an issue or a pull request."""
run = run or _run
code = _logins(run(["gh", "api", "--paginate", f"repos/{repo}/commits",
"--jq", ".[] | .author.login // empty"]))
code |= _authors(run(["gh", "pr", "list", "-R", repo, "--state", "all",
"--limit", "10000", "--json", "number,author"]))
reports = _authors(run(["gh", "issue", "list", "-R", repo, "--state", "all",
"--limit", "10000", "--json", "number,author"])) - code
helped = set()
for path in (f"repos/{repo}/issues/comments", f"repos/{repo}/pulls/comments"):
helped |= _logins(run(["gh", "api", "--paginate", path, "--jq", ".[] | .user.login"]))
helped -= code | reports
lines = []
for label, people in (("Code and data", code), ("Reports", reports),
("Help in issues", helped)):
if people:
names = [f"@{login}" for login in sorted(people, key=str.casefold)]
listed = names[0] if len(names) == 1 else f"{', '.join(names[:-1])} and {names[-1]}"
lines.append(f"- **{label}**: {listed}.")
return lines
def changelog_url(base: str, tag: str, repo: str = REPO) -> str:
return f"https://github.com/{repo}/compare/{base[:9]}...{tag}"
def closing_lines(thanks: list[str], changelog: str) -> list[str]:
"""The Thanks section, left out when nobody is to thank, and the full
changelog line."""
# Folded: the lists run to a hundred names. GitHub renders the markdown
# inside only with a blank line on each side of it.
lines = (["### Thanks", "", "<details>",
"<summary>Everyone who helped since the start</summary>", "",
*thanks, "", "</details>", ""]
if thanks else [])
return [*lines, f"**Full changelog**: {changelog}", ""]
def render(record: dict, platforms_dir: str, whats_new: str,
closing: list[str] | None = None) -> str:
packs = record.get("packs", {})
folders = _extract_folders(record, platforms_dir)
tag = str(record.get("tag", ""))
lines = [
"### Install in one command",
"### Get your BIOS",
"",
"It finds your setup and its BIOS folder, then downloads only what is missing.",
"Paste this in a terminal. It finds your setup and installs the missing BIOS files.",
"",
"**Linux, macOS, Steam Deck**",
"```sh",
install_command("sh"),
"```",
"",
"**Windows (PowerShell)**",
"**Windows**",
"```powershell",
install_command("powershell"),
"```",
"",
(f"**On a handheld**: copy the `RetroBIOS` file for your system to the card"
f" ([which one]({SITE}wiki/installer/#on-a-handheld-the-retrobios-port))."),
f"**On a handheld**: [get the app for your system]({SITE}{HANDHELD_PAGE}).",
"",
"### Or download a pack",
"### Full packs",
"",
"Extract it into your BIOS folder.",
"Large downloads, for a device without internet. Extract into your BIOS folder.",
"",
]
if any(len(pack["assets"]) > 1 for pack in packs.values()):
@@ -170,6 +283,7 @@ def render(record: dict, platforms_dir: str, whats_new: str) -> str:
*no_pack_paragraph(platforms_dir),
whats_new.strip(),
"",
*(closing or []),
]
return "\n".join(lines)
@@ -182,6 +296,8 @@ def main() -> int:
help="what changed since the previous release, written by hand")
parser.add_argument("--platforms-dir", default="platforms",
help="registry read for each pack's extract folder (default: %(default)s)")
parser.add_argument("--thanks", type=Path,
help="checked Thanks lines to print instead of the ones gh gives")
parser.add_argument("--output", required=True, type=Path,
help="notes file to write, passed to gh release create --notes-file")
args = parser.parse_args()
@@ -201,7 +317,20 @@ def main() -> int:
" changed and how it was checked, not a verdict on other projects or a"
" promise the gap analysis contradicts", file=sys.stderr)
return 1
write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new))
try:
base = previous_record_commit(record["tag"])
if args.thanks:
thanks = [line for line in args.thanks.read_text(encoding="utf-8").splitlines()
if line.strip()]
else:
thanks = gather_thanks()
except (subprocess.CalledProcessError, ValueError, KeyError, OSError) as exc:
detail = getattr(exc, "stderr", "") or exc
print(f"Error: cannot gather the thanks: {str(detail).strip()}. Pass a checked list"
" with --thanks FILE", file=sys.stderr)
return 1
closing = closing_lines(thanks, changelog_url(base, record["tag"]))
write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new, closing))
print(f"Wrote {args.output} for {record['tag']}, {len(record['packs'])} packs")
return 0
+258
View File
@@ -0,0 +1,258 @@
"""The handheld launchers the site serves, and where each one goes.
The four archives scripts/build_port.py writes carry install.py pinned by
SHA-256, so they change with the installer, not with the BIOS packs. The
site builds them from the commit it is built from and serves them under
get/handheld/, beside the bootstraps that pin the same installer
(sitebootstrap.py). build_archive refuses an install.py that install.sh
does not pin: a stale pin stops the site build, the last good archives
staying served.
LAUNCHERS is the one description of where each archive goes on the card
and which menu starts it. The handheld tables of wiki/installer.md are
written from it (scripts/generate_installer_doc.py), and so is the block
each get/<platform>/ page and the handheld page carry.
"""
from __future__ import annotations
import hashlib
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
import sitemarkup as markup
from artifacts import write_bytes_atomic
from build_port import ARCHIVES, BATOCERA_ZIP, MUOS_APP, PORT_ZIP, TOOLS_ZIP, build_archive
REPO_ROOT = Path(__file__).resolve().parent.parent
# Below the site's root: get/handheld/RetroBIOS.zip.
SERVED_DIR = "get/handheld"
# The page of the systems no registered platform answers for. It shares
# its name with the handheld guide (platforms/linux-handhelds.md), so the
# sidebar files it with that guide, as get/batocera.md is with
# platforms/batocera.md.
GROUP_PAGE = "linux-handhelds"
GROUP_TITLE = "RetroBIOS app for handhelds"
@dataclass(frozen=True)
class Launcher:
"""One row of the installer's handheld table.
*systems* are named as the installer page names them. *copy_to* is
where the file goes, and *places* gives a system its own folder when
the row's systems differ there. *lead* opens the row's first cell.
"""
archive: str
systems: tuple[str, ...]
copy_to: str
start: str
extracted: bool = False
lead: str = ""
places: tuple[tuple[str, tuple[str, ...]], ...] = field(default=())
def folder_for(self, system: str) -> str:
return next((folder for folder, systems in self.places if system in systems),
self.copy_to)
LAUNCHERS: tuple[Launcher, ...] = (
Launcher(MUOS_APP, ("muOS",),
"`ARCHIVE` at the root of the card, then install it from the Archive Manager",
"Applications"),
Launcher(PORT_ZIP, ("ArkOS", "dArkOS", "ROCKNIX", "AmberELEC", "JELOS", "KNULLI"),
"PortMaster's `autoinstall` folder", "Ports, after opening PortMaster once",
lead="Any other system with [PortMaster](https://portmaster.games/)",
places=(("`/roms/ports/PortMaster/autoinstall/`", ("ROCKNIX", "AmberELEC")),
("`/roms/tools/PortMaster/autoinstall/`", ("ArkOS",)),
("`/userdata/system/.local/share/PortMaster/autoinstall/`", ("KNULLI",)))),
Launcher(BATOCERA_ZIP, ("Batocera", "KNULLI"), "`roms/ports` on the SHARE partition",
"Ports, after updating the game list", extracted=True),
Launcher(TOOLS_ZIP, ("ArkOS", "dArkOS"), "`tools` on the EASYROMS partition",
"Options, then Tools", extracted=True),
Launcher(TOOLS_ZIP, ("ROCKNIX", "JELOS"),
"`/storage/.config/modules`, over the network share or SSH", "Tools",
extracted=True),
Launcher(TOOLS_ZIP, ("AmberELEC",), "`/storage/.config/distribution/modules`", "Tools",
extracted=True),
)
# The registered platform whose get/ page answers for a system. A system
# outside this map is on the handheld page.
PLATFORM_OF = {"Batocera": "batocera", "KNULLI": "knulli", "ROCKNIX": "rocknix",
"AmberELEC": "amberelec"}
def systems() -> list[str]:
"""Every system a launcher runs on, in table order."""
return list(dict.fromkeys(name for launcher in LAUNCHERS for name in launcher.systems))
def group_systems() -> list[str]:
return [name for name in systems() if name not in PLATFORM_OF]
def joined(names: list[str], last: str = "and") -> str:
"""muOS, ArkOS and JELOS."""
return names[0] if len(names) == 1 else f"{', '.join(names[:-1])} {last} {names[-1]}"
def page_of(system: str) -> str:
"""The site page that holds the download for *system*."""
return f"get/{PLATFORM_OF.get(system, GROUP_PAGE)}.md"
def launchers_for(system: str) -> list[Launcher]:
"""The launchers of *system*, its own menu first and PortMaster last."""
own = [launcher for launcher in LAUNCHERS if system in launcher.systems]
return sorted(own, key=lambda launcher: launcher.archive == PORT_ZIP)
@dataclass(frozen=True)
class Served:
data: bytes
@property
def size(self) -> int:
return len(self.data)
@property
def sha256(self) -> str:
return hashlib.sha256(self.data).hexdigest()
_built: dict[tuple[str, str], Served] = {}
def served(name: str, root: Path = REPO_ROOT) -> Served:
"""The bytes of archive *name* as build_port builds it from *root*,
built once per run: the page states the size and hash of the file the
same run writes."""
key = (str(root.resolve()), name)
if key not in _built:
scratch_root = root / "tmp"
scratch_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=scratch_root, prefix="handheld-") as scratch:
path = build_archive(name, Path(scratch) / name, root)
_built[key] = Served(path.read_bytes())
return _built[key]
def write_launchers(public: Path, root: Path = REPO_ROOT) -> list[Path]:
"""Write every launcher archive under the site's public folder, each
only when its bytes moved."""
folder = public / SERVED_DIR
folder.mkdir(parents=True, exist_ok=True)
written = []
for name in ARCHIVES:
path = folder / name
data = served(name, root).data
if not path.is_file() or path.read_bytes() != data:
write_bytes_atomic(str(path), data)
written.append(path)
return written
def table_rows() -> list[str]:
"""The handheld table of the installer page, each system linked to the
page that holds its download. The page sits in wiki/."""
def name(system: str) -> str:
return f"[{system}](../{page_of(system)})"
rows = ["| System | File | Copy it to | Start it from |",
"|--------|------|------------|---------------|"]
for launcher in LAUNCHERS:
names = ", ".join(name(system) for system in launcher.systems)
first = f"{launcher.lead}: {names}" if launcher.lead else names
file = f"`{launcher.archive}`" + (", extracted" if launcher.extracted else "")
copy_to = launcher.copy_to
if launcher.places:
copy_to += ": " + ", ".join(
f"{folder} on {' and '.join(where)}" for folder, where in launcher.places)
rows.append(f"| {first} | {file} | {copy_to} | {launcher.start} |")
return rows
def _anchor(system: str) -> str:
"""Where *system*'s downloads sit on its page."""
return "on-the-handheld" if system in PLATFORM_OF else system.casefold()
def download_rows() -> list[str]:
"""The short table that opens the installer's handheld section: each
system, its files as links to the site's copies, and its page. The
page sits in wiki/."""
rows = ["| System | Download | Steps |", "|--------|----------|-------|"]
for system in systems():
files = ", ".join(
f"[{launcher.archive}](../{SERVED_DIR}/{launcher.archive})"
+ (" (PortMaster)" if launcher.archive == PORT_ZIP else "")
for launcher in launchers_for(system))
page = f"../{page_of(system)}#{_anchor(system)}"
rows.append(f"| {system} | {files} | [{system} steps]({page}) |")
return rows
def _launcher_block(launcher: Launcher, system: str, root: Path) -> list[str]:
archive = served(launcher.archive, root)
verb = "Unzip it into" if launcher.extracted else "Copy it to"
via = " (with PortMaster)" if launcher.archive == PORT_ZIP else ""
return [
markup.button(f"Download {launcher.archive}", f"handheld/{launcher.archive}",
primary=True) + via,
"",
f"- {verb} {launcher.folder_for(system)}.",
f"- Start it from {launcher.start}.",
f"- {_size(archive.size)}. SHA-256: `{archive.sha256}`",
"",
]
def _size(count: int) -> str:
if count >= 1024 * 1024:
return f"{count / 1024 ** 2:.1f} MB"
return f"{count / 1024:.0f} KB"
APP_LINE = "RetroBIOS also runs on the handheld, with the controller."
# The installer's handheld section opens on the download of every system.
INSTALLER_SECTION = "wiki/installer.md#on-a-handheld-the-retrobios-port"
DETAILS_LINK = f"[Every system's file](../{INSTALLER_SECTION})"
def platform_block(platform: str, root: Path = REPO_ROOT) -> list[str]:
"""The app section of get/<platform>/, empty for a platform no launcher
runs on."""
system = next((name for name, key in PLATFORM_OF.items() if key == platform), None)
if system is None:
return []
lines = ["## On the handheld { #on-the-handheld }", "", APP_LINE, ""]
for launcher in launchers_for(system):
lines += _launcher_block(launcher, system, root)
return [*lines, DETAILS_LINK, ""]
def group_page(root: Path = REPO_ROOT) -> str:
"""get/linux-handhelds/: the app for the systems no setup page answers
for, then the setup pages that carry it for the others."""
names = group_systems()
listed = joined(names)
lines = [
"---", "search: exclude",
f'description: "The RetroBIOS app for {listed}: the file to put on the card '
'and the menu that starts it."',
"---", "",
f"# {GROUP_TITLE}", "",
f"{APP_LINE} Each system has its file below.", "",
]
for system in names:
lines += [f"## {system}", ""]
for launcher in launchers_for(system):
lines += _launcher_block(launcher, system, root)
others = [f"[{name}]({PLATFORM_OF[name]}.md#on-the-handheld)"
for name in sorted(PLATFORM_OF, key=str.casefold)]
lines += ["## Other systems", "", f"On {joined(others, 'or')}: on its page.", "",
DETAILS_LINK, ""]
return "\n".join(lines)
+16 -14
View File
@@ -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()
+8 -2
View File
@@ -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'''
+139 -4
View File
@@ -313,13 +313,14 @@ class TheReleaseNotesNameTheSetupsWithoutAZip(unittest.TestCase):
import release_notes
text = release_notes.render(self.RECORD, PLATFORMS, "### What changed\n\n- x\n")
paragraph = next(line for line in text.splitlines() if line.startswith("No ZIP for "))
paragraph = next(line for line in text.splitlines()
if line.startswith("Only the most used setups have a pack"))
for name in ("KNULLI", "Recalbox", "RetroPie", "Lakka"):
self.assertIn(name, paragraph)
for name in ("RetroArch", "Batocera", "RomM"):
self.assertNotIn(f" {name},", paragraph)
self.assertIn("The command above", paragraph)
self.assertIn("--dry-run", paragraph)
self.assertTrue(paragraph.endswith("use the command above."))
self.assertLess(text.index("| Platform"), text.index(paragraph))
self.assertLess(text.index(paragraph), text.index("### What changed"))
def test_the_rows_link_this_release_files(self):
@@ -331,7 +332,141 @@ class TheReleaseNotesNameTheSetupsWithoutAZip(unittest.TestCase):
self.assertIn(
"[Download](https://github.com/Abdess/retrobios/releases/download/"
"v2026.10.24/RomM_5_BIOS_Pack.zip)", text)
self.assertNotIn(f"{SITE}get/", text)
rows = [line for line in text.splitlines() if line.startswith("| ")]
self.assertTrue(rows)
for row in rows:
self.assertNotIn(f"{SITE}get/", row)
def test_the_command_comes_before_the_packs(self):
"""Most readers need a few files, not a pack of several GB (owner,
2026-11-10): the command and the handheld app open the notes."""
import release_notes
text = release_notes.render(self.RECORD, PLATFORMS, "### What changed\n\n- x\n")
command = text.index("```sh")
# The installer's handheld section, which opens on every download.
handheld = text.index(f"{SITE}wiki/installer/#on-a-handheld-the-retrobios-port")
packs = text.index("### Full packs")
self.assertLess(text.index("### Get your BIOS"), command)
self.assertLess(command, handheld)
self.assertLess(handheld, packs)
self.assertLess(packs, text.index("| Platform"))
self.assertLess(packs, text.index("### What changed"))
self.assertNotIn("releases/download/", text[:packs])
class TheReleaseNotesThankPeople(unittest.TestCase):
"""The notes end with everyone who helped since the repository started
and the full changelog, read from gh and git (owner, 2026-11-10).
Fixtures stand in for both."""
TAGS = {"c4": "v2026.11.10", "c3": "v2026.10.10", "c2": "v2026.10.10",
"c1": "v2026.10.10", "c0": "v2026.09.04"}
# `gh api --paginate --jq` prints one login per line, none for a
# commit GitHub links to no account.
COMMIT_AUTHORS = "Abdess\nhabib256\nAbdess76\ndependabot[bot]\nzed\nhabib256\n"
PULLS = [
{"number": 84, "author": {"login": "dajiaohuang", "is_bot": False}},
{"number": 86, "author": {"login": "habib256", "is_bot": False}},
{"number": 80, "author": {"login": "app/dependabot", "is_bot": True}},
{"number": 67, "author": {"login": "prakashmagat50-byte", "is_bot": False}},
{"number": 70, "author": {"login": "closedwithoutmerge", "is_bot": False}},
]
ISSUES = [
{"number": 87, "author": {"login": "paperweightorder", "is_bot": False}},
{"number": 85, "author": {"login": "72374", "is_bot": False}},
{"number": 83, "author": {"login": "72374", "is_bot": False}},
{"number": 32, "author": {"login": "Bappo0", "is_bot": False}},
{"number": 31, "author": {"login": "ghost", "is_bot": False}},
{"number": 30, "author": {"login": "habib256", "is_bot": False}},
{"number": 29, "author": {"login": "Abdess", "is_bot": False}},
]
ISSUE_COMMENTS = "Abdess\nhelper\n72374\ngithub-actions[bot]\nAlpha\n"
REVIEW_COMMENTS = "reviewer\nhabib256\n"
def fake(self, argv):
self.calls.append(argv)
if argv[:2] == ["git", "log"]:
return "\n".join(self.TAGS) + "\n"
if argv[:2] == ["git", "show"]:
return json.dumps({"tag": self.TAGS[argv[2].split(":")[0]]})
if argv[:2] == ["gh", "api"]:
path = argv[3]
return {"repos/Abdess/retrobios/commits": self.COMMIT_AUTHORS,
"repos/Abdess/retrobios/issues/comments": self.ISSUE_COMMENTS,
"repos/Abdess/retrobios/pulls/comments": self.REVIEW_COMMENTS}[path]
if argv[:3] == ["gh", "pr", "list"]:
return json.dumps(self.PULLS)
if argv[:3] == ["gh", "issue", "list"]:
return json.dumps(self.ISSUES)
raise AssertionError(argv)
def setUp(self):
self.calls = []
def test_the_base_is_the_previous_release_record(self):
import release_notes
self.assertEqual(release_notes.previous_record_commit("v2026.11.10", self.fake), "c1")
def test_everyone_since_the_start_is_thanked_once(self):
import release_notes
lines = release_notes.gather_thanks(self.fake)
self.assertEqual(lines, [
"- **Code and data**: @closedwithoutmerge, @dajiaohuang, @habib256 and @zed.",
"- **Reports**: @72374 and @paperweightorder.",
"- **Help in issues**: @Alpha, @helper and @reviewer.",
])
text = "\n".join(lines)
for left_out in ("Abdess", "bot", "dependabot", "ghost", "Bappo0", "prakashmagat50"):
self.assertNotIn(left_out, text)
# Every state: a pull request closed without merge still counts.
pulls = next(argv for argv in self.calls if argv[:3] == ["gh", "pr", "list"])
self.assertEqual(pulls[pulls.index("--state") + 1], "all")
issues = next(argv for argv in self.calls if argv[:3] == ["gh", "issue", "list"])
self.assertEqual(issues[issues.index("--state") + 1], "all")
def test_the_notes_end_with_thanks_then_the_changelog(self):
import release_notes
closing = release_notes.closing_lines(
["- **Code and data**: @habib256."],
release_notes.changelog_url("55eb95344" + "0" * 31, "v2026.11.10"))
text = release_notes.render(TheReleaseNotesNameTheSetupsWithoutAZip.RECORD, PLATFORMS,
"### What changed\n\n- x\n", closing)
self.assertLess(text.index("### What changed"), text.index("### Thanks"))
self.assertIn("### Thanks\n\n<details>\n"
"<summary>Everyone who helped since the start</summary>\n\n"
"- **Code and data**: @habib256.\n\n</details>\n\n**Full changelog**",
text)
self.assertEqual(release_notes.refused_words(text), [])
self.assertTrue(text.rstrip().endswith(
"**Full changelog**: https://github.com/Abdess/retrobios/compare/"
"55eb95344...v2026.11.10"))
def test_a_checked_list_replaces_the_one_gh_gives(self):
import release_notes
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
tmp = Path(tmp)
(tmp / "whats-new.md").write_text("### What changed\n\n- x\n", encoding="utf-8")
(tmp / "thanks.md").write_text("- **Code and data**: @habib256.\n",
encoding="utf-8")
(tmp / "release.json").write_text(
json.dumps(TheReleaseNotesNameTheSetupsWithoutAZip.RECORD), encoding="utf-8")
argv = ["release_notes.py", "--record", str(tmp / "release.json"),
"--whats-new", str(tmp / "whats-new.md"), "--thanks", str(tmp / "thanks.md"),
"--platforms-dir", PLATFORMS, "--output", str(tmp / "notes.md")]
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(release_notes, "_run", self.fake), \
mock.patch.object(release_notes, "gather_thanks",
side_effect=AssertionError("gh read")), \
contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(release_notes.main(), 0)
text = (tmp / "notes.md").read_text(encoding="utf-8")
self.assertIn("- **Code and data**: @habib256.\n", text)
self.assertIn("/compare/c4...v2026.10.24", text)
class EveryPlatformHasAPermanentPage(unittest.TestCase):
+168
View File
@@ -0,0 +1,168 @@
"""The handheld launchers the site serves.
The four archives build_port.py writes carry install.py pinned by SHA-256,
so they left the release (owner, 2026-11-10): the site builds them from its
own commit, serves them under get/handheld/, and the get/ page of each
system names the file, its size and hash, where it goes and which menu
starts it.
"""
from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import contextlib
import hashlib
import re
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import build_port # noqa: E402
import common # noqa: E402
import sitehandheld # noqa: E402
PLATFORMS = str(REPO_ROOT / "platforms")
def _pages() -> dict[str, str]:
"""Every page that carries a launcher, by site path."""
import generate_site # noqa: PLC0415
with contextlib.chdir(REPO_ROOT):
pages = {f"get/{key}.md": generate_site.generate_get_page(key)
for key in sorted(set(sitehandheld.PLATFORM_OF.values()))}
pages[f"get/{sitehandheld.GROUP_PAGE}.md"] = sitehandheld.group_page(REPO_ROOT)
return pages
class TheSiteServesTheLaunchers(unittest.TestCase):
def setUp(self):
scratch = REPO_ROOT / "tmp"
scratch.mkdir(exist_ok=True)
self.tmp = Path(tempfile.mkdtemp(dir=scratch, prefix="site-handheld-"))
self.addCleanup(shutil.rmtree, self.tmp, True)
def test_the_written_archives_are_the_ones_build_port_writes(self):
written = sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
self.assertEqual([path.name for path in written], list(build_port.ARCHIVES))
for path in written:
with self.subTest(archive=path.name):
self.assertEqual(path.parent, self.tmp / "public" / "get" / "handheld")
built = build_port.build_archive(path.name, self.tmp / "built" / path.name)
self.assertEqual(path.read_bytes(), built.read_bytes())
def test_an_unchanged_archive_is_not_rewritten(self):
first = sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
stamps = {path: path.stat().st_mtime_ns for path in first}
sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
self.assertEqual({path: path.stat().st_mtime_ns for path in first}, stamps)
def test_a_stale_pin_stops_the_build(self):
"""build_port refuses an install.py the bootstraps do not pin: served,
that launcher would refuse its own installer."""
with mock.patch.dict(sitehandheld._built, clear=True), \
mock.patch.object(build_port, "pinned_installer_sha256", return_value="0" * 64):
with self.assertRaisesRegex(ValueError, "re-pin the bootstraps"):
sitehandheld.write_launchers(self.tmp / "public", REPO_ROOT)
self.assertFalse(list((self.tmp / "public").rglob("RetroBIOS*")))
def test_every_archive_is_on_a_page_with_its_link_and_hash(self):
public = self.tmp / "public"
written = {path.name: path for path in sitehandheld.write_launchers(public, REPO_ROOT)}
pages = _pages()
for name in build_port.ARCHIVES:
with self.subTest(archive=name):
data = written[name].read_bytes()
digest = hashlib.sha256(data).hexdigest()
holding = [page for page, text in pages.items()
if f'href="handheld/{name}"' in text]
self.assertTrue(holding, f"no page links {name}")
for page in holding:
text = pages[page]
# The link resolves to the file written under public/.
target = (Path(page).parent / "handheld" / name).as_posix()
self.assertTrue((public / target).is_file(), target)
block = text.split(f'href="handheld/{name}"', 1)[1].split("\n\n", 2)[1]
self.assertIn(f"SHA-256: `{digest}`", block)
self.assertIn(sitehandheld._size(len(data)), block)
self.assertRegex(block, r"- (Copy it to|Unzip it into) .+\.")
self.assertRegex(block, r"- Start it from .+\.")
class WhereEachSystemFindsItsFile(unittest.TestCase):
def test_each_platform_page_is_a_registered_platform(self):
registered = set(common.list_registered_platforms(PLATFORMS))
self.assertLessEqual(set(sitehandheld.PLATFORM_OF.values()), registered)
self.assertNotIn(sitehandheld.GROUP_PAGE, registered)
def test_the_group_page_sits_with_the_handheld_guide(self):
"""It shares the guide's name, so the sidebar files it with it, and
the guide a muOS or ArkOS owner lands on links it."""
import generate_site # noqa: PLC0415
guide = generate_site.HANDHELD_GUIDE
self.assertEqual(sitehandheld.GROUP_PAGE, guide.page)
with contextlib.chdir(REPO_ROOT):
self.assertIn(f"(../get/{sitehandheld.GROUP_PAGE}.md)",
generate_site.generate_handheld_guide(guide))
self.assertIn(f"]({sitehandheld.GROUP_PAGE}.md)", generate_site.generate_get_index())
def test_every_system_has_its_launchers_on_its_page(self):
pages = _pages()
for system in sitehandheld.systems():
page = sitehandheld.page_of(system)
with self.subTest(system=system):
self.assertIn(page, pages)
for launcher in sitehandheld.launchers_for(system):
self.assertIn(f'href="handheld/{launcher.archive}"', pages[page])
self.assertIn(launcher.folder_for(system), pages[page])
self.assertIn(launcher.start, pages[page])
for system in sitehandheld.group_systems():
self.assertIn(f"\n## {system}\n", pages[f"get/{sitehandheld.GROUP_PAGE}.md"])
def test_the_installer_table_is_written_from_the_same_rows(self):
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
block = re.search(r"<!-- generated: handheld -->\n(.*?)<!-- /generated: handheld -->",
text, re.S)
self.assertIsNotNone(block)
self.assertEqual(block.group(1).strip(), "\n".join(sitehandheld.table_rows()))
self.assertNotIn("releases/latest", text.split("## On a handheld", 1)[1].split("\n## ")[0])
def test_the_installer_section_opens_on_the_downloads(self):
"""The section was long, with no link to click (owner, 2026-11-10):
it opens on one row per system, each file a link to its copy on the
site, before the detail."""
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
section = text.split("## On a handheld", 1)[1].split("\n## ")[0]
block = re.search(r"<!-- generated: handheld-downloads -->\n(.*?)"
r"<!-- /generated: handheld-downloads -->", section, re.S)
self.assertIsNotNone(block)
self.assertEqual(block.group(1).strip(), "\n".join(sitehandheld.download_rows()))
self.assertLess(block.start(), section.index("### Where each file goes"))
self.assertLess(len(section[:block.start()].split()), 40)
for system in sitehandheld.systems():
with self.subTest(system=system):
row = next(line for line in block.group(1).splitlines()
if line.startswith(f"| {system} |"))
for launcher in sitehandheld.launchers_for(system):
self.assertIn(f"](../{sitehandheld.SERVED_DIR}/{launcher.archive})", row)
self.assertIn(f"](../{sitehandheld.page_of(system)}#", row)
for name in build_port.ARCHIVES:
self.assertIn(f"](../{sitehandheld.SERVED_DIR}/{name})", block.group(1))
def test_the_generator_writes_them(self):
source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")
self.assertIn("sitehandheld.write_launchers(public, _REPO_ROOT)", source)
self.assertIn("sitehandheld.platform_block(platform_name, _REPO_ROOT)", source)
self.assertIn("sitehandheld.group_page(_REPO_ROOT)", source)
if __name__ == "__main__":
unittest.main()
+21 -1
View File
@@ -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
+1 -1
View File
@@ -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"),
)
+30 -12
View File
@@ -1501,27 +1501,45 @@ Android as on other systems, carries the same empty `.nomedia` beside
## On a handheld { #on-a-handheld-the-retrobios-port }
RetroBIOS also runs on the handheld itself, in the
[full-screen interface](#the-full-screen-interface), with the controller.
Copy the file for the system to the SD card, then start RetroBIOS from the
menu.
RetroBIOS also runs on the handheld, with the controller. Download the file
for the system, copy it to the SD card, then start RetroBIOS from the menu.
Download the file for your system from the
[latest release](https://github.com/Abdess/retrobios/releases/latest).
<!-- generated: handheld-downloads -->
| System | Download | Steps |
|--------|----------|-------|
| muOS | [RetroBIOS.muxapp](../get/handheld/RetroBIOS.muxapp) | [muOS steps](../get/linux-handhelds.md#muos) |
| ArkOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [ArkOS steps](../get/linux-handhelds.md#arkos) |
| dArkOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [dArkOS steps](../get/linux-handhelds.md#darkos) |
| ROCKNIX | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [ROCKNIX steps](../get/rocknix.md#on-the-handheld) |
| AmberELEC | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [AmberELEC steps](../get/amberelec.md#on-the-handheld) |
| JELOS | [RetroBIOS-tools.zip](../get/handheld/RetroBIOS-tools.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [JELOS steps](../get/linux-handhelds.md#jelos) |
| KNULLI | [RetroBIOS-batocera.zip](../get/handheld/RetroBIOS-batocera.zip), [RetroBIOS.zip](../get/handheld/RetroBIOS.zip) (PortMaster) | [KNULLI steps](../get/knulli.md#on-the-handheld) |
| Batocera | [RetroBIOS-batocera.zip](../get/handheld/RetroBIOS-batocera.zip) | [Batocera steps](../get/batocera.md#on-the-handheld) |
<!-- /generated: handheld-downloads -->
### Where each file goes { #where-each-file-goes }
It runs in the [full-screen interface](#the-full-screen-interface).
<!-- generated: handheld -->
| System | File | Copy it to | Start it from |
|--------|------|------------|---------------|
| muOS | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications |
| Any other system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on Knulli | Ports, after opening PortMaster once |
| Batocera, Knulli | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list |
| ArkOS, dArkOS | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools |
| ROCKNIX, JELOS | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools |
| AmberELEC | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools |
| [muOS](../get/linux-handhelds.md) | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications |
| Any other system with [PortMaster](https://portmaster.games/): [ArkOS](../get/linux-handhelds.md), [dArkOS](../get/linux-handhelds.md), [ROCKNIX](../get/rocknix.md), [AmberELEC](../get/amberelec.md), [JELOS](../get/linux-handhelds.md), [KNULLI](../get/knulli.md) | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on KNULLI | Ports, after opening PortMaster once |
| [Batocera](../get/batocera.md), [KNULLI](../get/knulli.md) | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list |
| [ArkOS](../get/linux-handhelds.md), [dArkOS](../get/linux-handhelds.md) | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools |
| [ROCKNIX](../get/rocknix.md), [JELOS](../get/linux-handhelds.md) | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools |
| [AmberELEC](../get/amberelec.md) | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools |
<!-- /generated: handheld -->
`RetroBIOS.zip` also installs on muOS, from `MUOS/PortMaster/autoinstall/`
on the first card (`/mnt/mmc` on the device). It then shows the interface in
muxterm, as the muOS app does.
### Buttons on a handheld { #buttons-on-a-handheld }
| Button | Action |
|--------|--------|
| D-pad | Move |
+11 -16
View File
@@ -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
+1 -1
View File
@@ -60,7 +60,7 @@ Every module in `tests/`, by the area it guards.
| Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` |
| Hardware targets and systems | synthetic + target files | `test_amberelec_targets`, `test_emuelec_targets`, `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_filesystems`, `test_install_app`, `test_install_from`, `test_install_games`, `test_install_journeys`, `test_install_offline`, `test_install_selection`, `test_install_tui`, `test_pypi_package` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port`, `test_site_handheld` |
| Running cores | parsers on fixed inputs, a libretro core built from C in the test when a compiler exists | `test_core_probe` |
| Measurement records | synthetic record files, the committed `measurements/` against HEAD, the **Proof** line naming the BIOS a core ran, and that no page shows a Tested block, a region outcome or a time per frame | `test_measurements` |
| Real-condition campaign | synthetic profiles, records and content folders, a fake host for load, disk, clock and commands, and whole passes against a libretro core built from C when a compiler and strace exist | `test_campaign` |