From d5c2ba266f6350cb3c5ceb9189d086a5d78115ce Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Tue, 10 Nov 2026 11:50:39 +0100
Subject: [PATCH] docs: lead the release notes with the command
The notes open on the command and the handheld app, then the full packs
for a device without internet, the setups the command alone serves, what
changed, everyone who helped since the start (read with gh, --thanks FILE
for a checked list) in a folded block, and the full changelog from the
previous release's record commit.
---
scripts/release_notes.py | 155 +++++++++++++++++++++++++++++++++---
tests/test_release_packs.py | 143 ++++++++++++++++++++++++++++++++-
2 files changed, 282 insertions(+), 16 deletions(-)
diff --git a/scripts/release_notes.py b/scripts/release_notes.py
index dff6f6f49..933802f2b 100644
--- a/scripts/release_notes.py
+++ b/scripts/release_notes.py
@@ -11,6 +11,14 @@ idea per line, plain words (owner, 2026-11-10). The maintainer writes
what changed, and that text is appended as given once it holds none of the
REFUSED phrases.
+The notes end with the people to thank and the full changelog, from the
+previous release's record commit (the first commit release.json gave the
+previous tag) to the new tag. The thanks name everyone who helped since the
+repository started, read with gh: a login is only ever the one GitHub links
+to the work, never one guessed from a git author name. The owner, bots and
+spam are left out (LEFT_OUT). --thanks FILE passes a checked list instead
+of the one gh gives.
+
Usage:
python scripts/release_notes.py --whats-new tmp/whats-new.md --output notes.md
"""
@@ -18,8 +26,11 @@ Usage:
from __future__ import annotations
import argparse
+import json
import re
+import subprocess
import sys
+from collections.abc import Callable
from pathlib import Path
import release_record
@@ -34,7 +45,12 @@ from common import (
from siteguides import install_command
REPO = "Abdess/retrobios"
+OWNER = "Abdess"
SITE = "https://abdess.github.io/retrobios/"
+# The handheld app is served by the site, built from its own commit, never
+# attached to a release. The installer's handheld section opens on the
+# download of every system.
+HANDHELD_PAGE = "wiki/installer/#on-a-handheld-the-retrobios-port"
def _size(count: int) -> str:
@@ -79,14 +95,18 @@ 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"). The command reads the install manifest,
+ which the pack builder writes from the same resolution as the 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. It reads the same file list a pack"
+ " is built from."),
"",
]
@@ -119,31 +139,126 @@ def refused_words(text: str) -> list[str]:
return [phrase for phrase in REFUSED if phrase in lowered]
-def render(record: dict, platforms_dir: str, whats_new: str) -> str:
+Run = Callable[[list[str]], str]
+
+
+def _run(argv: list[str]) -> str:
+ return subprocess.run(argv, capture_output=True, text=True, check=True).stdout
+
+
+def previous_record_commit(tag: str, run: Run | None = None) -> str:
+ """The commit that recorded the release before *tag*: the oldest commit
+ of the run of release.json changes that name the previous tag."""
+ run = run or _run
+ shas = run(["git", "log", "--format=%H", "--", "release.json"]).split()
+ previous, base = None, None
+ for sha in shas:
+ recorded = json.loads(run(["git", "show", f"{sha}:release.json"])).get("tag")
+ if previous is None:
+ if recorded != tag:
+ previous, base = recorded, sha
+ elif recorded == previous:
+ base = sha
+ else:
+ break
+ if base is None:
+ raise ValueError(f"release.json records no release before {tag}")
+ return base
+
+
+# Accounts that never helped: the owner's own, GitHub's placeholder for a
+# deleted account, and spam (issue 32 by Bappo0 and pull request 67 by
+# prakashmagat50-byte, both empty or garbage).
+LEFT_OUT = frozenset(("Abdess", "Abdess76", "ghost", "Bappo0", "prakashmagat50-byte"))
+
+
+def _person(login: str | None) -> str | None:
+ """The login to thank, None for the owner, a bot, a deleted account,
+ spam, or work GitHub links to no account."""
+ if not login or login in LEFT_OUT or login.endswith("[bot]") or login.startswith("app/"):
+ return None
+ return login
+
+
+def _logins(text: str) -> set[str]:
+ """One login per line, as `gh api --paginate --jq` prints them."""
+ return {person for line in text.splitlines() if (person := _person(line.strip()))}
+
+
+def _authors(text: str) -> set[str]:
+ """The authors of a `gh pr list` or `gh issue list --json author`."""
+ return {person for item in json.loads(text)
+ if not (item.get("author") or {}).get("is_bot")
+ and (person := _person((item.get("author") or {}).get("login")))}
+
+
+def gather_thanks(run: Run | None = None, repo: str = REPO) -> list[str]:
+ """Everyone who helped since the repository started, each named once:
+ commit authors as the commits API links them to an account and every
+ pull request author, merged or not, then issue authors, then whoever
+ commented on an issue or a pull request."""
+ run = run or _run
+ code = _logins(run(["gh", "api", "--paginate", f"repos/{repo}/commits",
+ "--jq", ".[] | .author.login // empty"]))
+ code |= _authors(run(["gh", "pr", "list", "-R", repo, "--state", "all",
+ "--limit", "10000", "--json", "number,author"]))
+ reports = _authors(run(["gh", "issue", "list", "-R", repo, "--state", "all",
+ "--limit", "10000", "--json", "number,author"])) - code
+ helped = set()
+ for path in (f"repos/{repo}/issues/comments", f"repos/{repo}/pulls/comments"):
+ helped |= _logins(run(["gh", "api", "--paginate", path, "--jq", ".[] | .user.login"]))
+ helped -= code | reports
+ lines = []
+ for label, people in (("Code and data", code), ("Reports", reports),
+ ("Help in issues", helped)):
+ if people:
+ names = [f"@{login}" for login in sorted(people, key=str.casefold)]
+ listed = names[0] if len(names) == 1 else f"{', '.join(names[:-1])} and {names[-1]}"
+ lines.append(f"- **{label}**: {listed}.")
+ return lines
+
+
+def changelog_url(base: str, tag: str, repo: str = REPO) -> str:
+ return f"https://github.com/{repo}/compare/{base[:9]}...{tag}"
+
+
+def closing_lines(thanks: list[str], changelog: str) -> list[str]:
+ """The Thanks section, left out when nobody is to thank, and the full
+ changelog line."""
+ # Folded: the lists run to a hundred names. GitHub renders the markdown
+ # inside only with a blank line on each side of it.
+ lines = (["### Thanks", "", "",
+ "Everyone who helped since the start
", "",
+ *thanks, "", " ", ""]
+ if thanks else [])
+ return [*lines, f"**Full changelog**: {changelog}", ""]
+
+
+def render(record: dict, platforms_dir: str, whats_new: str,
+ closing: list[str] | None = None) -> str:
packs = record.get("packs", {})
folders = _extract_folders(record, platforms_dir)
tag = str(record.get("tag", ""))
lines = [
- "### Install in one command",
+ "### Get your BIOS",
"",
- "It finds your setup and its BIOS folder, then downloads only what is missing.",
+ "Paste this in a terminal. It finds your setup and installs the missing BIOS files.",
"",
"**Linux, macOS, Steam Deck**",
"```sh",
install_command("sh"),
"```",
"",
- "**Windows (PowerShell)**",
+ "**Windows**",
"```powershell",
install_command("powershell"),
"```",
"",
- (f"**On a handheld**: copy the `RetroBIOS` file for your system to the card"
- f" ([which one]({SITE}wiki/installer/#on-a-handheld-the-retrobios-port))."),
+ f"**On a handheld**: [get the app for your system]({SITE}{HANDHELD_PAGE}).",
"",
- "### Or download a pack",
+ "### Full packs",
"",
- "Extract it into your BIOS folder.",
+ "Large downloads, for a device without internet. Extract into your BIOS folder.",
"",
]
if any(len(pack["assets"]) > 1 for pack in packs.values()):
@@ -170,6 +285,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 +298,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 +319,20 @@ def main() -> int:
" changed and how it was checked, not a verdict on other projects or a"
" promise the gap analysis contradicts", file=sys.stderr)
return 1
- write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new))
+ try:
+ base = previous_record_commit(record["tag"])
+ if args.thanks:
+ thanks = [line for line in args.thanks.read_text(encoding="utf-8").splitlines()
+ if line.strip()]
+ else:
+ thanks = gather_thanks()
+ except (subprocess.CalledProcessError, ValueError, KeyError, OSError) as exc:
+ detail = getattr(exc, "stderr", "") or exc
+ print(f"Error: cannot gather the thanks: {str(detail).strip()}. Pass a checked list"
+ " with --thanks FILE", file=sys.stderr)
+ return 1
+ closing = closing_lines(thanks, changelog_url(base, record["tag"]))
+ write_text_atomic(str(args.output), render(record, args.platforms_dir, whats_new, closing))
print(f"Wrote {args.output} for {record['tag']}, {len(record['packs'])} packs")
return 0
diff --git a/tests/test_release_packs.py b/tests/test_release_packs.py
index 33d5e88bb..563dc83a7 100644
--- a/tests/test_release_packs.py
+++ b/tests/test_release_packs.py
@@ -313,13 +313,14 @@ class TheReleaseNotesNameTheSetupsWithoutAZip(unittest.TestCase):
import release_notes
text = release_notes.render(self.RECORD, PLATFORMS, "### What changed\n\n- x\n")
- paragraph = next(line for line in text.splitlines() if line.startswith("No ZIP for "))
+ paragraph = next(line for line in text.splitlines()
+ if line.startswith("Only the most used setups have a pack"))
for name in ("KNULLI", "Recalbox", "RetroPie", "Lakka"):
self.assertIn(name, paragraph)
for name in ("RetroArch", "Batocera", "RomM"):
self.assertNotIn(f" {name},", paragraph)
- self.assertIn("The command above", paragraph)
- self.assertIn("--dry-run", paragraph)
+ self.assertIn("use the command above", paragraph)
+ self.assertLess(text.index("| Platform"), text.index(paragraph))
self.assertLess(text.index(paragraph), text.index("### What changed"))
def test_the_rows_link_this_release_files(self):
@@ -331,7 +332,141 @@ class TheReleaseNotesNameTheSetupsWithoutAZip(unittest.TestCase):
self.assertIn(
"[Download](https://github.com/Abdess/retrobios/releases/download/"
"v2026.10.24/RomM_5_BIOS_Pack.zip)", text)
- self.assertNotIn(f"{SITE}get/", text)
+ rows = [line for line in text.splitlines() if line.startswith("| ")]
+ self.assertTrue(rows)
+ for row in rows:
+ self.assertNotIn(f"{SITE}get/", row)
+
+ def test_the_command_comes_before_the_packs(self):
+ """Most readers need a few files, not a pack of several GB (owner,
+ 2026-11-10): the command and the handheld app open the notes."""
+ import release_notes
+
+ text = release_notes.render(self.RECORD, PLATFORMS, "### What changed\n\n- x\n")
+ command = text.index("```sh")
+ # The installer's handheld section, which opens on every download.
+ handheld = text.index(f"{SITE}wiki/installer/#on-a-handheld-the-retrobios-port")
+ packs = text.index("### Full packs")
+ self.assertLess(text.index("### Get your BIOS"), command)
+ self.assertLess(command, handheld)
+ self.assertLess(handheld, packs)
+ self.assertLess(packs, text.index("| Platform"))
+ self.assertLess(packs, text.index("### What changed"))
+ self.assertNotIn("releases/download/", text[:packs])
+
+
+class TheReleaseNotesThankPeople(unittest.TestCase):
+ """The notes end with everyone who helped since the repository started
+ and the full changelog, read from gh and git (owner, 2026-11-10).
+ Fixtures stand in for both."""
+
+ TAGS = {"c4": "v2026.11.10", "c3": "v2026.10.10", "c2": "v2026.10.10",
+ "c1": "v2026.10.10", "c0": "v2026.09.04"}
+ # `gh api --paginate --jq` prints one login per line, none for a
+ # commit GitHub links to no account.
+ COMMIT_AUTHORS = "Abdess\nhabib256\nAbdess76\ndependabot[bot]\nzed\nhabib256\n"
+ PULLS = [
+ {"number": 84, "author": {"login": "dajiaohuang", "is_bot": False}},
+ {"number": 86, "author": {"login": "habib256", "is_bot": False}},
+ {"number": 80, "author": {"login": "app/dependabot", "is_bot": True}},
+ {"number": 67, "author": {"login": "prakashmagat50-byte", "is_bot": False}},
+ {"number": 70, "author": {"login": "closedwithoutmerge", "is_bot": False}},
+ ]
+ ISSUES = [
+ {"number": 87, "author": {"login": "paperweightorder", "is_bot": False}},
+ {"number": 85, "author": {"login": "72374", "is_bot": False}},
+ {"number": 83, "author": {"login": "72374", "is_bot": False}},
+ {"number": 32, "author": {"login": "Bappo0", "is_bot": False}},
+ {"number": 31, "author": {"login": "ghost", "is_bot": False}},
+ {"number": 30, "author": {"login": "habib256", "is_bot": False}},
+ {"number": 29, "author": {"login": "Abdess", "is_bot": False}},
+ ]
+ ISSUE_COMMENTS = "Abdess\nhelper\n72374\ngithub-actions[bot]\nAlpha\n"
+ REVIEW_COMMENTS = "reviewer\nhabib256\n"
+
+ def fake(self, argv):
+ self.calls.append(argv)
+ if argv[:2] == ["git", "log"]:
+ return "\n".join(self.TAGS) + "\n"
+ if argv[:2] == ["git", "show"]:
+ return json.dumps({"tag": self.TAGS[argv[2].split(":")[0]]})
+ if argv[:2] == ["gh", "api"]:
+ path = argv[3]
+ return {"repos/Abdess/retrobios/commits": self.COMMIT_AUTHORS,
+ "repos/Abdess/retrobios/issues/comments": self.ISSUE_COMMENTS,
+ "repos/Abdess/retrobios/pulls/comments": self.REVIEW_COMMENTS}[path]
+ if argv[:3] == ["gh", "pr", "list"]:
+ return json.dumps(self.PULLS)
+ if argv[:3] == ["gh", "issue", "list"]:
+ return json.dumps(self.ISSUES)
+ raise AssertionError(argv)
+
+ def setUp(self):
+ self.calls = []
+
+ def test_the_base_is_the_previous_release_record(self):
+ import release_notes
+
+ self.assertEqual(release_notes.previous_record_commit("v2026.11.10", self.fake), "c1")
+
+ def test_everyone_since_the_start_is_thanked_once(self):
+ import release_notes
+
+ lines = release_notes.gather_thanks(self.fake)
+ self.assertEqual(lines, [
+ "- **Code and data**: @closedwithoutmerge, @dajiaohuang, @habib256 and @zed.",
+ "- **Reports**: @72374 and @paperweightorder.",
+ "- **Help in issues**: @Alpha, @helper and @reviewer.",
+ ])
+ text = "\n".join(lines)
+ for left_out in ("Abdess", "bot", "dependabot", "ghost", "Bappo0", "prakashmagat50"):
+ self.assertNotIn(left_out, text)
+ # Every state: a pull request closed without merge still counts.
+ pulls = next(argv for argv in self.calls if argv[:3] == ["gh", "pr", "list"])
+ self.assertEqual(pulls[pulls.index("--state") + 1], "all")
+ issues = next(argv for argv in self.calls if argv[:3] == ["gh", "issue", "list"])
+ self.assertEqual(issues[issues.index("--state") + 1], "all")
+
+ def test_the_notes_end_with_thanks_then_the_changelog(self):
+ import release_notes
+
+ closing = release_notes.closing_lines(
+ ["- **Code and data**: @habib256."],
+ release_notes.changelog_url("55eb95344" + "0" * 31, "v2026.11.10"))
+ text = release_notes.render(TheReleaseNotesNameTheSetupsWithoutAZip.RECORD, PLATFORMS,
+ "### What changed\n\n- x\n", closing)
+ self.assertLess(text.index("### What changed"), text.index("### Thanks"))
+ self.assertIn("### Thanks\n\n\n"
+ "Everyone who helped since the start
\n\n"
+ "- **Code and data**: @habib256.\n\n \n\n**Full changelog**",
+ text)
+ self.assertEqual(release_notes.refused_words(text), [])
+ self.assertTrue(text.rstrip().endswith(
+ "**Full changelog**: https://github.com/Abdess/retrobios/compare/"
+ "55eb95344...v2026.11.10"))
+
+ def test_a_checked_list_replaces_the_one_gh_gives(self):
+ import release_notes
+
+ with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
+ tmp = Path(tmp)
+ (tmp / "whats-new.md").write_text("### What changed\n\n- x\n", encoding="utf-8")
+ (tmp / "thanks.md").write_text("- **Code and data**: @habib256.\n",
+ encoding="utf-8")
+ (tmp / "release.json").write_text(
+ json.dumps(TheReleaseNotesNameTheSetupsWithoutAZip.RECORD), encoding="utf-8")
+ argv = ["release_notes.py", "--record", str(tmp / "release.json"),
+ "--whats-new", str(tmp / "whats-new.md"), "--thanks", str(tmp / "thanks.md"),
+ "--platforms-dir", PLATFORMS, "--output", str(tmp / "notes.md")]
+ with mock.patch.object(sys, "argv", argv), \
+ mock.patch.object(release_notes, "_run", self.fake), \
+ mock.patch.object(release_notes, "gather_thanks",
+ side_effect=AssertionError("gh read")), \
+ contextlib.redirect_stdout(io.StringIO()):
+ self.assertEqual(release_notes.main(), 0)
+ text = (tmp / "notes.md").read_text(encoding="utf-8")
+ self.assertIn("- **Code and data**: @habib256.\n", text)
+ self.assertIn("/compare/c4...v2026.10.24", text)
class EveryPlatformHasAPermanentPage(unittest.TestCase):