diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 00000000..d21f2f88 --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Write the notes of a release from its record. + +The notes carried the pack table, typed by hand from figures release.json +already held: a copied cell or an old collection total at the top is how +issue 81 happened. Install commands, the parts sentence and the table come +from the record and the registry. The maintainer writes what changed, and +that text is appended as given. + +Usage: + python scripts/release_notes.py --whats-new tmp/whats-new.md --output notes.md +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import release_record +from common import load_platform_registry, write_text_atomic + +REPO = "Abdess/retrobios" +RAW = f"https://raw.githubusercontent.com/{REPO}/main" + + +def _size(count: int) -> str: + if count >= 1024**3: + return f"{count / 1024**3:.1f} GB" + return f"{count / 1024**2:.0f} MB" + + +def _display(pack: str) -> str: + """RetroArch_Lakka_v1.22.2_BIOS_Pack.zip reads as RetroArch Lakka v1.22.2.""" + return pack.split("_BIOS_Pack", 1)[0].replace("_", " ") + + +def _extract_folders(record: dict, platforms_dir: str) -> dict[str, str]: + """Pack name to the folder the registry says it extracts into.""" + folders: dict[str, str] = {} + for key, entry in sorted(load_platform_registry(platforms_dir).items()): + if not entry.get("extract_to"): + continue + needle = release_record._match_key(key) + for name in sorted(record.get("packs", {})): + # The name a pack starts with: RetroArch_Lakka is RetroArch's. + if needle and release_record._match_key(name).startswith(needle): + folders.setdefault(name, entry["extract_to"]) + return folders + + +def _links(tag: str, assets: list[str]) -> str: + base = f"https://github.com/{REPO}/releases/download/{tag}" + if len(assets) == 1: + return f"[{assets[0]}]({base}/{assets[0]})" + return " + ".join(f"[part {n}]({base}/{asset})" for n, asset in enumerate(assets, 1)) + + +def render(record: dict, platforms_dir: str, whats_new: str) -> str: + tag = record["tag"] + packs = record.get("packs", {}) + folders = _extract_folders(record, platforms_dir) + lines = [ + "### Install in one command", + "", + ("The installer detects the platform and its BIOS folder, downloads only" + " what is missing and checks every file. Running it again is safe."), + "", + "**Linux, macOS, Steam Deck**", + "```sh", + f"curl -fsSL {RAW}/install.sh | sh", + "```", + "", + "**Windows (PowerShell)**", + "```powershell", + f"irm {RAW}/install.ps1 | iex", + "```", + "", + "### Download a pack", + "", + ("One pack per platform, the complete one: the BIOS list the platform" + " publishes plus every file its emulators open. Files is what the pack" + " holds once extracted, the count a file manager shows. Extracted size" + " is the space those files take, and the download is smaller."), + "", + ] + if any(len(pack["assets"]) > 1 for pack in packs.values()): + lines += [ + ("A pack over 2 GB comes as parts that are each an ordinary ZIP of whole" + " files (`Pack.part1of2.zip`, `Pack.part2of2.zip`). Download every part" + " and extract them all into the same folder. Nothing needs joining."), + "", + ] + lines += [ + "| Platform | Download | Extracted size | Files | Extract to |", + "|----------|----------|---------------:|------:|-----------|", + ] + for name, pack in sorted(packs.items(), key=lambda item: -item[1]["extracted_size"]): + folder = folders.get(name) + lines.append( + f"| {_display(name)} | {_links(tag, pack['assets'])} |" + f" {_size(pack['extracted_size'])} | {pack['files']:,} |" + f" {f'`{folder}`' if folder else '-'} |" + ) + lines += ["", whats_new.strip(), ""] + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(allow_abbrev=False, description="Write a release's notes") + parser.add_argument("--record", default=release_record.RECORD, type=Path, + help="release record (default: %(default)s)") + parser.add_argument("--whats-new", required=True, type=Path, + help="what changed since the previous release, written by hand") + parser.add_argument("--platforms-dir", default="platforms") + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + + record = release_record.load_record(args.record) + if not record.get("packs"): + print(f"Error: {args.record} records no pack", file=sys.stderr) + return 1 + whats_new = args.whats_new.read_text(encoding="utf-8") + if not whats_new.strip(): + print(f"Error: {args.whats_new} is empty", file=sys.stderr) + return 1 + write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new)) + print(f"Wrote {args.output} for {record['tag']}, {len(record['packs'])} packs") + return 0 + + +if __name__ == "__main__": + from common import run_cli + + run_cli(main) diff --git a/tests/test_pack_counts.py b/tests/test_pack_counts.py index 7158cd01..be858eff 100644 --- a/tests/test_pack_counts.py +++ b/tests/test_pack_counts.py @@ -35,6 +35,7 @@ import generate_db # noqa: E402 import generate_pack as builder # noqa: E402 import generate_readme # noqa: E402 import generate_site # noqa: E402 +import release_notes # noqa: E402 import release_record # noqa: E402 import split_pack # noqa: E402 @@ -474,5 +475,47 @@ class RefusedFilesAreNotPresent(unittest.TestCase): self.assertEqual((coverage["present"], coverage["total"]), (1, 2)) +class TheReleaseNotesReadTheRecord(unittest.TestCase): + """The notes' pack table was typed by hand from release.json: a copied + cell or an old collection total at the top reproduced issue 81.""" + + RECORD = {"tag": "v2026.10.10", "packs": { + "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip": { + "files": 8504, "extracted_size": 6_400_000_000, "download_size": 4_000_000_000, + "assets": ["RetroArch_Lakka_v1.22.2_BIOS_Pack.part1of2.zip", + "RetroArch_Lakka_v1.22.2_BIOS_Pack.part2of2.zip"]}, + "EmuDeck_2.7EA_BIOS_Pack.zip": { + "files": 620, "extracted_size": 3_500_000_000, "download_size": 3_000_000_000, + "assets": ["EmuDeck_2.7EA_BIOS_Pack.zip"]}, + }} + + def render(self, record=None): + return release_notes.render(record or self.RECORD, str(REPO_ROOT / "platforms"), + "### What changed\n\n- x\n") + + def test_each_pack_row_carries_the_record(self): + text = self.render() + row = next(line for line in text.splitlines() if line.startswith("| RetroArch")) + self.assertIn("8,504", row) + self.assertIn("part1of2.zip", row) + self.assertIn("part2of2.zip", row) + self.assertIn("`system/`", row) + self.assertIn("| Platform | Download | Extracted size | Files |", text) + + def test_the_parts_sentence_sits_above_the_table_only_when_needed(self): + text = self.render() + self.assertLess(text.index("ordinary ZIP"), text.index("| Platform")) + whole = {"tag": "v1", "packs": {"EmuDeck_2.7EA_BIOS_Pack.zip": + self.RECORD["packs"]["EmuDeck_2.7EA_BIOS_Pack.zip"]}} + self.assertNotIn("ordinary ZIP", self.render(whole)) + + def test_the_notes_never_open_on_the_collection(self): + self.assertNotIn("collection", self.render().split("### What changed")[0]) + + def test_the_release_process_runs_it(self): + page = (REPO_ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8") + self.assertIn("scripts/release_notes.py", page) + + if __name__ == "__main__": unittest.main() diff --git a/wiki/release-process.md b/wiki/release-process.md index 865b7d0c..b21ff676 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -218,10 +218,15 @@ python scripts/release_record.py dist/ --tag "v${DATE}" python scripts/generate_readme.py --db database.json --platforms-dir platforms python scripts/generate_site.py -# 6. Create the release as a DRAFT, upload every asset, and only then publish it. +# 6. Write the notes: install commands, the parts sentence and the pack table +# come from release.json, and what changed since the previous tag is the +# maintainer's own text in tmp/whats-new.md, appended as written. +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 notes.md +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; do [ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber done @@ -292,9 +297,10 @@ and keeping the retired line, so signatures on past releases keep verifying. One pack per platform, the full one: the platform's list plus everything its cores load. Platform-only and per-emulator packs are build options, not release assets, since a lighter pack means a core that fails with no message. -The release notes follow the previous release: the quick install commands, -the pack table, what changed since the previous tag, and the contributors of -the closed issues. A pack has two sizes and they are far apart: Batocera +The release notes are written by `release_notes.py`: the install commands, +the pack table and its parts sentence come from `release.json`, then what +changed since the previous tag and the contributors of the closed issues, +written by hand. A pack has two sizes and they are far apart: Batocera downloads as 2.4 GB and extracts to 4.0 GB. Step 5 prints both, and whichever one the table carries, the header names it. Someone sizing a USB drive is reading that column. The README table is the extracted size of the released diff --git a/wiki/tools.md b/wiki/tools.md index c3ca6b28..45a4c1cf 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -611,6 +611,7 @@ Further options, by script: | `provenance_report.py` | `--provenance-dir`, `--write-derivable PATH` (with `--derive`, and the pipeline writes `derivable.json`) | | `refresh_stale.py` | `--freshness-arg FLAG` passes a flag through to `check_freshness.py`, repeatable | | `release_record.py` | `--install-dir` (manifests the archives are held against), `--output` | +| `release_notes.py` | `--whats-new FILE` (required, appended as written), `--record`, `--platforms-dir`, `--output` | | `restore_large_files.py` | `--cache` (downloaded assets), `--gitignore`, `--db` | | `romset_recipes.py` | `--dat-recipes PATH`, a snapshot or a directory of them (default `recipes/`) | | `split_pack.py` | `--max-size 1900M`, the ceiling per part (K, M, G, default 2G) |