#!/usr/bin/env python3 """Generate grouped GitHub release notes for published BC-250 images.""" from __future__ import annotations import argparse import datetime as dt import json import re import subprocess import sys import time from pathlib import Path CHANNELS = [ { "channel": "stable", "label": "Stable", "suffix": "", }, { "channel": "testing", "label": "Testing", "suffix": "-testing", }, { "channel": "unstable", "label": "Unstable", "suffix": "-unstable", }, ] BASE_PACKAGES = [ { "kind": "normal", "family": "Deck", "variant": "Deck / Game Mode", "name": "bazzite-bc250-patched-deck", }, { "kind": "normal", "family": "GNOME", "variant": "GNOME", "name": "bazzite-bc250-patched-gnome", }, { "kind": "normal", "family": "KDE", "variant": "KDE", "name": "bazzite-bc250-patched-kde", }, { "kind": "40cu", "family": "Deck", "variant": "Deck / Game Mode 40CU", "name": "bazzite-bc250-patched-deck-40cu", }, { "kind": "40cu", "family": "GNOME", "variant": "GNOME 40CU", "name": "bazzite-bc250-patched-gnome-40cu", }, { "kind": "40cu", "family": "KDE", "variant": "KDE 40CU", "name": "bazzite-bc250-patched-kde-40cu", }, ] PACKAGES = [ { **base_package, "channel": channel["channel"], "channel_label": channel["label"], "name": f"{base_package['name']}{channel['suffix']}", } for channel in CHANNELS for base_package in BASE_PACKAGES ] SPONSORS = """# 🎉 Sponsors ## Printer Tools App [![Banner - Printer Tools App](https://github.com/62fixolab/62fixolab/raw/master/assets/banner-printer-tools.png)](https://printertools.app) ## Scooter Tools App [![Banner - Scooter Tools App](https://github.com/62fixolab/62fixolab/raw/master/assets/banner-scooter-tools.png)](https://scootertools.app) ## AdMate App [![Banner - AdMate App](https://github.com/62fixolab/62fixolab/raw/master/assets/banner-admate.png)](https://admate.dev) """ def normalize_date(value: str | None) -> tuple[str, str]: if value is None: today = dt.datetime.now(dt.timezone.utc).date() return today.strftime("%Y%m%d"), today.strftime("%Y.%m.%d") raw = value.strip() match = re.fullmatch(r"(\d{4})[.-]?(\d{2})[.-]?(\d{2})", raw) if not match: raise SystemExit(f"Invalid date '{value}'. Use YYYY.MM.DD, YYYY-MM-DD, or YYYYMMDD.") y, m, d = match.groups() dt.date(int(y), int(m), int(d)) return f"{y}{m}{d}", f"{y}.{m}.{d}" def run_json(command: list[str], retries: int, retry_delay: int) -> object: last_error = "" for attempt in range(1, retries + 1): proc = subprocess.run(command, text=True, capture_output=True, check=False) if proc.returncode == 0: return json.loads(proc.stdout) last_error = proc.stderr.strip() or proc.stdout.strip() if "HTTP 404" in last_error or "Not Found" in last_error: raise FileNotFoundError(last_error) if attempt < retries: time.sleep(retry_delay) raise RuntimeError(f"Command failed after {retries} attempts: {' '.join(command)}\n{last_error}") def fetch_package_versions(owner: str, package: str, retries: int, retry_delay: int) -> list[dict]: errors: list[str] = [] for scope in ("users", "orgs"): path = f"/{scope}/{owner}/packages/container/{package}/versions?per_page=100" try: data = run_json(["gh", "api", path], retries, retry_delay) except FileNotFoundError as exc: errors.append(str(exc)) continue except RuntimeError as exc: errors.append(str(exc)) continue if isinstance(data, list): return data if any("HTTP 404" in error or "Not Found" in error for error in errors): return [] raise RuntimeError("\n".join(errors)) def version_tags(version: dict) -> list[str]: metadata = version.get("metadata") or {} container = metadata.get("container") or {} tags = container.get("tags") or [] return [str(tag) for tag in tags] def collect_entries(owner: str, date_ymd: str, retries: int, retry_delay: int) -> list[dict]: entries: list[dict] = [] exact_tag_re = re.compile(rf"^{re.escape(date_ymd)}-(\d+)$") for package in PACKAGES: versions = fetch_package_versions(owner, package["name"], retries, retry_delay) for version in versions: tags = version_tags(version) exact_tags = sorted(tag for tag in tags if exact_tag_re.fullmatch(tag)) if not exact_tags: continue exact_tag = exact_tags[-1] os_version = exact_tag.rsplit("-", 1)[-1] commit_tags = sorted( tag for tag in tags if re.fullmatch(r"(?!\d{8}-\d+$)[0-9a-f]{7,40}-\d+", tag) ) digest = str(version.get("name", "")) if not digest.startswith("sha256:"): digest = f"sha256:{digest}" entries.append( { **package, "exact_tag": exact_tag, "os_version": os_version, "base_version": f"F{os_version}.{date_ymd}", "digest": digest, "created_at": version.get("created_at", ""), "commit_tag": commit_tags[-1] if commit_tags else "", } ) break return entries def list_dates(owner: str, retries: int, retry_delay: int) -> list[str]: dates: set[str] = set() date_tag_re = re.compile(r"^(\d{4})(\d{2})(\d{2})(?:-\d+)?$") for package in PACKAGES: versions = fetch_package_versions(owner, package["name"], retries, retry_delay) for version in versions: for tag in version_tags(version): match = date_tag_re.fullmatch(tag) if match: dates.add(".".join(match.groups())) return sorted(dates) def unique_package_commit_tag(entries: list[dict]) -> str: commit_tags = sorted( { entry["commit_tag"].split("-", 1)[0] for entry in entries if entry.get("commit_tag") } ) return commit_tags[0] if len(commit_tags) == 1 else "" def package_url(repo_url: str, package: str) -> str: return f"{repo_url}/pkgs/container/{package}" def image_ref(owner: str, package: str, tag: str) -> str: return f"ghcr.io/{owner}/{package}:{tag}" def install_block(owner: str, entry: dict, tag: str) -> str: return "\n".join( [ f"{entry['variant']}:", "", "```bash", f"rpm-ostree rebase ostree-image-signed:docker://{image_ref(owner, entry['name'], tag)}", "systemctl reboot", "```", ] ) def markdown_table(entries: list[dict], owner: str, repo_url: str) -> str: lines = [ "| Variant | Bazzite base | Image | Exact tag | Digest |", "| --- | --- | --- | --- | --- |", ] for entry in entries: image = f"ghcr.io/{owner}/{entry['name']}" package_link = package_url(repo_url, entry["name"]) lines.append( f"| {entry['variant']} | `{entry['base_version']}` | [`{image}`]({package_link}) | " f"`{entry['exact_tag']}` | `{entry['digest']}` |" ) return "\n".join(lines) def base_version_summary(entries: list[dict]) -> str: channel_order = ["stable", "testing", "unstable"] channel_labels = {channel["channel"]: channel["label"] for channel in CHANNELS} family_order = ["Deck", "GNOME", "KDE"] grouped: dict[str, dict[str, set[str]]] = {} for entry in entries: channel_group = grouped.setdefault(entry["channel"], {}) channel_group.setdefault(entry["base_version"], set()).add(entry["family"]) def base_sort_key(item: tuple[str, set[str]]) -> tuple[int, str]: base_version, families = item ordered_families = [family_order.index(family) for family in families if family in family_order] return (min(ordered_families) if ordered_families else 99, base_version) parts: list[str] = [] for channel in channel_order: if channel not in grouped: continue channel_parts: list[str] = [] for base_version, families in sorted(grouped[channel].items(), key=base_sort_key): label = "/".join(family for family in family_order if family in families) channel_parts.append(f"{label} `{base_version}`") parts.append(f"{channel_labels[channel]}: {', '.join(channel_parts)}") return "; ".join(parts) def channel_labels_for_entries(entries: list[dict]) -> str: channel_order = [channel["channel"] for channel in CHANNELS] channel_labels = {channel["channel"]: channel["label"] for channel in CHANNELS} present = {entry["channel"] for entry in entries} return ", ".join(channel_labels[channel] for channel in channel_order if channel in present) def channel_entries(entries: list[dict], channel: str, kind: str) -> list[dict]: return [entry for entry in entries if entry["channel"] == channel and entry["kind"] == kind] def family_summary(entries: list[dict]) -> str: family_order = ["Deck", "GNOME", "KDE"] present = {entry["family"] for entry in entries} return ", ".join(family for family in family_order if family in present) def render_notes( *, owner: str, repo: str, date_display: str, entries: list[dict], commit: str | None, run_url: str | None, ) -> str: repo_url = f"https://github.com/{owner}/{repo}" normal_entries = [entry for entry in entries if entry["kind"] == "normal"] experimental_entries = [entry for entry in entries if entry["kind"] == "40cu"] lines: list[str] = [ SPONSORS.rstrip(), "", f"# Bazzite BC-250 Patched Images {date_display}", "", "New Bazzite AMD BC-250 patched image batch published to GHCR.", "", "> [!IMPORTANT]", "> These are OCI images, not ISOs. Install or update with `rpm-ostree rebase`.", "", "## What changed", "", "- Rebuilt from updated official Bazzite channel bases.", f"- Bazzite base versions: {base_version_summary(entries)}.", f"- Published updated channels: {channel_labels_for_entries(entries)}.", ] if normal_entries: lines.append(f"- Published updated normal images: {family_summary(normal_entries)}.") if experimental_entries: lines.append(f"- Published updated optional experimental `-40cu` images: {family_summary(experimental_entries)}.") non_stable_channels = {entry["channel"] for entry in entries if entry["channel"] != "stable"} if non_stable_channels: lines.append("- Added separate Bazzite `testing`/`unstable` packages so users can test those channels without replacing stable `latest` packages.") lines.extend( [ "", "## Included in every image", "", "- `cyan-skillfish-governor-smu`.", "- GPU frequency scaling for AMD BC-250 boards.", "- MangoHud/radeontop `655%` GPU usage telemetry fix.", "- Signed images for `ostree-image-signed` rebases.", "", ] ) for channel in CHANNELS: normal = channel_entries(entries, channel["channel"], "normal") experimental = channel_entries(entries, channel["channel"], "40cu") if normal: section_name = "Recommended stable images" if channel["channel"] == "stable" else f"{channel['label']} images" lines.extend( [ f"## {section_name}", "", "Use these unless you are deliberately testing extra CUs." if channel["channel"] == "stable" else "Use these only if you deliberately want this Bazzite update channel.", "", markdown_table(normal, owner, repo_url), "", f"### Install {channel['label'].lower()} images", "", ] ) for entry in normal: lines.append(install_block(owner, entry, "latest")) lines.append("") lines.extend( [ "Pin this exact build by replacing `latest` with the exact tag from the table above.", "", ] ) if experimental: section_name = ( "Experimental stable 40CU images" if channel["channel"] == "stable" else f"Experimental {channel['label']} 40CU images" ) lines.extend( [ f"## {section_name}", "", "> [!CAUTION]", "> The `-40cu` images do not force 40CU on boot. They include tools to test extra CUs. 32CU/40CU stability is silicon lottery.", "", markdown_table(experimental, owner, repo_url), "", f"Full 40CU guide: {repo_url}/blob/main/docs/40cu.md", "", f"### Install {channel['label'].lower()} experimental 40CU images", "", "Use these only if you want to test optional CU unlock tooling.", "", ] ) for entry in experimental: lines.append(install_block(owner, entry, "latest")) lines.append("") lines.extend( [ "Pin this exact build by replacing `latest` with the exact tag from the table above.", "", ] ) lines.extend( [ "## Documentation", "", f"- Main README: {repo_url}", f"- Bazzite channel/package guide: {repo_url}/blob/main/docs/update-channels.md", ] ) if experimental_entries: lines.extend( [ f"- Full 40CU guide: {repo_url}/blob/main/docs/40cu.md", "", "> [!WARNING]", "> If you install a `-40cu` image, read the 40CU guide before saving any boot profile. The image includes testing tools, but extra CUs are still silicon lottery.", "", ] ) else: lines.append("") lines.extend( [ "## Package pages", "", ] ) for entry in entries: lines.append(f"- {package_url(repo_url, entry['name'])}") lines.extend( [ "", "## Source build", "", ] ) if run_url: lines.append(f"- Workflow run: {run_url}") if commit: lines.append(f"- Commit: `{commit}`") else: commit_tag = unique_package_commit_tag(entries) if commit_tag: lines.append(f"- Package commit tag: `{commit_tag}`") lines.append(f"- Build date: `{date_display}`") return "\n".join(lines).rstrip() + "\n" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--owner", required=True, help="GitHub/GHCR owner, for example 62fixolab.") parser.add_argument("--repo", default="Latest-Bazzite-AMD-BC-250-Patched-Images") parser.add_argument("--date", help="Release date as YYYY.MM.DD, YYYY-MM-DD, or YYYYMMDD. Defaults to UTC today.") parser.add_argument("--commit", help="Commit SHA to show in release notes.") parser.add_argument("--run-url", help="Workflow run URL to show in release notes.") parser.add_argument("--output", help="Write release notes to this file. Defaults to stdout.") parser.add_argument("--list-dates", action="store_true", help="List known publish dates and exit.") parser.add_argument("--print-package-commit-tag", action="store_true", help="Print the unique package commit tag for a date and exit.") parser.add_argument("--retries", type=int, default=8) parser.add_argument("--retry-delay", type=int, default=10) return parser.parse_args() def main() -> int: args = parse_args() if args.list_dates: for date_value in list_dates(args.owner, args.retries, args.retry_delay): print(date_value) return 0 date_ymd, date_display = normalize_date(args.date) entries = collect_entries(args.owner, date_ymd, args.retries, args.retry_delay) if not entries: raise SystemExit(f"No package tags found for {date_display}.") if args.print_package_commit_tag: commit_tag = unique_package_commit_tag(entries) if commit_tag: print(commit_tag) return 0 notes = render_notes( owner=args.owner, repo=args.repo, date_display=date_display, entries=entries, commit=args.commit, run_url=args.run_url, ) if args.output: output_path = Path(args.output) output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(notes, encoding="utf-8") else: sys.stdout.write(notes) return 0 if __name__ == "__main__": raise SystemExit(main())