diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/changelog.py | 126 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 70 |
2 files changed, 171 insertions, 25 deletions
diff --git a/scripts/changelog.py b/scripts/changelog.py index 629d086a..91c93f6e 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -134,12 +134,81 @@ def read_protocol_version(source_path: Path = PROTOCOL_SOURCE_PATH) -> int: return int(match.group(1)) +def normalize_announcement(value: Any, label: str) -> dict[str, str] | None: + if value is None: + return None + if not isinstance(value, dict): + raise ChangelogError(f"{label} announcement must be an object") + + allowed_keys = {"id", "title", "body"} + extra_keys = sorted(set(value) - allowed_keys) + if extra_keys: + raise ChangelogError( + f"{label} announcement has unsupported field(s): {', '.join(extra_keys)}" + ) + + announcement: dict[str, str] = {} + for key in ("id", "title", "body"): + field_value = value.get(key) + if not isinstance(field_value, str) or not field_value.strip(): + raise ChangelogError(f"{label} announcement is missing non-empty string field: {key}") + announcement[key] = field_value.strip() + + if not re.fullmatch(r"[a-z0-9][a-z0-9._-]*", announcement["id"]): + raise ChangelogError( + f"{label} announcement has invalid id; use lowercase letters, numbers, dots, underscores, or dashes" + ) + + return announcement + + +def normalize_release_metadata(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ChangelogError(f"{label} must be an object") + + allowed_keys = {"notes", "announcement"} + extra_keys = sorted(set(value) - allowed_keys) + if extra_keys: + raise ChangelogError(f"{label} has unsupported field(s): {', '.join(extra_keys)}") + + notes = value.get("notes") + if not isinstance(notes, str) or not notes.strip(): + raise ChangelogError(f"{label} is missing non-empty release notes") + + metadata: dict[str, Any] = {"notes": notes.strip()} + announcement = normalize_announcement(value.get("announcement"), label) + if announcement is not None: + metadata["announcement"] = announcement + return metadata + + +def normalize_releases(value: Any) -> dict[str, dict[str, Any]]: + if value is None: + return {} + if not isinstance(value, dict): + raise ChangelogError("releases must be an object") + + releases: dict[str, dict[str, Any]] = {} + for raw_version, raw_metadata in value.items(): + if not isinstance(raw_version, str) or not raw_version.strip(): + raise ChangelogError("releases contains an empty version key") + version = normalize_version(raw_version) + parse_version(version) + releases[version] = normalize_release_metadata(raw_metadata, f"releases.{version}") + + return { + version: releases[version] + for version in sorted(releases, key=parse_version, reverse=True) + } + + def build_latest_json( version: str, notes: str, assets: dict[str, str], protocol: int | None = None, announcement: dict[str, str] | None = None, + releases: dict[str, Any] | None = None, ) -> str: normalized_version = normalize_version(version) normalized_notes = notes.strip() @@ -154,6 +223,16 @@ def build_latest_json( raise ChangelogError(f"missing asset targets: {', '.join(missing_targets)}") ordered_assets = {target: assets[target] for target in ASSET_TARGETS} + normalized_announcement = normalize_announcement(announcement, "root") + archived_releases = normalize_releases(releases) + current_metadata: dict[str, Any] = {"notes": normalized_notes} + if normalized_announcement is not None: + current_metadata["announcement"] = normalized_announcement + archived_releases[normalized_version] = current_metadata + archived_releases = { + release_version: archived_releases[release_version] + for release_version in sorted(archived_releases, key=parse_version, reverse=True) + } manifest: dict[str, Any] = { "version": normalized_version, @@ -161,8 +240,9 @@ def build_latest_json( "notes": normalized_notes, "assets": ordered_assets, } - if announcement is not None: - manifest["announcement"] = announcement + if normalized_announcement is not None: + manifest["announcement"] = normalized_announcement + manifest["releases"] = archived_releases return json.dumps(manifest, indent=2) + "\n" @@ -292,6 +372,23 @@ def load_json(path: Path) -> dict[str, Any]: return data +def archived_releases_from_current_manifest(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + releases = normalize_releases(manifest.get("releases")) + version = manifest.get("version") + notes = manifest.get("notes") + if isinstance(version, str) and version.strip() and isinstance(notes, str) and notes.strip(): + metadata: dict[str, Any] = {"notes": notes.strip()} + announcement = normalize_announcement(manifest.get("announcement"), "current root") + if announcement is not None: + metadata["announcement"] = announcement + releases[normalize_version(version)] = metadata + + return { + release_version: releases[release_version] + for release_version in sorted(releases, key=parse_version, reverse=True) + } + + def load_product_announcement(path: Path) -> dict[str, str] | None: try: content = path.read_text(encoding="utf-8") @@ -305,29 +402,7 @@ def load_product_announcement(path: Path) -> dict[str, str] | None: 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 + return normalize_announcement(data, f"announcement in {path}") def write_text(path: Path, text: str) -> None: @@ -466,6 +541,7 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int: dict(new_manifest["assets"]), protocol=int(new_manifest["protocol"]), announcement=announcement, + releases=archived_releases_from_current_manifest(current_manifest), ) write_text(manifest_path, output) if announcement is not None: diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 287d77c7..1d46179a 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -7,6 +7,7 @@ from pathlib import Path from scripts.changelog import ( ChangelogError, + archived_releases_from_current_manifest, build_latest_json, canonicalize_manifest, DEFAULT_PRODUCT_ANNOUNCEMENT_PATH, @@ -94,6 +95,75 @@ class ChangelogScriptTests(unittest.TestCase): manifest["announcement"], {"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, ) + self.assertEqual( + manifest["releases"]["0.1.1"]["announcement"], + {"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"}, + ) + + def test_build_latest_json_preserves_previous_release_metadata(self) -> None: + manifest = json.loads( + build_latest_json( + "0.1.2", + "### Fixed\n- Two", + default_release_assets("0.1.2"), + releases={"0.1.1": {"notes": "### Fixed\n- One"}}, + ) + ) + + self.assertEqual(list(manifest["releases"]), ["0.1.2", "0.1.1"]) + self.assertEqual(manifest["releases"]["0.1.2"], {"notes": "### Fixed\n- Two"}) + self.assertEqual(manifest["releases"]["0.1.1"], {"notes": "### Fixed\n- One"}) + + def test_build_latest_json_rejects_release_metadata_assets(self) -> None: + with self.assertRaisesRegex(ChangelogError, "unsupported"): + build_latest_json( + "0.1.2", + "### Fixed\n- Two", + default_release_assets("0.1.2"), + releases={"0.1.1": {"notes": "### Fixed\n- One", "assets": {}}}, + ) + + def test_archived_releases_from_current_manifest_seeds_legacy_root(self) -> None: + releases = archived_releases_from_current_manifest( + { + "version": "0.1.1", + "notes": "### Fixed\n- One", + "announcement": { + "id": "one", + "title": "One", + "body": "body", + }, + } + ) + + self.assertEqual( + releases, + { + "0.1.1": { + "notes": "### Fixed\n- One", + "announcement": { + "id": "one", + "title": "One", + "body": "body", + }, + } + }, + ) + + def test_archived_releases_from_current_manifest_prefers_root_for_current_version(self) -> None: + releases = archived_releases_from_current_manifest( + { + "version": "0.1.2", + "notes": "### Fixed\n- Root", + "releases": { + "0.1.2": {"notes": "### Fixed\n- Stale"}, + "0.1.1": {"notes": "### Fixed\n- One"}, + }, + } + ) + + self.assertEqual(releases["0.1.2"], {"notes": "### Fixed\n- Root"}) + self.assertEqual(releases["0.1.1"], {"notes": "### Fixed\n- One"}) def write_temp_json(self, content: str) -> Path: tmp = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") |
