mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 20:43:50 -06:00
chore: merge the launchers served by the site
This commit is contained in:
commit
fba9a0b511
17 files changed
+837
-80
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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 {})}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Reference in new issue
Block a user