diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 19:59:22 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-20 19:59:22 +0300 |
| commit | 8bbabfc5b1c9f6f318e057d34848df8e791a6c48 (patch) | |
| tree | c6ef7fd599e05997f39dc2f4c5eb9a2bc3e25b2a | |
| parent | 836fd7e585af15cc1ccb0d4f04586ffec28d2a3c (diff) | |
fix: harden release manifest update flow
| -rw-r--r-- | .github/workflows/release.yml | 3 | ||||
| -rw-r--r-- | scripts/changelog.py | 13 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 20 | ||||
| -rw-r--r-- | src/update.rs | 51 |
4 files changed, 79 insertions, 8 deletions
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ec6a4cf..a6de5624 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -298,13 +298,14 @@ jobs: git show "${GITHUB_REF_NAME}:docs/next/product-announcement.json" > "$ANNOUNCEMENT_PATH" cp "$ANNOUNCEMENT_PATH" "$ANNOUNCEMENT_ORIGINAL_PATH" python3 scripts/changelog.py validate-product-announcement --path "$ANNOUNCEMENT_PATH" + RELEASE_PROTOCOL=$(git show "${GITHUB_REF_NAME}:src/server/protocol.rs" | python3 -c 'import re, sys; match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))') CURRENT_VERSION=$(python3 -c 'import json; print(json.load(open("website/latest.json")).get("version", ""))') if [ "$CURRENT_VERSION" = "$VERSION" ]; then echo "website/latest.json is already at v$VERSION" exit 0 fi - python3 scripts/changelog.py sync-latest-json --version "$VERSION" --output website/latest.json --announcement "$ANNOUNCEMENT_PATH" + python3 scripts/changelog.py sync-latest-json --version "$VERSION" --output website/latest.json --announcement "$ANNOUNCEMENT_PATH" --protocol "$RELEASE_PROTOCOL" if cmp -s "$ANNOUNCEMENT_ORIGINAL_PATH" docs/next/product-announcement.json; then printf 'null\n' > docs/next/product-announcement.json else diff --git a/scripts/changelog.py b/scripts/changelog.py index 56838baa..629d086a 100644 --- a/scripts/changelog.py +++ b/scripts/changelog.py @@ -176,7 +176,9 @@ def default_release_assets(version: str, repo: str = DEFAULT_RELEASE_REPO) -> di } -def manifest_from_release_payload(payload: dict[str, Any], version: str) -> dict[str, Any]: +def manifest_from_release_payload( + payload: dict[str, Any], version: str, protocol: int | None = None +) -> dict[str, Any]: normalized_version = normalize_version(version) tag_name = str(payload.get("tagName") or "") if normalize_version(tag_name) != normalized_version: @@ -215,7 +217,7 @@ def manifest_from_release_payload(payload: dict[str, Any], version: str) -> dict return { "version": normalized_version, - "protocol": read_protocol_version(), + "protocol": protocol if protocol is not None else read_protocol_version(), "notes": notes, "assets": manifest_assets, } @@ -455,13 +457,14 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int: ensure_manifest_is_outdated(current_manifest, version) release_payload = fetch_release_payload(version, args.repo) - new_manifest = manifest_from_release_payload(release_payload, version) + new_manifest = manifest_from_release_payload(release_payload, version, args.protocol) announcement_path = Path(args.announcement) announcement = load_product_announcement(announcement_path) output = build_latest_json( version, str(new_manifest["notes"]), dict(new_manifest["assets"]), + protocol=int(new_manifest["protocol"]), announcement=announcement, ) write_text(manifest_path, output) @@ -502,7 +505,7 @@ def cmd_validate_product_announcement(args: argparse.Namespace) -> int: def cmd_verify_release_state(args: argparse.Namespace) -> int: version = normalize_version(args.version) release_payload = fetch_release_payload(version, args.repo) - expected_manifest = manifest_from_release_payload(release_payload, version) + expected_manifest = manifest_from_release_payload(release_payload, version, args.protocol) local_manifest = ensure_manifest_matches_expected( load_json(Path(args.output)), @@ -552,6 +555,7 @@ def build_parser() -> argparse.ArgumentParser: 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.add_argument("--protocol", type=int) sync_latest_json.set_defaults(func=cmd_sync_latest_json) validate_product_announcement = subparsers.add_parser( @@ -571,6 +575,7 @@ def build_parser() -> argparse.ArgumentParser: verify_release_state.add_argument("--repo", default=DEFAULT_RELEASE_REPO) verify_release_state.add_argument("--output", default=str(DEFAULT_LATEST_JSON_PATH)) verify_release_state.add_argument("--live-url", default=DEFAULT_LIVE_MANIFEST_URL) + verify_release_state.add_argument("--protocol", type=int) verify_release_state.set_defaults(func=cmd_verify_release_state) return parser diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py index 5d9fd506..287d77c7 100644 --- a/scripts/test_changelog.py +++ b/scripts/test_changelog.py @@ -186,6 +186,26 @@ class ChangelogScriptTests(unittest.TestCase): }, ) + def test_manifest_from_release_payload_uses_explicit_protocol(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", + protocol=42, + ) + + self.assertEqual(manifest["protocol"], 42) + def test_manifest_from_release_payload_rejects_missing_asset(self) -> None: with self.assertRaisesRegex(ChangelogError, "missing asset herdr-macos-aarch64"): manifest_from_release_payload( diff --git a/src/update.rs b/src/update.rs index 63b58c08..60fa594e 100644 --- a/src/update.rs +++ b/src/update.rs @@ -98,7 +98,7 @@ struct UpdateManifest { protocol: Option<u32>, notes: String, assets: BTreeMap<String, String>, - announcement: Option<crate::product_announcements::ManifestAnnouncement>, + announcement: Option<serde_json::Value>, } #[derive(Deserialize)] @@ -158,9 +158,23 @@ fn fetch_update_manifest() -> Result<UpdateManifest, String> { } fn handle_manifest_announcement(manifest: &UpdateManifest) { + let announcement = match manifest.announcement.as_ref() { + Some(value) => match serde_json::from_value::< + crate::product_announcements::ManifestAnnouncement, + >(value.clone()) + { + Ok(announcement) => Some(announcement), + Err(err) => { + tracing::warn!("skipping invalid product announcement in update manifest: {err}"); + None + } + }, + None => None, + }; + if let Err(err) = crate::product_announcements::save_manifest_announcement( &manifest.version, - manifest.announcement.as_ref(), + announcement.as_ref(), ) { tracing::warn!("failed to save product announcement: {err}"); } @@ -1528,7 +1542,8 @@ mod tests { manifest .announcement .as_ref() - .map(|announcement| announcement.id.as_str()), + .and_then(|announcement| announcement.get("id")) + .and_then(serde_json::Value::as_str), Some("keymap-v2") ); assert_eq!( @@ -1550,6 +1565,36 @@ mod tests { } #[test] + fn invalid_manifest_announcement_does_not_block_release_info() { + let (os, arch) = platform_target(); + let asset_key = format!("{os}-{arch}"); + let json = format!( + r####"{{ + "version": "99.99.99", + "protocol": 4, + "notes": "### Changed\n- One", + "announcement": {{ + "id": 123, + "title": "Keymap changes", + "body": "### Heads up\n- Defaults changed" + }}, + "assets": {{ + "{asset_key}": "https://example.com/herdr" + }} + }}"#### + ); + + let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); + handle_manifest_announcement(&manifest); + let release = release_info_from_manifest(manifest) + .unwrap() + .expect("release info"); + + assert_eq!(release.version, Version::parse("99.99.99").unwrap()); + assert_eq!(release.download_url, "https://example.com/herdr"); + } + + #[test] fn checked_in_website_manifest_matches_update_schema() { let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json")) .expect("website/latest.json should match updater schema"); |
