#!/usr/bin/env python3 """Generate and validate immutable desktop release candidates.""" from __future__ import annotations import argparse import json import re import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent CHANGELOG = ROOT / "CHANGELOG.md" METADATA = ROOT / ".release" / "desktop-candidate.json" SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") DESKTOP_PATHS = ( "desktop/", "crates/buzz-core/", "crates/buzz-persona/", "crates/buzz-sdk/", "crates/buzz-agent/", "crates/buzz-media/", ) CANDIDATE_FILES = { ".release/desktop-candidate.json", "CHANGELOG.md", "desktop/package.json", "desktop/src-tauri/tauri.conf.json", "desktop/src-tauri/Cargo.toml", "desktop/src-tauri/Cargo.lock", "pnpm-lock.yaml", } REQUIRED_CANDIDATE_FILES = { ".release/desktop-candidate.json", "CHANGELOG.md", "desktop/package.json", "desktop/src-tauri/tauri.conf.json", "desktop/src-tauri/Cargo.toml", } def git(*args: str) -> str: return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]: args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"] if paths: args += ["--", *paths] out = git(*args) if not out: return [] return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()] def stable_tags(base_sha: str) -> list[tuple[int, str, str]]: tags: list[tuple[int, str, str]] = [] for tag in git("tag", "--merged", base_sha, "--list").splitlines(): if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag): continue sha = git("rev-list", "-n", "1", tag) distance = int(git("rev-list", "--count", f"{sha}..{base_sha}")) tags.append((distance, tag, sha)) return tags def previous_tag(base_sha: str) -> str: tags = stable_tags(base_sha) if not tags: return "" min_distance = min(item[0] for item in tags) nearest = [item for item in tags if item[0] == min_distance] commits = {item[2] for item in nearest} if len(commits) != 1: detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest) raise SystemExit(f"ambiguous previous desktop release tags: {detail}") # During migration, prefer the namespaced tag when aliases share a commit. nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1])) return nearest[0][1] def bullet(commit: dict[str, str], repo: str) -> str: sha, subject = commit["sha"], commit["subject"] short = sha[:12] pr_match = re.search(r" \(#([0-9]+)\)$", subject) if pr_match: pr = pr_match.group(1) subject = subject[: pr_match.start()] return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))" return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))" def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]: # With no prior desktop tag, account for the repository's root commit too. # A ``root..base`` range silently drops that first commit. range_spec = f"{previous}..{base_sha}" if previous else base_sha all_commits = commit_list(range_spec) relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} relevant = [c for c in all_commits if c["sha"] in relevant_shas] other = [c for c in all_commits if c["sha"] not in relevant_shas] return relevant, other def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]: relevant, other = expected(base_sha, previous) lines = [f"## v{version}", "", "### Desktop and shared changes", ""] lines += [bullet(c, repo) for c in relevant] or ["- None"] lines += ["", "### Other repository changes", ""] lines += [bullet(c, repo) for c in other] or ["- None"] compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0] lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"] return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other] def generate(args: argparse.Namespace) -> None: if not SEMVER.fullmatch(args.version): raise SystemExit(f"invalid semver: {args.version}") base_sha = git("rev-parse", args.base) previous = previous_tag(base_sha) repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git") block, commits = render(args.version, base_sha, previous, repo) old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n" if not old.startswith("# Changelog"): raise SystemExit("CHANGELOG.md must begin with '# Changelog'") remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else "" CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}") METADATA.parent.mkdir(parents=True, exist_ok=True) METADATA.write_text(json.dumps({ "schema": 1, "version": args.version, "base_sha": base_sha, "previous_tag": previous or None, "tag": f"desktop-v{args.version}", "commit_count": len(commits), }, indent=2) + "\n") def validate(args: argparse.Namespace) -> None: data = json.loads(METADATA.read_text()) version = args.version or data["version"] if data != {**data, "version": version}: raise SystemExit("candidate version does not match metadata") if data["tag"] != f"desktop-v{version}": raise SystemExit("candidate tag does not match version") candidate = git("rev-parse", args.candidate) parents = git("show", "-s", "--format=%P", candidate).split() if len(parents) != 1 or parents[0] != data["base_sha"]: raise SystemExit("candidate must be one commit directly above recorded base_sha") changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines()) unexpected = changed - CANDIDATE_FILES missing = REQUIRED_CANDIDATE_FILES - changed if unexpected or missing: detail = [] if unexpected: detail.append(f"unexpected files: {', '.join(sorted(unexpected))}") if missing: detail.append(f"missing required files: {', '.join(sorted(missing))}") raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")") previous = data["previous_tag"] or "" actual_previous = previous_tag(data["base_sha"]) if previous != actual_previous: raise SystemExit( f"recorded previous tag {previous or ''} does not match " f"nearest release tag {actual_previous or ''}" ) repo = args.repo or "block/buzz" expected_block, shas = render(version, data["base_sha"], previous, repo) text = CHANGELOG.read_text() blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text) if len(blocks) != 1: raise SystemExit(f"expected exactly one changelog block for v{version}") if blocks[0].rstrip() != expected_block.rstrip(): raise SystemExit("changelog block is not deterministic for recorded candidate base") found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0]) if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]: raise SystemExit("changelog does not account for every expected non-merge commit exactly once") manifests = { ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"], ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"], } cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text()) manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else "" bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] if bad: raise SystemExit(f"version mismatch in: {', '.join(bad)}") author = git("show", "-s", "--format=%an <%ae>", candidate) body = git("show", "-s", "--format=%B", candidate) if author != "Wes ": raise SystemExit(f"unexpected candidate author: {author}") if "Signed-off-by: Wes " not in body: raise SystemExit("candidate is missing Wes Signed-off-by trailer") if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): raise SystemExit("candidate is missing automation Co-authored-by trailer") print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") def main() -> None: parser = argparse.ArgumentParser() sub = parser.add_subparsers(dest="command", required=True) gen = sub.add_parser("generate") gen.add_argument("version") gen.add_argument("--base", required=True) gen.add_argument("--repo") val = sub.add_parser("validate") val.add_argument("--candidate", default="HEAD") val.add_argument("--version") val.add_argument("--repo") args = parser.parse_args() generate(args) if args.command == "generate" else validate(args) if __name__ == "__main__": main()