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):