#!/usr/bin/env python3 """Build GitHub Release notes: install URL, changelog, contributor icons.""" from __future__ import annotations import argparse import re import subprocess import sys from collections.abc import Callable from pathlib import Path REPO = Path(__file__).resolve().parent.parent def _load_changelog(): path = Path(__file__).with_name("check-changelog.py") import importlib.util spec = importlib.util.spec_from_file_location("check_changelog", path) if spec is None or spec.loader is None: raise RuntimeError(f"cannot load {path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def release_name(tag: str) -> str: """Title shown above the release body, e.g. `firecrab v0.1.2`.""" pinned = tag if tag.startswith("v") else f"v{tag}" return f"firecrab {pinned}" def _strip_version_heading(block: str) -> str: """Drop the `## [x.y.z] - date` line the release page already states.""" lines = block.splitlines() if lines and lines[0].startswith("## ["): lines = lines[1:] return "\n".join(lines).strip() def _absolute_repo_links(block: str, repo: str, tag: str) -> str: """Point in-repo markdown links at this tag's tree. A release page is not a tree page, so `](public-docs/guides/oci.md)` 404s there. """ base = f"https://github.com/{repo}/blob/{tag}" return re.sub( r"\]\((?!https?://|#)([^)]+)\)", lambda match: f"]({base}/{match.group(1).lstrip('./')})", block, ) def _link_definitions(changelog_text: str, block: str) -> list[str]: """Reference definitions for the shorthand links this block uses. The changelog keeps them in one list at the bottom of the file, so a single release's block carries `[#145]` with nothing to resolve it. """ defined = dict(re.findall(r"(?m)^\[([^\]]+)\]:\s*(\S+)\s*$", changelog_text)) already = set(re.findall(r"(?m)^\[([^\]]+)\]:", block)) used = re.findall(r"\[([^\]]+)\](?!\(|:)", block) out: list[str] = [] seen: set[str] = set() for ref in used: if ref in seen or ref in already or ref not in defined: continue seen.add(ref) out.append(f"[{ref}]: {defined[ref]}") return out def filter_contributors(names: list[str]) -> list[str]: seen: set[str] = set() out: list[str] = [] for name in names: cleaned = name.strip() if not cleaned: continue lower = cleaned.lower() if "bot" in lower or lower.endswith("[bot]"): continue if cleaned in seen: continue seen.add(cleaned) out.append(cleaned) return out def _run_git(argv: list[str]) -> str: return subprocess.run( argv, check=True, capture_output=True, text=True, ).stdout def git_contributors( repo: Path, since: str | None = None, runner: Callable[[list[str]], str] = _run_git, ) -> list[str]: """Names behind the commits in a release. `since` is the previous release's tag, which limits the list to work done for this release. Without it the whole history is counted, which credits every past release's contributors again. """ argv = ["git", "-C", str(repo), "shortlog", "-sn"] argv.append(f"{since}..HEAD" if since else "--all") names: list[str] = [] for line in runner(argv).splitlines(): parts = line.strip().split(None, 1) if len(parts) == 2: names.append(parts[1]) return filter_contributors(names) def build_notes( *, tag: str, changelog_text: str, contributors: list[str], repo: str, ) -> str: version = tag[1:] if tag.startswith("v") else tag pinned = tag if tag.startswith("v") else f"v{tag}" latest_install = ( f"https://github.com/{repo}/releases/latest/download/install.sh" ) pinned_install = ( f"https://github.com/{repo}/releases/download/{pinned}/install.sh" ) changelog = _load_changelog() changelog_block = _strip_version_heading( changelog.extract_notes(changelog_text, pinned) ) definitions = _link_definitions(changelog_text, changelog_block) changelog_block = _absolute_repo_links(changelog_block, repo, pinned) lines: list[str] = [ "## Install", "", "One command on a Linux host. The script detects `x86_64` or `aarch64`", "and `gnu` (glibc) or `musl`, then installs that host bundle.", "", "```sh", f"curl -fsSL {latest_install} | bash", "```", "", "Pin this release:", "", "```sh", f"curl -fsSL {pinned_install} | bash", "```", "", "Force a libc when auto-detect is wrong:", "", "```sh", "./install.sh --libc gnu # glibc (Debian, Fedora, Arch, openSUSE, Ubuntu)", "./install.sh --libc musl # Alpine / static musl", "```", "", "Local binaries you already built:", "", "```sh", "./install.sh --bin-dir target/release --dashboard-dir firecrab-frontend/dist", "```", "", "## Changelog", "", changelog_block.rstrip(), "", ] if definitions: lines.extend(definitions) lines.append("") lines.extend(["## Contributors", ""]) people = filter_contributors(contributors) if people: lines.append('
')
for name in people:
lines.append(
f' '
f'
'
)
lines.append("