diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 22:51:28 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 22:51:28 +0300 |
| commit | eac2314e6ccb4ba91b7272523e68110035167279 (patch) | |
| tree | cfa813cec6164a75366dfeeaa88d97ab9fa6e0fa /scripts | |
| parent | 427c2606d8f62558e3949d2f44173175e96bed49 (diff) | |
fix: handle package-manager remote updates
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/changelog.py | 93 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 106 |
2 files changed, 174 insertions, 25 deletions
diff --git a/scripts/changelog.py b/scripts/changelog.py index 1e7d2738..188bc29e 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -162,11 +162,35 @@ def normalize_announcement(value: Any, label: str) -> dict[str, str] | None: return announcement -def normalize_release_metadata(value: Any, label: str) -> dict[str, Any]: +def infer_protocol_from_notes(notes: str) -> int | None: + match = re.search(r"protocol(?: is now)? version (\d+)", notes, flags=re.IGNORECASE) + if match is None: + return None + return int(match.group(1)) + + +def normalize_assets(value: Any, label: str) -> dict[str, str]: if not isinstance(value, dict): raise ChangelogError(f"{label} must be an object") - allowed_keys = {"notes", "announcement"} + missing_targets = [target for target in ASSET_TARGETS if target not in value] + if missing_targets: + raise ChangelogError(f"{label} is missing asset URL for {', '.join(missing_targets)}") + + normalized_assets: dict[str, str] = {} + for target in ASSET_TARGETS: + url = value.get(target) + if not isinstance(url, str) or not url.strip(): + raise ChangelogError(f"{label} is missing asset URL for {target}") + normalized_assets[target] = url.strip() + return normalized_assets + + +def normalize_release_metadata(value: Any, label: str, version: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ChangelogError(f"{label} must be an object") + + allowed_keys = {"notes", "announcement", "assets", "protocol"} extra_keys = sorted(set(value) - allowed_keys) if extra_keys: raise ChangelogError(f"{label} has unsupported field(s): {', '.join(extra_keys)}") @@ -176,6 +200,19 @@ def normalize_release_metadata(value: Any, label: str) -> dict[str, Any]: raise ChangelogError(f"{label} is missing non-empty release notes") metadata: dict[str, Any] = {"notes": notes.strip()} + protocol = value.get("protocol") + if protocol is not None: + if not isinstance(protocol, int): + raise ChangelogError(f"{label}.protocol must be an integer") + metadata["protocol"] = protocol + else: + inferred_protocol = infer_protocol_from_notes(notes) + if inferred_protocol is not None: + metadata["protocol"] = inferred_protocol + if "assets" in value: + metadata["assets"] = normalize_assets(value.get("assets"), f"{label}.assets") + else: + metadata["assets"] = default_release_assets(version) announcement = normalize_announcement(value.get("announcement"), label) if announcement is not None: metadata["announcement"] = announcement @@ -194,7 +231,7 @@ def normalize_releases(value: Any) -> dict[str, dict[str, Any]]: 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}") + releases[version] = normalize_release_metadata(raw_metadata, f"releases.{version}", version) return { version: releases[version] @@ -218,14 +255,14 @@ def build_latest_json( if protocol is None: protocol = read_protocol_version() - 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} + ordered_assets = normalize_assets(assets, "assets") normalized_announcement = normalize_announcement(announcement, "root") archived_releases = normalize_releases(releases) - current_metadata: dict[str, Any] = {"notes": normalized_notes} + current_metadata: dict[str, Any] = { + "notes": normalized_notes, + "protocol": protocol, + "assets": ordered_assets, + } if normalized_announcement is not None: current_metadata["announcement"] = normalized_announcement archived_releases[normalized_version] = current_metadata @@ -320,12 +357,7 @@ def canonicalize_manifest(manifest: dict[str, Any], label: str) -> dict[str, Any if not isinstance(assets, dict): raise ChangelogError(f"{label} is missing an assets object") - normalized_assets: dict[str, str] = {} - for target in ASSET_TARGETS: - url = assets.get(target) - if not isinstance(url, str) or not url.strip(): - raise ChangelogError(f"{label} is missing asset URL for {target}") - normalized_assets[target] = url.strip() + normalized_assets = normalize_assets(assets, f"{label} assets") return { "version": normalize_version(version), @@ -349,6 +381,18 @@ def ensure_manifest_matches_expected( return canonical_manifest +def ensure_current_release_assets_are_mirrored(manifest: dict[str, Any], label: str) -> None: + canonical = canonicalize_manifest(manifest, label) + releases = normalize_releases(manifest.get("releases")) + metadata = releases.get(canonical["version"]) + if metadata is None: + raise ChangelogError(f"{label} is missing releases.{canonical['version']}") + if metadata.get("assets") != canonical["assets"]: + raise ChangelogError( + f"{label} releases.{canonical['version']}.assets must match top-level assets" + ) + + def load_text(path: Path) -> str: try: return path.read_text(encoding="utf-8") @@ -377,11 +421,20 @@ def archived_releases_from_current_manifest(manifest: dict[str, Any]) -> dict[st version = manifest.get("version") notes = manifest.get("notes") if isinstance(version, str) and version.strip() and isinstance(notes, str) and notes.strip(): + normalized_version = normalize_version(version) metadata: dict[str, Any] = {"notes": notes.strip()} + protocol = manifest.get("protocol") + if isinstance(protocol, int): + metadata["protocol"] = protocol + assets = manifest.get("assets") + if isinstance(assets, dict): + metadata["assets"] = normalize_assets(assets, "current root assets") + else: + metadata["assets"] = default_release_assets(normalized_version) announcement = normalize_announcement(manifest.get("announcement"), "current root") if announcement is not None: metadata["announcement"] = announcement - releases[normalize_version(version)] = metadata + releases[normalized_version] = metadata return { release_version: releases[release_version] @@ -583,19 +636,23 @@ def cmd_verify_release_state(args: argparse.Namespace) -> int: release_payload = fetch_release_payload(version, args.repo) expected_manifest = manifest_from_release_payload(release_payload, version, args.protocol) + local_raw_manifest = load_json(Path(args.output)) local_manifest = ensure_manifest_matches_expected( - load_json(Path(args.output)), + local_raw_manifest, expected_manifest, str(args.output), ) + ensure_current_release_assets_are_mirrored(local_raw_manifest, str(args.output)) print(f"GitHub release v{version}: OK") print(f"local manifest ({args.output}): OK") + live_raw_manifest = fetch_remote_json(args.live_url, args.live_url) live_manifest = ensure_manifest_matches_expected( - fetch_remote_json(args.live_url, args.live_url), + live_raw_manifest, expected_manifest, args.live_url, ) + ensure_current_release_assets_are_mirrored(live_raw_manifest, args.live_url) print(f"live manifest ({args.live_url}): OK") verify_asset_urls_resolve(dict(expected_manifest["assets"]), "release") diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 1d46179a..a1bce002 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -12,9 +12,11 @@ from scripts.changelog import ( canonicalize_manifest, DEFAULT_PRODUCT_ANNOUNCEMENT_PATH, default_release_assets, + ensure_current_release_assets_are_mirrored, ensure_manifest_is_outdated, ensure_manifest_matches_expected, extract_section_body, + infer_protocol_from_notes, load_product_announcement, manifest_from_release_payload, prepare_release, @@ -80,6 +82,7 @@ class ChangelogScriptTests(unittest.TestCase): "macos-aarch64": "https://github.com/ogulcancelik/herdr/releases/download/v0.1.1/herdr-macos-aarch64", }, ) + self.assertEqual(manifest["releases"]["0.1.1"]["assets"], manifest["assets"]) def test_build_latest_json_embeds_product_announcement(self) -> None: manifest = json.loads( @@ -111,22 +114,58 @@ class ChangelogScriptTests(unittest.TestCase): ) 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"}) + self.assertEqual(manifest["releases"]["0.1.2"]["notes"], "### Fixed\n- Two") + self.assertEqual(manifest["releases"]["0.1.2"]["protocol"], read_protocol_version()) + self.assertEqual(manifest["releases"]["0.1.2"]["assets"], default_release_assets("0.1.2")) + self.assertEqual(manifest["releases"]["0.1.1"]["notes"], "### Fixed\n- One") + self.assertEqual(manifest["releases"]["0.1.1"]["assets"], default_release_assets("0.1.1")) + + def test_build_latest_json_accepts_release_metadata_assets(self) -> None: + assets = default_release_assets("0.1.1") + 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", "assets": assets}}, + ) + ) + + self.assertEqual(manifest["releases"]["0.1.1"]["assets"], assets) + + def test_build_latest_json_preserves_release_metadata_protocol(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", "protocol": 7}}, + ) + ) + + self.assertEqual(manifest["releases"]["0.1.1"]["protocol"], 7) - def test_build_latest_json_rejects_release_metadata_assets(self) -> None: - with self.assertRaisesRegex(ChangelogError, "unsupported"): + def test_build_latest_json_infers_release_metadata_protocol_from_notes(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", "assets": {}}}, + releases={ + "0.1.1": { + "notes": "### Breaking Changes\n- The client/server protocol is now version 7." + } + }, ) + ) + + self.assertEqual(manifest["releases"]["0.1.1"]["protocol"], 7) def test_archived_releases_from_current_manifest_seeds_legacy_root(self) -> None: releases = archived_releases_from_current_manifest( { "version": "0.1.1", + "protocol": 3, "notes": "### Fixed\n- One", "announcement": { "id": "one", @@ -141,6 +180,8 @@ class ChangelogScriptTests(unittest.TestCase): { "0.1.1": { "notes": "### Fixed\n- One", + "protocol": 3, + "assets": default_release_assets("0.1.1"), "announcement": { "id": "one", "title": "One", @@ -154,6 +195,7 @@ class ChangelogScriptTests(unittest.TestCase): releases = archived_releases_from_current_manifest( { "version": "0.1.2", + "protocol": read_protocol_version(), "notes": "### Fixed\n- Root", "releases": { "0.1.2": {"notes": "### Fixed\n- Stale"}, @@ -162,8 +204,22 @@ class ChangelogScriptTests(unittest.TestCase): } ) - self.assertEqual(releases["0.1.2"], {"notes": "### Fixed\n- Root"}) - self.assertEqual(releases["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") + self.assertEqual(releases["0.1.2"]["protocol"], read_protocol_version()) + self.assertEqual(releases["0.1.2"]["assets"], default_release_assets("0.1.2")) + self.assertEqual(releases["0.1.1"]["assets"], default_release_assets("0.1.1")) + + def test_infer_protocol_from_notes(self) -> None: + self.assertEqual( + infer_protocol_from_notes("The client/server protocol is now version 10."), + 10, + ) + self.assertEqual( + infer_protocol_from_notes("The client/server protocol version 9."), + 9, + ) + self.assertIsNone(infer_protocol_from_notes("No wire changes.")) def write_temp_json(self, content: str) -> Path: tmp = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") @@ -346,6 +402,42 @@ class ChangelogScriptTests(unittest.TestCase): canonical = ensure_manifest_matches_expected(actual, expected, "test manifest") self.assertEqual(canonical, expected) + def test_current_release_assets_must_be_mirrored(self) -> None: + assets = default_release_assets("0.1.1") + ensure_current_release_assets_are_mirrored( + { + "version": "0.1.1", + "protocol": read_protocol_version(), + "notes": "### Fixed\n- One", + "assets": assets, + "releases": { + "0.1.1": { + "notes": "### Fixed\n- One", + "assets": assets, + } + }, + }, + "test manifest", + ) + + def test_current_release_assets_must_match_top_level_assets(self) -> None: + with self.assertRaisesRegex(ChangelogError, "assets must match top-level assets"): + ensure_current_release_assets_are_mirrored( + { + "version": "0.1.1", + "protocol": read_protocol_version(), + "notes": "### Fixed\n- One", + "assets": default_release_assets("0.1.1"), + "releases": { + "0.1.1": { + "notes": "### Fixed\n- One", + "assets": default_release_assets("0.1.0"), + } + }, + }, + "test manifest", + ) + def test_ensure_manifest_matches_expected_rejects_different_notes(self) -> None: with self.assertRaisesRegex(ChangelogError, "does not match the published GitHub release manifest"): ensure_manifest_matches_expected( |
