diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index 6b442affd..6ce8a3094 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -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"
diff --git a/scripts/build_port.py b/scripts/build_port.py
index dbedf0537..b09b5b07d 100644
--- a/scripts/build_port.py
+++ b/scripts/build_port.py
@@ -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)):
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/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py
index 5a1b795e8..39d88a3a4 100644
--- a/scripts/generate_installer_doc.py
+++ b/scripts/generate_installer_doc.py
@@ -8,7 +8,8 @@ Each table sits between `` and
``. 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:
diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py
index fcda2e74d..dcf32b9bc 100644
--- a/scripts/generate_pack.py
+++ b/scripts/generate_pack.py
@@ -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.
"""
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index 34369fd51..8272392a7 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -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...")
diff --git a/scripts/release_notes.py b/scripts/release_notes.py
index dff6f6f49..c18cc503c 100644
--- a/scripts/release_notes.py
+++ b/scripts/release_notes.py
@@ -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", "", "",
+ "Everyone who helped since the start
", "",
+ *thanks, "", " ", ""]
+ 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
diff --git a/scripts/sitehandheld.py b/scripts/sitehandheld.py
new file mode 100644
index 000000000..36749d4f4
--- /dev/null
+++ b/scripts/sitehandheld.py
@@ -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// 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//, 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)
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_release_packs.py b/tests/test_release_packs.py
index 33d5e88bb..c70712674 100644
--- a/tests/test_release_packs.py
+++ b/tests/test_release_packs.py
@@ -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\n"
+ "Everyone who helped since the start
\n\n"
+ "- **Code and data**: @habib256.\n\n \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):
diff --git a/tests/test_site_handheld.py b/tests/test_site_handheld.py
new file mode 100644
index 000000000..ae81d0cd6
--- /dev/null
+++ b/tests/test_site_handheld.py
@@ -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"\n(.*?)",
+ 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"\n(.*?)"
+ r"", 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()
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/installer.md b/wiki/installer.md
index 7dcd94386..920f1860e 100644
--- a/wiki/installer.md
+++ b/wiki/installer.md
@@ -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).
+
+
+| 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) |
+
+
+### Where each file goes { #where-each-file-goes }
+
+It runs in the [full-screen interface](#the-full-screen-interface).
+
+
| 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 |
+
`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 |
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
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 2c1f6a867..a94265a99 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -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` |