mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-13 05:23:51 -06:00
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:
1 parent
962f18ac3f
commit
d5c2ba266f
2 files changed
+282
-16
No files matched your search
+143
-12
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user