aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 22:51:28 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 22:51:28 +0300
commiteac2314e6ccb4ba91b7272523e68110035167279 (patch)
treecfa813cec6164a75366dfeeaa88d97ab9fa6e0fa /scripts
parent427c2606d8f62558e3949d2f44173175e96bed49 (diff)
fix: handle package-manager remote updates
Diffstat (limited to 'scripts')
-rw-r--r--scripts/changelog.py93
-rw-r--r--scripts/test_changelog.py106
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(