feat: write the release notes from the record

The pack table, its parts sentence and the install commands were 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. release_notes.py renders them and appends the hand-written account of what changed.
This commit is contained in:
Abdessamad Derraz committed 2026-10-20 04:51:56 +02:00
1 parent 8bd2d319b2
commit 810396941d
4 files changed
+190 -5

No files matched your search

+135
View File
@@ -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)
+43
View File
@@ -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()
+11 -5
View File
@@ -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
+1
View File
@@ -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) |