chore: merge origin/main

This commit is contained in:
Abdessamad Derraz committed 2026-11-09 14:02:45 +01:00
commit 025fd08a72
16 files changed
+484 -9

No files matched your search

+6 -2
View File
@@ -19,6 +19,8 @@ on:
- "install.ps1"
# The handheld port bundles install.py and its tests read both.
- "ports/**"
# The PyPI package is built from install.py and its tests build it.
- "packaging/**"
# Every input of the site build, so a page that would fail the deploy
# fails here first (tests/test_workflow_paths.py).
- "wiki/**"
@@ -50,6 +52,7 @@ on:
- "install.sh"
- "install.ps1"
- "ports/**"
- "packaging/**"
- "wiki/**"
- "docs_assets/**"
- "provenance/**"
@@ -241,9 +244,10 @@ jobs:
# heading guard's premise, and skipped on every run without it. pyte
# reads the installer's full screen back in its end-to-end tests,
# hypothesis fuzzes its key decoder, and vermin holds install.py to 3.8.
# setuptools reads pyproject.toml for the packaging guard.
# setuptools reads pyproject.toml for the packaging guard, and uv builds
# the PyPI package the way a release builds it.
- name: Install dependencies
run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0"
run: pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0" "uv==0.12.9"
# The full screen also runs under the 3.8 floor: the end-to-end tests
# start install.py with the interpreter that runs them.
+1 -1
View File
@@ -72,7 +72,7 @@ validation above is its check. For code, profiles or platform files, these are
the checks CI runs on a pull request (Python 3.12 or newer):
```bash
pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0"
pip install pyyaml "jsonschema[format-nongpl]==4.26.0" "markdown==3.10.2" "pyte==0.8.2" "hypothesis==6.170.8" "vermin==1.9.2" "setuptools==80.9.0" "uv==0.12.9"
python scripts/pr_checks.py
```
+2
View File
@@ -25,6 +25,8 @@ irm https://abdess.github.io/retrobios/i.ps1 | iex
curl -fsSL https://abdess.github.io/retrobios/i | sh
```
**With Python 3.8 or newer**: `uvx retrobios` runs the same installer, and so does `pipx run retrobios`.
**A handheld, an SD card, or not sure?** [Pick the device](https://abdess.github.io/retrobios/which-pack/): its page gives the folder and the command. A muOS, Onion, spruceOS, MinUI, KNULLI, ROCKNIX, Lakka or MiSTer SD card plugged into the computer is found by itself.
![Recording of the one-line command in a terminal, on a demonstration card. The installer finds an ArkOS SD card with 6 games, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary.](docs_assets/demo/install.gif)
+22
View File
@@ -0,0 +1,22 @@
# RetroBIOS
Puts the BIOS files emulators need in the right folder, and checks them.
It finds the setup and its BIOS folder, downloads only the missing files and
checks each one. It works with RetroArch, Batocera, EmuDeck, RetroBat,
Recalbox, RetroDECK, RomM and more.
Run it:
```bash
uvx retrobios
```
Or `pipx run retrobios`, or `pip install retrobios` and then `retrobios`.
Python 3.8 or newer.
Add `--check` to see what is missing without changing anything, and `--help`
for every option.
Guides, the list of setups and the files each one needs:
https://abdess.github.io/retrobios/
+51
View File
@@ -0,0 +1,51 @@
# The package PyPI serves as `retrobios`: install.py and the command that
# runs it, nothing else. The root pyproject.toml is the contributors'
# environment. scripts/build_pypi.py stages this file, README.md, LICENSE,
# retrobios/__main__.py and install.py into a build folder, writes
# retrobios/__init__.py with the version, and runs `uv build` there.
[build-system]
# Pinned: the same backend writes the same bytes from the same files. 3.12
# writes core metadata 2.4, the version this package was checked under
# (twine check --strict). 4.x writes 2.5.
requires = ["flit_core==3.12.0"]
build-backend = "flit_core.buildapi"
[project]
name = "retrobios"
dynamic = ["version"]
description = "Install the BIOS files emulators need, checked"
readme = "README.md"
requires-python = ">=3.8"
license = "MIT"
license-files = ["LICENSE"]
dependencies = []
keywords = [
"bios", "firmware", "emulation", "emulator", "retrogaming", "installer",
"retroarch", "batocera", "emudeck", "retrobat", "recalbox", "retrodeck",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: System :: Emulators",
]
[project.urls]
Homepage = "https://abdess.github.io/retrobios/"
Documentation = "https://abdess.github.io/retrobios/wiki/installer/"
Source = "https://github.com/Abdess/retrobios"
Issues = "https://github.com/Abdess/retrobios/issues"
Releases = "https://github.com/Abdess/retrobios/releases"
[project.scripts]
retrobios = "retrobios:main"
+5
View File
@@ -0,0 +1,5 @@
"""`python -m retrobios` runs the installer as the retrobios command does."""
from retrobios import main
main()
+6 -1
View File
@@ -10,7 +10,10 @@ packages = []
py-modules = []
[project]
name = "retrobios"
# Not the package PyPI serves (packaging/pypi/ builds that one, named
# retrobios): a contributor's `pip install -e ".[dev]"` and `pip install
# retrobios` in one environment would otherwise replace each other.
name = "retrobios-dev"
version = "1.0.0"
description = "BIOS collection manager for retrogaming platforms"
# install.py is the only entry point a user runs before cloning, and both
@@ -38,6 +41,8 @@ dev = [
# installs only in its isolated build environment and 3.12 no longer
# bundles: without it the guard skipped on every CI run.
"setuptools>=61",
# scripts/build_pypi.py runs `uv build` for the PyPI package and its test.
"uv==0.12.9",
# The type check CI runs, pinned: another mypy reports other errors
# than the ones mypy-baseline.txt holds.
"mypy==1.18.2",
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Build the `retrobios` package PyPI serves: install.py as a command.
The wheel and the sdist hold install.py, byte for byte the copy the
bootstraps pin, a launcher that runs it as a script (`retrobios` and
`python -m retrobios`), the metadata, README.md and LICENSE. Nothing of the
collection. packaging/pypi/ holds the project file, the README and
`__main__.py`; this script stages them with install.py and LICENSE into a
build folder, writes `__init__.py` with the version, and runs `uv build`
there with the flit_core backend pinned in the project file.
The version is the UTC date of the last commit that changed install.py,
with the zeros PEP 440 drops left out: 2026-11-09 gives 2026.11.9. When
install.py changed several times that day, the newest gets .N, its rank in
the day: the third change gives 2026.11.9.3. The same install.py always
gives the same version, so PyPI refuses a republish of unchanged code.
SOURCE_DATE_EPOCH is that commit's time, so two builds of one commit give
the same bytes.
Usage:
python scripts/build_pypi.py
python scripts/build_pypi.py --output-dir tmp/pypi-dist
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from sitebootstrap import PIN, SH_NAME
ROOT = Path(__file__).resolve().parent.parent
PACKAGE_DIR = ROOT / "packaging" / "pypi"
DEFAULT_OUTPUT = ROOT / "tmp" / "pypi-dist"
MODULE = "retrobios"
# The launcher. runpy runs install.py as __main__, so its own __main__ block
# keeps the exit codes it gives a reader that left (141) and Ctrl+C (130).
LAUNCHER = '''"""The RetroBIOS installer, install.py, run as the retrobios command."""
import runpy
from pathlib import Path
__version__ = "{version}"
def main() -> None:
runpy.run_path(str(Path(__file__).with_name("install.py")), run_name="__main__")
'''
def package_version(stamps: list[int]) -> str:
"""The version of install.py from its commit times, newest first (UTC)."""
if not stamps:
raise ValueError("install.py has no commit to date the package by")
day = datetime.datetime.fromtimestamp(stamps[0], datetime.timezone.utc).date()
rank = sum(1 for stamp in stamps
if datetime.datetime.fromtimestamp(stamp, datetime.timezone.utc).date() == day)
base = f"{day.year}.{day.month}.{day.day}"
return base if rank == 1 else f"{base}.{rank}"
def install_py_commits() -> list[int]:
"""Commit times of install.py, newest first, from git."""
out = subprocess.run(["git", "log", "--format=%ct", "--", "install.py"], cwd=ROOT,
capture_output=True, text=True, check=True).stdout
return [int(line) for line in out.split()]
def installer_bytes() -> bytes:
"""install.py, refused unless it is the one install.sh pins: the package
then runs the installer the one-line command runs, under the same id."""
data = (ROOT / "install.py").read_bytes()
pins = PIN[SH_NAME].findall((ROOT / "install.sh").read_text(encoding="utf-8"))
if pins != [hashlib.sha256(data).hexdigest()]:
raise ValueError("install.py is not the installer install.sh pins: recompute the pin")
return data
def stage(folder: Path, version: str) -> None:
"""The project as the backend reads it."""
module = folder / MODULE
module.mkdir(parents=True)
for name in ("pyproject.toml", "README.md"):
shutil.copyfile(PACKAGE_DIR / name, folder / name)
shutil.copyfile(ROOT / "LICENSE", folder / "LICENSE")
shutil.copyfile(PACKAGE_DIR / MODULE / "__main__.py", module / "__main__.py")
(module / "__init__.py").write_text(LAUNCHER.format(version=version), encoding="utf-8")
(module / "install.py").write_bytes(installer_bytes())
def build(output_dir: Path) -> tuple[Path, Path]:
"""The wheel and the sdist in output_dir, replacing earlier builds there."""
stamps = install_py_commits()
version = package_version(stamps)
output_dir.mkdir(parents=True, exist_ok=True)
for pattern in ("*.whl", "*.tar.gz"):
for old in output_dir.glob(f"{MODULE}-{pattern}"):
old.unlink()
scratch = ROOT / "tmp"
scratch.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="pypi-stage-", dir=scratch) as name:
folder = Path(name) / "retrobios"
stage(folder, version)
# Without a flag, uv builds the sdist, then the wheel from that sdist:
# what a user building from the sdist gets is what is published.
subprocess.run(
["uv", "build", "--quiet", "--out-dir", str(output_dir.resolve()), str(folder)],
check=True, env={**os.environ, "SOURCE_DATE_EPOCH": str(stamps[0])})
wheel = output_dir / f"{MODULE}-{version}-py3-none-any.whl"
sdist = output_dir / f"{MODULE}-{version}.tar.gz"
for path in (wheel, sdist):
if not path.is_file():
raise RuntimeError(f"uv build wrote no {path.name}")
return wheel, sdist
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False, description=__doc__.splitlines()[0])
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT,
help="where the wheel and the sdist go (default: tmp/pypi-dist)")
args = parser.parse_args()
for path in build(args.output_dir):
print(path)
return 0
if __name__ == "__main__":
from common import run_cli
run_cli(main)
+4 -1
View File
@@ -39,7 +39,7 @@ from common import (
)
from nativemode import check_label
import release_record
from siteguides import install_command
from siteguides import PACKAGE_COMMAND, install_command
from validate_pr import LARGE_FILE_ISSUE_URL
from largefiles import load_registered_paths
import pr_checks
@@ -649,6 +649,9 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
install_command("sh"),
"```",
"",
f"**With Python 3.8 or newer**: `{PACKAGE_COMMAND}` runs the same installer, and so"
" does `pipx run retrobios`.",
"",
f"**A handheld, an SD card, or not sure?** [Pick the device]({SITE_URL}which-pack/):"
" its page gives the folder and the command. A muOS, Onion, spruceOS, MinUI, KNULLI,"
" ROCKNIX, Lakka or MiSTer SD card plugged into the computer is found by itself.",
+6
View File
@@ -112,6 +112,7 @@ from siteguides import (
INSTALL_BASE,
INSTALL_PS1,
INSTALL_SH,
PACKAGE_COMMAND,
TERMINALS,
TRUST_ITEMS,
detected_systems as _detected_systems,
@@ -11951,6 +11952,11 @@ def generate_which_pack() -> str:
# handheld recording (sitesteps).
box_tabs = (*all_tabs, "device")
box_steps, box_demos = sitesteps.install_box(box_tabs)
# Python is on most Linux and macOS machines: the package runs the same
# installer there without the bootstrap.
for tab in ("macos", "linux"):
box_demos[tab] = [*box_demos[tab],
f"With Python 3.8 or newer, `{PACKAGE_COMMAND}` does the same.", ""]
lines = [
"---",
f"terms: {', '.join(IOS_TERMS)}",
+2
View File
@@ -393,6 +393,8 @@ INSTALL_BASE: str = _LOADED["install_base"]
# The bootstraps the site serves (sitebootstrap.py writes them).
INSTALL_SH: str = f"{INSTALL_BASE}/{SH_NAME}"
INSTALL_PS1: str = f"{INSTALL_BASE}/{PS1_NAME}"
# The same installer from PyPI (scripts/build_pypi.py builds the package).
PACKAGE_COMMAND = "uvx retrobios"
DEVICE_GROUPS: dict[str, tuple[str, str]] = _LOADED["device_groups"]
GROUP_ANCHORS: dict[str, str] = _LOADED["group_anchors"]
ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also_runs"]
+1 -1
View File
@@ -1564,7 +1564,7 @@ def _literals() -> str:
VALUE = re.compile(r"^(?:\S*[\d/\\~<>{}=]\S*|--?[\w-]+\S*|\S*[A-Z]{2,}\S*|\.\.\.|"
r"\S*\.\w{2,4}[,.:;)]?|[A-Z][a-z]+[A-Z]\S*|\(\S*)$")
# A code span that is a command, a path or a shape, not a message.
NOT_A_MESSAGE = re.compile(r"^(?:\{|-|\.|/|~|<|\$|%|python|py |curl|irm|sh |pkg |sudo|ls |"
NOT_A_MESSAGE = re.compile(r"^(?:\{|-|\.|/|~|<|\$|%|python|py |curl|irm|uvx |pipx |pip |sh |pkg |sudo|ls |"
r"xcode|winget|termux|install\.py|scripts/|generate_|&\()")
+214
View File
@@ -0,0 +1,214 @@
"""The `retrobios` package on PyPI is install.py and the command that runs it.
`uvx retrobios`, `pipx run retrobios` and `pip install retrobios` reach the
installer the one-line command runs: the same bytes, so the same id, the
same file lists, cache and exit codes. scripts/build_pypi.py stages the
project and builds it with `uv build` and the flit_core backend, and these
tests read what it wrote and run it.
"""
from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import configparser
import email.parser
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import build_pypi # noqa: E402
MODULE_FILES = {"retrobios/__init__.py", "retrobios/__main__.py", "retrobios/install.py"}
class Built(unittest.TestCase):
"""One build per class, in a scratch folder."""
_scratch: tempfile.TemporaryDirectory
scratch: Path
wheel: Path
sdist: Path
version: str
@classmethod
def setUpClass(cls) -> None:
if shutil.which("uv") is None:
if os.environ.get("CI"):
raise AssertionError("CI installs uv")
raise unittest.SkipTest("uv is not installed")
cls._scratch = tempfile.TemporaryDirectory(prefix="pypi-")
cls.scratch = Path(cls._scratch.name)
cls.wheel, cls.sdist = build_pypi.build(cls.scratch / "dist")
cls.version = cls.wheel.name.split("-")[1]
@classmethod
def tearDownClass(cls) -> None:
cls._scratch.cleanup()
class TheArchivesHoldTheInstallerAndNothingElse(Built):
def test_wheel_members(self):
info = f"retrobios-{self.version}.dist-info"
with zipfile.ZipFile(self.wheel) as archive:
names = set(archive.namelist())
self.assertEqual(names, MODULE_FILES | {
f"{info}/{name}" for name in
("METADATA", "WHEEL", "entry_points.txt", "RECORD", "licenses/LICENSE")})
self.assertEqual(archive.read("retrobios/install.py"),
(REPO_ROOT / "install.py").read_bytes())
entry_points = archive.read(f"{info}/entry_points.txt").decode()
meta = email.parser.Parser().parsestr(archive.read(f"{info}/METADATA").decode())
scripts = configparser.ConfigParser()
scripts.read_string(entry_points)
self.assertEqual(dict(scripts["console_scripts"]), {"retrobios": "retrobios:main"})
self.assertEqual(meta["Name"], "retrobios")
self.assertEqual(meta["Version"], self.version)
self.assertEqual(meta["Requires-Python"], ">=3.8")
self.assertIsNone(meta["Requires-Dist"])
# The backend ends the body with one more newline than the file.
self.assertEqual(meta.get_payload().rstrip("\n"),
(REPO_ROOT / "packaging/pypi/README.md").read_text(
encoding="utf-8").rstrip("\n"))
def test_metadata(self):
info = f"retrobios-{self.version}.dist-info"
with zipfile.ZipFile(self.wheel) as archive:
meta = email.parser.Parser().parsestr(archive.read(f"{info}/METADATA").decode())
self.assertEqual(meta["Metadata-Version"], "2.4")
self.assertEqual(meta["License-Expression"], "MIT")
classifiers = meta.get_all("Classifier")
# PEP 639: the license is the expression, never a classifier as well.
self.assertEqual([name for name in classifiers if name.startswith("License ::")], [])
self.assertEqual(
[name for name in classifiers if name.startswith("Programming Language :: Python :: 3.")],
[f"Programming Language :: Python :: 3.{minor}" for minor in range(8, 15)])
for name in ("Programming Language :: Python :: 3 :: Only",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: End Users/Desktop", "Environment :: Console",
"Operating System :: OS Independent", "Topic :: System :: Emulators"):
self.assertIn(name, classifiers)
self.assertTrue(meta["Keywords"])
self.assertEqual(sorted(url.split(",")[0] for url in meta.get_all("Project-URL")),
["Documentation", "Homepage", "Issues", "Releases", "Source"])
def test_sdist_members(self):
top = f"retrobios-{self.version}"
with tarfile.open(self.sdist) as archive:
names = {member.name for member in archive.getmembers()}
installer = archive.extractfile(f"{top}/retrobios/install.py").read()
self.assertEqual(names, {f"{top}/{name}" for name in (
*MODULE_FILES, "PKG-INFO", "pyproject.toml", "README.md", "LICENSE")})
self.assertEqual(installer, (REPO_ROOT / "install.py").read_bytes())
def test_no_collection_file(self):
with zipfile.ZipFile(self.wheel) as archive:
names = archive.namelist()
with tarfile.open(self.sdist) as archive:
names += [name.split("/", 1)[1] for name in archive.getnames()]
for top in ("bios/", "data/", "install/", "platforms/", "emulators/"):
self.assertEqual([name for name in names if name.startswith(top)], [], top)
def test_build_is_reproducible(self):
# SOURCE_DATE_EPOCH is install.py's commit time, so the clock moving
# between the two builds changes no byte.
again = build_pypi.build(self.scratch / "again")
self.assertEqual([path.read_bytes() for path in again],
[self.wheel.read_bytes(), self.sdist.read_bytes()])
class TheVersionIsInstallPyCommitDate(unittest.TestCase):
"""The release date put November code under an October version: the
version now follows the commits of install.py itself."""
def test_one_change_that_day(self):
# 2026-11-09 12:00 UTC, then a change the day before.
self.assertEqual(build_pypi.package_version([1794225600, 1794139200]), "2026.11.9")
def test_the_rank_in_the_day(self):
self.assertEqual(build_pypi.package_version([1794229200, 1794225600, 1794222000]),
"2026.11.9.3")
def test_no_commit_refused(self):
with self.assertRaises(ValueError):
build_pypi.package_version([])
class AnUnpinnedInstallerIsRefused(unittest.TestCase):
"""The package runs the installer the one-line command runs, or none."""
def test_pin_mismatch(self):
with tempfile.TemporaryDirectory(prefix="pypi-pin-") as name:
root = Path(name)
(root / "install.py").write_bytes(b"print('another')\n")
shutil.copyfile(REPO_ROOT / "install.sh", root / "install.sh")
original = build_pypi.ROOT
build_pypi.ROOT = root
try:
with self.assertRaises(ValueError):
build_pypi.installer_bytes()
finally:
build_pypi.ROOT = original
def _python38() -> str | None:
found = shutil.which("python3.8")
if found is None and os.environ.get("CI"):
raise AssertionError("CI installs Python 3.8")
return found
class TheInstalledCommandIsTheInstaller(Built):
"""Installed in a fresh venv, `retrobios` answers as `python install.py`."""
def _installed(self, python: str, name: str) -> Path:
env = self.scratch / name
made = subprocess.run([python, "-m", "venv", str(env)], capture_output=True, text=True,
check=False)
self.assertEqual(made.returncode, 0, made.stderr[-2000:])
bindir = env / ("Scripts" if os.name == "nt" else "bin")
done = subprocess.run(
[str(bindir / "python"), "-m", "pip", "install", "--no-index", "--no-deps",
"--disable-pip-version-check", str(self.wheel)],
capture_output=True, text=True, check=False)
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
return bindir / "retrobios"
def _same_answers(self, python: str, name: str) -> None:
command = self._installed(python, name)
# `retrobios` and `python -m retrobios` both run install.py.
launchers = ([str(command)], [str(command.with_name("python")), "-m", "retrobios"])
for launcher in launchers:
for args in (["--help"], ["--version"], ["--no-such-option"]):
with self.subTest(python=name, launcher=launcher[-1], args=args):
packaged = subprocess.run([*launcher, *args], capture_output=True,
text=True, check=False, cwd=self.scratch)
script = subprocess.run([python, str(REPO_ROOT / "install.py"), *args],
capture_output=True, text=True, check=False,
cwd=self.scratch)
self.assertEqual(
(packaged.returncode, packaged.stdout, packaged.stderr),
(script.returncode, script.stdout, script.stderr))
def test_this_python(self):
self._same_answers(sys.executable, "venv-current")
def test_python_38(self):
python = _python38()
if python is None:
self.skipTest("python3.8 is not on PATH")
self._same_answers(python, "venv-38")
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -40,6 +40,15 @@ irm https://abdess.github.io/retrobios/i.ps1 | iex
Always type the `https://`: without it the first request goes out in clear
text, and anyone on the way could answer with another script.
With Python 3.8 or newer, the same installer is a package:
```bash
uvx retrobios
```
`pipx run retrobios` works too, or `pip install retrobios` and then
`retrobios`. Options follow the command: `uvx retrobios --check`.
`i` and `i.ps1` are `install.sh` and `install.ps1` as of the commit the site
was built from, set to download `install.py` from that same commit. The
fingerprint they check and the installer they fetch never come from two
+17 -2
View File
@@ -110,8 +110,8 @@ scripts stay Python.
`platforms/**`, `emulators/**`, `schemas/**`, `scripts/**`, `tests/**`,
`install.py`, `install.sh` or `install.ps1` (the bootstraps pin the
installer's SHA-256, so editing one without the other fails the run),
`ports/**` (the handheld port bundles the installer), or any input of the site
build. The path lists are spelled out once per event because the workflow
`ports/**` (the handheld port bundles the installer), `packaging/**` (the PyPI
package is built from it), or any input of the site build. The path lists are spelled out once per event because the workflow
parser reads no YAML anchor, and a test holds the two equal.
**Concurrency.** Per-PR group on a pull request, per-ref on a push. A pull
@@ -312,6 +312,21 @@ git status --short
git add release.json README.md install/*.json install/targets/*.json
git commit -m "chore: record release v${DATE}" && git push
# 5b. The PyPI package, `uvx retrobios`: install.py and its command. The
# version is the UTC date of the last commit that changed install.py
# (2026-11-09 gives 2026.11.9, the third change that day 2026.11.9.3),
# so an unchanged install.py keeps its version and PyPI refuses it
# again. Publishing is not tied to a release: any push that changes
# install.py can be published. The build replaces any earlier one in
# tmp/pypi-dist. `uv build` builds it with the flit_core backend the
# project pins, dated by that commit (SOURCE_DATE_EPOCH), so a rebuild
# gives the same bytes. Build, run the wheel, then upload the two files. The
# token stays in its file: never in the repository, a script or a log.
python scripts/build_pypi.py --output-dir tmp/pypi-dist
python -m unittest tests.test_pypi_package
uvx --from tmp/pypi-dist/retrobios-*.whl retrobios --help
uv publish --token "$(cat ~/.config/retrobios/pypi-token)" tmp/pypi-dist/*
# 6. Keep only the new release plus large-files: an older pack carries hashes
# the platforms no longer check, so it misleads more than it helps
gh release list --json tagName,createdAt \
+1 -1
View File
@@ -59,7 +59,7 @@ Every module in `tests/`, by the area it guards.
| Packs and manifests | synthetic + real packs | `test_artifact_lock`, `test_atomic_artifacts`, `test_batocera_cores`, `test_core_data_trees`, `test_deterministic_zip`, `test_emulator_pack_counts`, `test_finalize_pack_names`, `test_large_file_cache`, `test_libretro_layout`, `test_manifest_entry`, `test_minui_platform`, `test_no_game_data`, `test_pack_conformance_dirs`, `test_pack_core_extras`, `test_pack_counts`, `test_pack_destinations`, `test_pack_failures`, `test_pack_file_names`, `test_pack_grouping`, `test_pack_integrity`, `test_pack_member_identity`, `test_pack_readme`, `test_platform_inheritance`, `test_platform_keys`, `test_release_packs`, `test_required_only_archives`, `test_required_only_twins`, `test_save_root`, `test_split_extras`, `test_split_names`, `test_split_pack`, `test_system_pack_extras`, `test_system_pack_parity`, `test_torrentzip`, `test_which_pack` |
| 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_app`, `test_install_from`, `test_install_games`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_app`, `test_install_from`, `test_install_games`, `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` |
| 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` |