aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 18:48:40 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-07 18:48:40 +0300
commitf279b4170e2c58d623cc9a1a83a177c2bdfb5eb3 (patch)
treeeeb1af564cb9576a60e344be7b2fba03289b8798 /scripts
parented0c051c55e40c1ab3cdc029ba4ca518ee407a1e (diff)
fix: tighten release manifest sync flow
Diffstat (limited to 'scripts')
-rw-r--r--scripts/changelog.py212
-rw-r--r--scripts/test_changelog.py101
2 files changed, 309 insertions, 4 deletions
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<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$", re.MULTILINE)
VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\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 '<missing>'}"
+ )
+ 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()