From f279b4170e2c58d623cc9a1a83a177c2bdfb5eb3 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 7 Apr 2026 18:48:40 +0300 Subject: fix: tighten release manifest sync flow --- scripts/changelog.py | 212 +++++++++++++++++++++++++++++++++++++++++++++- scripts/test_changelog.py | 101 +++++++++++++++++++++- 2 files changed, 309 insertions(+), 4 deletions(-) (limited to 'scripts') diff --git a/scripts/changelog.py b/scripts/changelog.py index 5a87d465..97bbd752 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -2,14 +2,26 @@ from __future__ import annotations import argparse +import json import re +import subprocess import sys from dataclasses import dataclass from datetime import date from pathlib import Path +from typing import Any SECTION_RE = re.compile(r"^##\s+(?:\[(?P[^\]]+)\]|(?P.+?))\s*$", re.MULTILINE) VERSION_WITH_DATE_RE = re.compile(r"^(?P.+?)\s+-\s+\d{4}-\d{2}-\d{2}$") +DEFAULT_RELEASE_REPO = "ogulcancelik/herdr" +DEFAULT_LATEST_JSON_PATH = Path("website/latest.json") +ASSET_TARGETS = ( + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", +) +EXPECTED_ASSET_NAMES = {target: f"herdr-{target}" for target in ASSET_TARGETS} @dataclass(frozen=True) @@ -34,6 +46,21 @@ def normalize_title(raw_title: str) -> str: return title +def normalize_version(version: str) -> str: + return version.strip().removeprefix("v") + + +def parse_version(version: str) -> tuple[int, int, int]: + normalized = normalize_version(version) + parts = normalized.split(".") + if len(parts) != 3: + raise ChangelogError(f"invalid version: {version}") + try: + return tuple(int(part) for part in parts) # type: ignore[return-value] + except ValueError as exc: + raise ChangelogError(f"invalid version: {version}") from exc + + def parse_sections(text: str) -> list[Section]: matches = list(SECTION_RE.finditer(text)) sections: list[Section] = [] @@ -95,28 +122,169 @@ def prepare_release(text: str, version: str, release_date: str) -> str: return rebuilt + "\n" +def build_latest_json(version: str, notes: str, assets: dict[str, str]) -> str: + normalized_version = normalize_version(version) + normalized_notes = notes.strip() + if not normalized_notes: + raise ChangelogError("release notes are empty") + + missing_targets = [target for target in ASSET_TARGETS if target not in assets] + if missing_targets: + raise ChangelogError(f"missing asset targets: {', '.join(missing_targets)}") + + ordered_assets = {target: assets[target] for target in ASSET_TARGETS} + + return json.dumps( + { + "version": normalized_version, + "notes": normalized_notes, + "assets": ordered_assets, + }, + indent=2, + ) + "\n" + + +def default_release_assets(version: str, repo: str = DEFAULT_RELEASE_REPO) -> dict[str, str]: + normalized_version = normalize_version(version) + tag = f"v{normalized_version}" + return { + target: f"https://github.com/{repo}/releases/download/{tag}/{EXPECTED_ASSET_NAMES[target]}" + for target in ASSET_TARGETS + } + + +def manifest_from_release_payload(payload: dict[str, Any], version: str) -> dict[str, Any]: + normalized_version = normalize_version(version) + tag_name = str(payload.get("tagName") or "") + if normalize_version(tag_name) != normalized_version: + raise ChangelogError( + f"GitHub release tag mismatch: expected v{normalized_version}, got {tag_name or ''}" + ) + if payload.get("isDraft"): + raise ChangelogError(f"GitHub release v{normalized_version} is still a draft") + if payload.get("isPrerelease"): + raise ChangelogError(f"GitHub release v{normalized_version} is a prerelease") + + notes = str(payload.get("body") or "").strip() + if not notes: + raise ChangelogError(f"GitHub release v{normalized_version} has empty release notes") + + assets_list = payload.get("assets") + if not isinstance(assets_list, list): + raise ChangelogError("GitHub release response is missing assets") + + release_assets: dict[str, Any] = {} + for asset in assets_list: + if isinstance(asset, dict): + name = asset.get("name") + if isinstance(name, str) and name not in release_assets: + release_assets[name] = asset + + manifest_assets: dict[str, str] = {} + for target, asset_name in EXPECTED_ASSET_NAMES.items(): + asset = release_assets.get(asset_name) + if not isinstance(asset, dict): + raise ChangelogError(f"GitHub release v{normalized_version} is missing asset {asset_name}") + url = str(asset.get("url") or "").strip() + if not url: + raise ChangelogError(f"GitHub release asset {asset_name} is missing a download URL") + manifest_assets[target] = url + + return { + "version": normalized_version, + "notes": notes, + "assets": manifest_assets, + } + + def load_text(path: Path) -> str: try: return path.read_text(encoding="utf-8") except FileNotFoundError as exc: - raise ChangelogError(f"changelog not found: {path}") from exc + raise ChangelogError(f"file not found: {path}") from exc + + +def load_json(path: Path) -> dict[str, Any]: + try: + content = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ChangelogError(f"file not found: {path}") from exc + + try: + data = json.loads(content) + except json.JSONDecodeError as exc: + raise ChangelogError(f"invalid JSON in {path}: {exc}") from exc + + if not isinstance(data, dict): + raise ChangelogError(f"expected JSON object in {path}") + return data def write_text(path: Path, text: str) -> None: path.write_text(text, encoding="utf-8") +def fetch_release_payload(version: str, repo: str) -> dict[str, Any]: + normalized_version = normalize_version(version) + command = [ + "gh", + "release", + "view", + f"v{normalized_version}", + "--repo", + repo, + "--json", + "tagName,isDraft,isPrerelease,body,assets", + ] + result = subprocess.run(command, capture_output=True, text=True, check=False) + if result.returncode != 0: + stderr = result.stderr.strip() or result.stdout.strip() or "unknown gh error" + raise ChangelogError(f"failed to read GitHub release v{normalized_version}: {stderr}") + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise ChangelogError(f"invalid JSON from gh release view: {exc}") from exc + + if not isinstance(payload, dict): + raise ChangelogError("unexpected GitHub release payload shape") + return payload + + +def ensure_manifest_is_outdated(current_manifest: dict[str, Any], version: str) -> None: + current_version = current_manifest.get("version") + if not isinstance(current_version, str): + raise ChangelogError("website/latest.json is missing a string version") + + if parse_version(current_version) >= parse_version(version): + raise ChangelogError( + f"website/latest.json is already at v{normalize_version(current_version)}; expected something older than v{normalize_version(version)}" + ) + + +def git_status_lines(path: Path) -> list[str]: + result = subprocess.run( + ["git", "status", "--short", "--", str(path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + return [line for line in result.stdout.splitlines() if line.strip()] + + def cmd_prepare(args: argparse.Namespace) -> int: path = Path(args.path) original = load_text(path) - updated = prepare_release(original, args.version, args.date) + updated = prepare_release(original, normalize_version(args.version), args.date) write_text(path, updated) return 0 def cmd_extract(args: argparse.Namespace) -> int: path = Path(args.path) - body = extract_section_body(load_text(path), args.version) + body = extract_section_body(load_text(path), normalize_version(args.version)) if args.output: write_text(Path(args.output), body) else: @@ -124,6 +292,35 @@ def cmd_extract(args: argparse.Namespace) -> int: return 0 +def cmd_sync_latest_json(args: argparse.Namespace) -> int: + manifest_path = Path(args.output) + version = normalize_version(args.version) + + current_manifest = load_json(manifest_path) + ensure_manifest_is_outdated(current_manifest, version) + + release_payload = fetch_release_payload(version, args.repo) + new_manifest = manifest_from_release_payload(release_payload, version) + output = build_latest_json(version, str(new_manifest["notes"]), dict(new_manifest["assets"])) + write_text(manifest_path, output) + + print(f"updated {manifest_path} from GitHub release v{version}") + status_lines = git_status_lines(manifest_path) + print("files changed:") + if status_lines: + for line in status_lines: + print(f" {line}") + else: + print(f" (no git status output for {manifest_path})") + + print("next:") + print(f" git diff -- {manifest_path}") + print(f" git add {manifest_path}") + print(f" git commit -m \"docs: update website manifest for v{version}\"") + print(" git push") + return 0 + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Prepare and extract changelog release notes") subparsers = parser.add_subparsers(dest="command", required=True) @@ -140,6 +337,15 @@ def build_parser() -> argparse.ArgumentParser: extract.add_argument("--output") extract.set_defaults(func=cmd_extract) + sync_latest_json = subparsers.add_parser( + "sync-latest-json", + help="Update website/latest.json from a published GitHub release", + ) + sync_latest_json.add_argument("--version", required=True) + sync_latest_json.add_argument("--repo", default=DEFAULT_RELEASE_REPO) + sync_latest_json.add_argument("--output", default=str(DEFAULT_LATEST_JSON_PATH)) + sync_latest_json.set_defaults(func=cmd_sync_latest_json) + return parser diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index cf9e23ac..6821dc38 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -1,8 +1,17 @@ from __future__ import annotations +import json import unittest -from scripts.changelog import extract_section_body, prepare_release +from scripts.changelog import ( + ChangelogError, + build_latest_json, + default_release_assets, + ensure_manifest_is_outdated, + extract_section_body, + manifest_from_release_payload, + prepare_release, +) class ChangelogScriptTests(unittest.TestCase): @@ -30,6 +39,96 @@ class ChangelogScriptTests(unittest.TestCase): self.assertEqual(body, "### Fixed\n- Smoothed Claude flapping.\n") + def test_build_latest_json_trims_notes(self) -> None: + manifest = json.loads( + build_latest_json( + "0.1.1", + "\n### Fixed\n- One\n\n", + default_release_assets("0.1.1"), + ) + ) + + self.assertEqual(manifest["notes"], "### Fixed\n- One") + + def test_build_latest_json_embeds_notes_and_release_assets(self) -> None: + manifest = json.loads( + build_latest_json( + "v0.1.1", + "### Fixed\n- Smoothed Claude flapping.\n", + default_release_assets("0.1.1"), + ) + ) + + self.assertEqual(manifest["version"], "0.1.1") + self.assertEqual(manifest["notes"], "### Fixed\n- Smoothed Claude flapping.") + self.assertEqual( + manifest["assets"], + { + "linux-x86_64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-linux-x86_64", + "linux-aarch64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-linux-aarch64", + "macos-x86_64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-macos-x86_64", + "macos-aarch64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-macos-aarch64", + }, + ) + + def test_manifest_from_release_payload_uses_release_body_and_asset_urls(self) -> None: + manifest = manifest_from_release_payload( + { + "tagName": "v0.1.1", + "isDraft": False, + "isPrerelease": False, + "body": "### Fixed\n- One\n", + "assets": [ + {"name": "herdr-linux-x86_64", "url": "https://example.com/linux-x86_64"}, + {"name": "herdr-linux-aarch64", "url": "https://example.com/linux-aarch64"}, + {"name": "herdr-macos-x86_64", "url": "https://example.com/macos-x86_64"}, + {"name": "herdr-macos-aarch64", "url": "https://example.com/macos-aarch64"}, + ], + }, + "0.1.1", + ) + + self.assertEqual( + manifest, + { + "version": "0.1.1", + "notes": "### Fixed\n- One", + "assets": { + "linux-x86_64": "https://example.com/linux-x86_64", + "linux-aarch64": "https://example.com/linux-aarch64", + "macos-x86_64": "https://example.com/macos-x86_64", + "macos-aarch64": "https://example.com/macos-aarch64", + }, + }, + ) + + def test_manifest_from_release_payload_rejects_missing_asset(self) -> None: + with self.assertRaisesRegex(ChangelogError, "missing asset herdr-macos-aarch64"): + manifest_from_release_payload( + { + "tagName": "v0.1.1", + "isDraft": False, + "isPrerelease": False, + "body": "### Fixed\n- One\n", + "assets": [ + {"name": "herdr-linux-x86_64", "url": "https://example.com/linux-x86_64"}, + {"name": "herdr-linux-aarch64", "url": "https://example.com/linux-aarch64"}, + {"name": "herdr-macos-x86_64", "url": "https://example.com/macos-x86_64"}, + ], + }, + "0.1.1", + ) + + def test_ensure_manifest_is_outdated_rejects_same_or_newer_version(self) -> None: + with self.assertRaisesRegex(ChangelogError, "already at v0.1.1"): + ensure_manifest_is_outdated({"version": "0.1.1"}, "0.1.1") + + with self.assertRaisesRegex(ChangelogError, "already at v0.1.2"): + ensure_manifest_is_outdated({"version": "0.1.2"}, "0.1.1") + + def test_ensure_manifest_is_outdated_allows_older_version(self) -> None: + ensure_manifest_is_outdated({"version": "0.1.0"}, "0.1.1") + if __name__ == "__main__": unittest.main() -- cgit v1.2.3