aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 19:59:22 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 19:59:22 +0300
commit8bbabfc5b1c9f6f318e057d34848df8e791a6c48 (patch)
treec6ef7fd599e05997f39dc2f4c5eb9a2bc3e25b2a
parent836fd7e585af15cc1ccb0d4f04586ffec28d2a3c (diff)
fix: harden release manifest update flow
-rw-r--r--.github/workflows/release.yml3
-rw-r--r--scripts/changelog.py13
-rw-r--r--scripts/test_changelog.py20
-rw-r--r--src/update.rs51
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");