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.
This commit is contained in:
Abdessamad Derraz committed 2026-11-10 11:50:39 +01:00
1 parent 962f18ac3f
commit d5c2ba266f
2 files changed
+282 -16

No files matched your search

+143 -12
View File
@@ -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", "", "<details>",
"<summary>Everyone who helped since the start</summary>", "",
*thanks, "", "</details>", ""]
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