diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/changelog.py | 99 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 78 |
2 files changed, 166 insertions, 11 deletions
diff --git a/scripts/changelog.py b/scripts/changelog.py index e930fd82..56838baa 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -17,6 +17,7 @@ SECTION_RE = re.compile(r"^##\s+(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$ 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") +DEFAULT_PRODUCT_ANNOUNCEMENT_PATH = Path("docs/next/product-announcement.json") PROTOCOL_SOURCE_PATH = Path("src/server/protocol.rs") ASSET_TARGETS = ( "linux-x86_64", @@ -134,7 +135,11 @@ def read_protocol_version(source_path: Path = PROTOCOL_SOURCE_PATH) -> int: def build_latest_json( - version: str, notes: str, assets: dict[str, str], protocol: int | None = None + version: str, + notes: str, + assets: dict[str, str], + protocol: int | None = None, + announcement: dict[str, str] | None = None, ) -> str: normalized_version = normalize_version(version) normalized_notes = notes.strip() @@ -150,15 +155,16 @@ def build_latest_json( ordered_assets = {target: assets[target] for target in ASSET_TARGETS} - return json.dumps( - { - "version": normalized_version, - "protocol": protocol, - "notes": normalized_notes, - "assets": ordered_assets, - }, - indent=2, - ) + "\n" + manifest: dict[str, Any] = { + "version": normalized_version, + "protocol": protocol, + "notes": normalized_notes, + "assets": ordered_assets, + } + if announcement is not None: + manifest["announcement"] = announcement + + return json.dumps(manifest, indent=2) + "\n" def default_release_assets(version: str, repo: str = DEFAULT_RELEASE_REPO) -> dict[str, str]: @@ -284,6 +290,44 @@ def load_json(path: Path) -> dict[str, Any]: return data +def load_product_announcement(path: Path) -> dict[str, str] | None: + try: + content = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ChangelogError(f"product announcement 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 data is None: + return None + if not isinstance(data, dict): + raise ChangelogError(f"expected announcement object or null in {path}") + + allowed_keys = {"id", "title", "body"} + extra_keys = sorted(set(data) - allowed_keys) + if extra_keys: + raise ChangelogError( + f"announcement in {path} has unsupported field(s): {', '.join(extra_keys)}" + ) + + announcement: dict[str, str] = {} + for key in ("id", "title", "body"): + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise ChangelogError(f"announcement in {path} is missing non-empty string field: {key}") + announcement[key] = value.strip() + + if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", announcement["id"]): + raise ChangelogError( + f"announcement in {path} has invalid id; use lowercase letters, numbers, dots, underscores, or dashes" + ) + + return announcement + + def write_text(path: Path, text: str) -> None: path.write_text(text, encoding="utf-8") @@ -412,10 +456,22 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int: 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"])) + announcement_path = Path(args.announcement) + announcement = load_product_announcement(announcement_path) + output = build_latest_json( + version, + str(new_manifest["notes"]), + dict(new_manifest["assets"]), + announcement=announcement, + ) write_text(manifest_path, output) + if announcement is not None: + write_text(announcement_path, "null\n") print(f"updated {manifest_path} from GitHub release v{version}") + if announcement is not None: + print(f"included product announcement from {announcement_path}") + print(f"cleared {announcement_path}") status_lines = git_status_lines(manifest_path) print("files changed:") if status_lines: @@ -432,6 +488,17 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int: return 0 +def cmd_validate_product_announcement(args: argparse.Namespace) -> int: + announcement = load_product_announcement(Path(args.path)) + if announcement is None: + print(f"product announcement ({args.path}): none") + else: + print( + f"product announcement ({args.path}): {announcement['id']} - {announcement['title']}" + ) + return 0 + + def cmd_verify_release_state(args: argparse.Namespace) -> int: version = normalize_version(args.version) release_payload = fetch_release_payload(version, args.repo) @@ -484,8 +551,18 @@ def build_parser() -> argparse.ArgumentParser: 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.add_argument("--announcement", default=str(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH)) sync_latest_json.set_defaults(func=cmd_sync_latest_json) + validate_product_announcement = subparsers.add_parser( + "validate-product-announcement", + help="Validate docs/next product announcement JSON", + ) + validate_product_announcement.add_argument( + "--path", default=str(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH) + ) + validate_product_announcement.set_defaults(func=cmd_validate_product_announcement) + verify_release_state = subparsers.add_parser( "verify-release-state", help="Verify GitHub release, local manifest, live manifest, and asset URLs all match", diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 5778b6ac..5d9fd506 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -1,16 +1,20 @@ from __future__ import annotations import json +import tempfile import unittest +from pathlib import Path from scripts.changelog import ( ChangelogError, build_latest_json, canonicalize_manifest, + DEFAULT_PRODUCT_ANNOUNCEMENT_PATH, default_release_assets, ensure_manifest_is_outdated, ensure_manifest_matches_expected, extract_section_body, + load_product_announcement, manifest_from_release_payload, prepare_release, read_protocol_version, @@ -76,6 +80,80 @@ class ChangelogScriptTests(unittest.TestCase): }, ) + def test_build_latest_json_embeds_product_announcement(self) -> None: + manifest = json.loads( + build_latest_json( + "0.1.1", + "### Fixed\n- One", + default_release_assets("0.1.1"), + announcement={"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, + ) + ) + + self.assertEqual( + manifest["announcement"], + {"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, + ) + + def write_temp_json(self, content: str) -> Path: + tmp = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") + with tmp: + tmp.write(content) + return Path(tmp.name) + + def test_checked_in_product_announcement_is_valid_or_null(self) -> None: + self.assertTrue(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH.is_file()) + load_product_announcement(DEFAULT_PRODUCT_ANNOUNCEMENT_PATH) + + def test_load_product_announcement_accepts_null(self) -> None: + path = self.write_temp_json("null\n") + try: + self.assertIsNone(load_product_announcement(path)) + finally: + path.unlink(missing_ok=True) + + def test_load_product_announcement_accepts_valid_object(self) -> None: + path = self.write_temp_json( + json.dumps({"id": "keybinding-v2", "title": "Keybind Refactor", "body": "Body"}) + ) + try: + self.assertEqual( + load_product_announcement(path), + {"id": "keybinding-v2", "title": "Keybind Refactor", "body": "Body"}, + ) + finally: + path.unlink(missing_ok=True) + + def test_load_product_announcement_rejects_missing_file(self) -> None: + path = Path(tempfile.gettempdir()) / "herdr-missing-product-announcement.json" + path.unlink(missing_ok=True) + with self.assertRaisesRegex(ChangelogError, "file not found"): + load_product_announcement(path) + + def test_load_product_announcement_rejects_missing_empty_or_extra_fields(self) -> None: + cases = [ + ({"id": "keybinding-v2", "title": "Keybind Refactor"}, "body"), + ({"id": "", "title": "Keybind Refactor", "body": "Body"}, "id"), + ({"id": "keybinding-v2", "title": "Keybind Refactor", "body": "Body", "cta": "x"}, "unsupported"), + ] + for payload, expected in cases: + path = self.write_temp_json(json.dumps(payload)) + try: + with self.assertRaisesRegex(ChangelogError, expected): + load_product_announcement(path) + finally: + path.unlink(missing_ok=True) + + def test_load_product_announcement_rejects_invalid_id(self) -> None: + path = self.write_temp_json( + json.dumps({"id": "Keybinding V2", "title": "Keybind Refactor", "body": "Body"}) + ) + try: + with self.assertRaisesRegex(ChangelogError, "invalid id"): + load_product_announcement(path) + finally: + path.unlink(missing_ok=True) + def test_manifest_from_release_payload_uses_release_body_and_asset_urls(self) -> None: manifest = manifest_from_release_payload( { |
