aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-08-07 14:40:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-08-07 14:40:14 +0300
commit322ab6a5a98db5c230b7c90881b8b4baf4cee624 (patch)
treea64ad1b46bc0ef0a0748ccad8409c5e7e97cb7ae /scripts
parent69a07fdf061a35276295189d52d591893c429806 (diff)
fix: verify stable release checksums
Diffstat (limited to 'scripts')
-rw-r--r--scripts/changelog.py46
-rw-r--r--scripts/test_changelog.py77
-rw-r--r--scripts/test_unix_installer.py171
3 files changed, 280 insertions, 14 deletions
diff --git a/scripts/changelog.py b/scripts/changelog.py
index a5d2daf2..4d023ccc 100644
--- a/scripts/changelog.py
+++ b/scripts/changelog.py
@@ -186,11 +186,26 @@ def normalize_assets(value: Any, label: str) -> dict[str, str]:
return normalized_assets
+def normalize_sha256(value: Any, label: str) -> dict[str, str]:
+ if not isinstance(value, dict):
+ raise ChangelogError(f"{label} must be an object")
+
+ checksums: dict[str, str] = {}
+ for target in ASSET_TARGETS:
+ checksum = value.get(target)
+ if not isinstance(checksum, str) or re.fullmatch(
+ r"[0-9a-fA-F]{64}", checksum.strip()
+ ) is None:
+ raise ChangelogError(f"{label} has invalid SHA-256 for {target}")
+ checksums[target] = checksum.strip().lower()
+ return checksums
+
+
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"}
+ allowed_keys = {"notes", "announcement", "assets", "sha256", "protocol"}
extra_keys = sorted(set(value) - allowed_keys)
if extra_keys:
raise ChangelogError(f"{label} has unsupported field(s): {', '.join(extra_keys)}")
@@ -213,6 +228,8 @@ def normalize_release_metadata(value: Any, label: str, version: str) -> dict[str
metadata["assets"] = normalize_assets(value.get("assets"), f"{label}.assets")
else:
metadata["assets"] = default_release_assets(version)
+ if "sha256" in value:
+ metadata["sha256"] = normalize_sha256(value.get("sha256"), f"{label}.sha256")
announcement = normalize_announcement(value.get("announcement"), label)
if announcement is not None:
metadata["announcement"] = announcement
@@ -243,6 +260,7 @@ def build_latest_json(
version: str,
notes: str,
assets: dict[str, str],
+ sha256: dict[str, str],
protocol: int | None = None,
announcement: dict[str, str] | None = None,
releases: dict[str, Any] | None = None,
@@ -256,6 +274,7 @@ def build_latest_json(
protocol = read_protocol_version()
ordered_assets = normalize_assets(assets, "assets")
+ ordered_sha256 = normalize_sha256(sha256, "sha256")
normalized_announcement = normalize_announcement(announcement, "root")
archived_releases = normalize_releases(releases)
current_metadata: dict[str, Any] = {
@@ -263,6 +282,7 @@ def build_latest_json(
"protocol": protocol,
"assets": ordered_assets,
}
+ current_metadata["sha256"] = ordered_sha256
if normalized_announcement is not None:
current_metadata["announcement"] = normalized_announcement
archived_releases[normalized_version] = current_metadata
@@ -277,6 +297,7 @@ def build_latest_json(
"notes": normalized_notes,
"assets": ordered_assets,
}
+ manifest["sha256"] = ordered_sha256
if normalized_announcement is not None:
manifest["announcement"] = normalized_announcement
manifest["releases"] = archived_releases
@@ -322,7 +343,14 @@ def manifest_from_release_payload(
if isinstance(name, str) and name not in release_assets:
release_assets[name] = asset
+ missing_assets = [name for name in EXPECTED_ASSET_NAMES.values() if name not in release_assets]
+ if missing_assets:
+ raise ChangelogError(
+ f"GitHub release v{normalized_version} is missing asset {missing_assets[0]}"
+ )
+
manifest_assets: dict[str, str] = {}
+ manifest_sha256: dict[str, str] = {}
for target, asset_name in EXPECTED_ASSET_NAMES.items():
asset = release_assets.get(asset_name)
if not isinstance(asset, dict):
@@ -330,13 +358,19 @@ def manifest_from_release_payload(
url = str(asset.get("url") or "").strip()
if not url:
raise ChangelogError(f"GitHub release asset {asset_name} is missing a download URL")
+ digest = str(asset.get("digest") or "").strip()
+ digest_match = re.fullmatch(r"sha256:([0-9a-fA-F]{64})", digest)
+ if digest_match is None:
+ raise ChangelogError(f"GitHub release asset {asset_name} is missing a SHA-256 digest")
manifest_assets[target] = url
+ manifest_sha256[target] = digest_match.group(1).lower()
return {
"version": normalized_version,
"protocol": protocol if protocol is not None else read_protocol_version(),
"notes": notes,
"assets": manifest_assets,
+ "sha256": manifest_sha256,
}
@@ -358,12 +392,14 @@ def canonicalize_manifest(manifest: dict[str, Any], label: str) -> dict[str, Any
raise ChangelogError(f"{label} is missing an assets object")
normalized_assets = normalize_assets(assets, f"{label} assets")
+ normalized_sha256 = normalize_sha256(manifest.get("sha256"), f"{label} sha256")
return {
"version": normalize_version(version),
"protocol": protocol,
"notes": notes.strip(),
"assets": normalized_assets,
+ "sha256": normalized_sha256,
}
@@ -391,6 +427,10 @@ def ensure_current_release_assets_are_mirrored(manifest: dict[str, Any], label:
raise ChangelogError(
f"{label} releases.{canonical['version']}.assets must match top-level assets"
)
+ if metadata.get("sha256") != canonical["sha256"]:
+ raise ChangelogError(
+ f"{label} releases.{canonical['version']}.sha256 must match top-level sha256"
+ )
def load_text(path: Path) -> str:
@@ -431,6 +471,9 @@ def archived_releases_from_current_manifest(manifest: dict[str, Any]) -> dict[st
metadata["assets"] = normalize_assets(assets, "current root assets")
else:
metadata["assets"] = default_release_assets(normalized_version)
+ sha256 = manifest.get("sha256")
+ if isinstance(sha256, dict):
+ metadata["sha256"] = normalize_sha256(sha256, "current root sha256")
announcement = normalize_announcement(manifest.get("announcement"), "current root")
if announcement is not None:
metadata["announcement"] = announcement
@@ -592,6 +635,7 @@ def cmd_sync_latest_json(args: argparse.Namespace) -> int:
version,
str(new_manifest["notes"]),
dict(new_manifest["assets"]),
+ sha256=dict(new_manifest["sha256"]),
protocol=int(new_manifest["protocol"]),
announcement=announcement,
releases=archived_releases_from_current_manifest(current_manifest),
diff --git a/scripts/test_changelog.py b/scripts/test_changelog.py
index dff19caf..ad384734 100644
--- a/scripts/test_changelog.py
+++ b/scripts/test_changelog.py
@@ -24,6 +24,31 @@ from scripts.changelog import (
)
+def release_sha256() -> dict[str, str]:
+ return {
+ "linux-x86_64": "a" * 64,
+ "linux-aarch64": "b" * 64,
+ "macos-x86_64": "c" * 64,
+ "macos-aarch64": "d" * 64,
+ }
+
+
+def release_assets_with_digests() -> list[dict[str, str]]:
+ return [
+ {
+ "name": f"herdr-{target}",
+ "url": f"https://example.com/{target}",
+ "digest": f"sha256:{digest * 64}",
+ }
+ for target, digest in (
+ ("linux-x86_64", "a"),
+ ("linux-aarch64", "b"),
+ ("macos-x86_64", "c"),
+ ("macos-aarch64", "d"),
+ )
+ ]
+
+
class ChangelogScriptTests(unittest.TestCase):
def test_prepare_release_moves_unreleased_into_versioned_section(self) -> None:
original = """# Changelog\n\n## Unreleased\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n"""
@@ -55,6 +80,7 @@ class ChangelogScriptTests(unittest.TestCase):
"0.1.1",
"\n### Fixed\n- One\n\n",
default_release_assets("0.1.1"),
+ release_sha256(),
)
)
@@ -67,6 +93,7 @@ class ChangelogScriptTests(unittest.TestCase):
"v0.1.1",
"### Fixed\n- Smoothed Claude flapping.\n",
default_release_assets("0.1.1"),
+ release_sha256(),
)
)
@@ -83,6 +110,8 @@ class ChangelogScriptTests(unittest.TestCase):
},
)
self.assertEqual(manifest["releases"]["0.1.1"]["assets"], manifest["assets"])
+ self.assertEqual(manifest["sha256"], release_sha256())
+ self.assertEqual(manifest["releases"]["0.1.1"]["sha256"], manifest["sha256"])
def test_build_latest_json_embeds_product_announcement(self) -> None:
manifest = json.loads(
@@ -90,6 +119,7 @@ class ChangelogScriptTests(unittest.TestCase):
"0.1.1",
"### Fixed\n- One",
default_release_assets("0.1.1"),
+ release_sha256(),
announcement={"id": "keybinding-v2", "title": "Keybind Refactor", "body": "body"},
)
)
@@ -109,6 +139,7 @@ class ChangelogScriptTests(unittest.TestCase):
"0.1.2",
"### Fixed\n- Two",
default_release_assets("0.1.2"),
+ release_sha256(),
releases={"0.1.1": {"notes": "### Fixed\n- One"}},
)
)
@@ -127,6 +158,7 @@ class ChangelogScriptTests(unittest.TestCase):
"0.1.2",
"### Fixed\n- Two",
default_release_assets("0.1.2"),
+ release_sha256(),
releases={"0.1.1": {"notes": "### Fixed\n- One", "assets": assets}},
)
)
@@ -139,6 +171,7 @@ class ChangelogScriptTests(unittest.TestCase):
"0.1.2",
"### Fixed\n- Two",
default_release_assets("0.1.2"),
+ release_sha256(),
releases={"0.1.1": {"notes": "### Fixed\n- One", "protocol": 7}},
)
)
@@ -151,6 +184,7 @@ class ChangelogScriptTests(unittest.TestCase):
"0.1.2",
"### Fixed\n- Two",
default_release_assets("0.1.2"),
+ release_sha256(),
releases={
"0.1.1": {
"notes": "### Breaking Changes\n- The client/server protocol is now version 7."
@@ -280,19 +314,14 @@ class ChangelogScriptTests(unittest.TestCase):
finally:
path.unlink(missing_ok=True)
- def test_manifest_from_release_payload_uses_release_body_and_asset_urls(self) -> None:
+ def test_manifest_from_release_payload_uses_release_body_and_asset_digests(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"},
- ],
+ "assets": release_assets_with_digests(),
},
"0.1.1",
)
@@ -309,6 +338,7 @@ class ChangelogScriptTests(unittest.TestCase):
"macos-x86_64": "https://example.com/macos-x86_64",
"macos-aarch64": "https://example.com/macos-aarch64",
},
+ "sha256": release_sha256(),
},
)
@@ -319,12 +349,7 @@ class ChangelogScriptTests(unittest.TestCase):
"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"},
- ],
+ "assets": release_assets_with_digests(),
},
"0.1.1",
protocol=42,
@@ -332,6 +357,22 @@ class ChangelogScriptTests(unittest.TestCase):
self.assertEqual(manifest["protocol"], 42)
+ def test_manifest_from_release_payload_rejects_missing_digest(self) -> None:
+ assets = release_assets_with_digests()
+ assets[0].pop("digest")
+
+ with self.assertRaisesRegex(ChangelogError, "missing a SHA-256 digest"):
+ manifest_from_release_payload(
+ {
+ "tagName": "v0.1.1",
+ "isDraft": False,
+ "isPrerelease": False,
+ "body": "### Fixed\n- One\n",
+ "assets": assets,
+ },
+ "0.1.1",
+ )
+
def test_manifest_from_release_payload_rejects_missing_asset(self) -> None:
with self.assertRaisesRegex(ChangelogError, "missing asset herdr-macos-aarch64"):
manifest_from_release_payload(
@@ -386,6 +427,7 @@ class ChangelogScriptTests(unittest.TestCase):
"macos-x86_64": "https://example.com/macos-x86_64",
"macos-aarch64": "https://example.com/macos-aarch64",
},
+ "sha256": release_sha256(),
}
expected = {
"version": "0.1.1",
@@ -397,6 +439,7 @@ class ChangelogScriptTests(unittest.TestCase):
"macos-x86_64": "https://example.com/macos-x86_64",
"macos-aarch64": "https://example.com/macos-aarch64",
},
+ "sha256": release_sha256(),
}
canonical = ensure_manifest_matches_expected(actual, expected, "test manifest")
@@ -410,10 +453,12 @@ class ChangelogScriptTests(unittest.TestCase):
"protocol": read_protocol_version(),
"notes": "### Fixed\n- One",
"assets": assets,
+ "sha256": release_sha256(),
"releases": {
"0.1.1": {
"notes": "### Fixed\n- One",
"assets": assets,
+ "sha256": release_sha256(),
}
},
},
@@ -428,10 +473,12 @@ class ChangelogScriptTests(unittest.TestCase):
"protocol": read_protocol_version(),
"notes": "### Fixed\n- One",
"assets": default_release_assets("0.1.1"),
+ "sha256": release_sha256(),
"releases": {
"0.1.1": {
"notes": "### Fixed\n- One",
"assets": default_release_assets("0.1.0"),
+ "sha256": release_sha256(),
}
},
},
@@ -451,6 +498,7 @@ class ChangelogScriptTests(unittest.TestCase):
"macos-x86_64": "https://example.com/macos-x86_64",
"macos-aarch64": "https://example.com/macos-aarch64",
},
+ "sha256": release_sha256(),
},
{
"version": "0.1.1",
@@ -462,6 +510,7 @@ class ChangelogScriptTests(unittest.TestCase):
"macos-x86_64": "https://example.com/macos-x86_64",
"macos-aarch64": "https://example.com/macos-aarch64",
},
+ "sha256": release_sha256(),
},
"test manifest",
)
@@ -483,12 +532,14 @@ class ChangelogScriptTests(unittest.TestCase):
"protocol": read_protocol_version() + 1,
"notes": "### Fixed\n- One",
"assets": default_release_assets("0.1.1"),
+ "sha256": release_sha256(),
}
expected = {
"version": "0.1.1",
"protocol": read_protocol_version(),
"notes": "### Fixed\n- One",
"assets": default_release_assets("0.1.1"),
+ "sha256": release_sha256(),
}
with self.assertRaisesRegex(ChangelogError, "does not match"):
diff --git a/scripts/test_unix_installer.py b/scripts/test_unix_installer.py
new file mode 100644
index 00000000..82de55c8
--- /dev/null
+++ b/scripts/test_unix_installer.py
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+INSTALLER = REPO_ROOT / "website" / "install.sh"
+REQUIRED_COMMANDS = ("awk", "cat", "chmod", "cp", "mkdir", "mktemp", "mv", "rm")
+
+
+class UnixInstallerTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temp_dir = tempfile.TemporaryDirectory(prefix="herdr-installer-test-")
+ self.root = Path(self.temp_dir.name)
+ self.bin_dir = self.root / "bin"
+ self.bin_dir.mkdir()
+ self.install_dir = self.root / "install"
+ self.payload = self.root / "payload"
+ self.payload.write_bytes(b"fake-herdr-binary\n")
+ self.expected_sha256 = hashlib.sha256(self.payload.read_bytes()).hexdigest()
+
+ for command in REQUIRED_COMMANDS:
+ path = shutil.which(command)
+ if path is None:
+ self.fail(f"test host is missing required command: {command}")
+ (self.bin_dir / command).symlink_to(path)
+
+ self._write_executable(
+ "uname",
+ """#!/bin/sh
+case "$1" in
+ -s) echo Linux ;;
+ -m) echo x86_64 ;;
+ *) exit 1 ;;
+esac
+""",
+ )
+ self._write_executable(
+ "curl",
+ """#!/bin/sh
+out=""
+previous=""
+for argument in "$@"; do
+ if [ "$previous" = "-o" ]; then
+ out="$argument"
+ break
+ fi
+ previous="$argument"
+done
+if [ -n "$out" ]; then
+ cp "$FAKE_PAYLOAD" "$out"
+else
+ cat "$FAKE_MANIFEST"
+fi
+""",
+ )
+
+ def tearDown(self) -> None:
+ self.temp_dir.cleanup()
+
+ def _write_executable(self, name: str, content: str) -> None:
+ path = self.bin_dir / name
+ path.write_text(content, encoding="utf-8")
+ path.chmod(0o755)
+
+ def _select_checksum_tool(self, tool: str) -> None:
+ if tool == "sha256sum":
+ path = shutil.which("sha256sum")
+ if path is None:
+ self.fail("test host is missing sha256sum")
+ (self.bin_dir / "sha256sum").symlink_to(path)
+ return
+
+ if tool == "shasum":
+ sha256sum = shutil.which("sha256sum")
+ if sha256sum is None:
+ self.fail("test host is missing sha256sum for the shasum fixture")
+ self._write_executable(
+ "shasum",
+ f"""#!/bin/sh
+[ "$1" = "-a" ] && [ "$2" = "256" ] || exit 2
+shift 2
+exec {sha256sum} "$@"
+""",
+ )
+ return
+
+ if tool == "openssl":
+ path = shutil.which("openssl")
+ if path is None:
+ self.fail("test host is missing openssl")
+ (self.bin_dir / "openssl").symlink_to(path)
+ return
+
+ self.fail(f"unknown checksum tool fixture: {tool}")
+
+ def _write_manifest(self, checksum: str | None) -> Path:
+ manifest: dict[str, object] = {
+ "version": "9.9.9",
+ "assets": {
+ "linux-x86_64": "https://example.invalid/herdr-linux-x86_64"
+ },
+ }
+ if checksum is not None:
+ manifest["sha256"] = {"linux-x86_64": checksum}
+ path = self.root / "latest.json"
+ path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
+ return path
+
+ def _run_installer(self, checksum: str | None, tool: str = "sha256sum") -> subprocess.CompletedProcess[str]:
+ self._select_checksum_tool(tool)
+ manifest = self._write_manifest(checksum)
+ env = {
+ **os.environ,
+ "PATH": str(self.bin_dir),
+ "FAKE_MANIFEST": str(manifest),
+ "FAKE_PAYLOAD": str(self.payload),
+ "HERDR_INSTALL_DIR": str(self.install_dir),
+ }
+ return subprocess.run(
+ ["/bin/sh", str(INSTALLER)],
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ def test_valid_download_uses_each_supported_checksum_tool(self) -> None:
+ for tool in ("sha256sum", "shasum", "openssl"):
+ with self.subTest(tool=tool):
+ shutil.rmtree(self.install_dir, ignore_errors=True)
+ for name in ("sha256sum", "shasum", "openssl"):
+ (self.bin_dir / name).unlink(missing_ok=True)
+
+ result = self._run_installer(self.expected_sha256.upper(), tool)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual((self.install_dir / "herdr").read_bytes(), self.payload.read_bytes())
+
+ def test_checksum_mismatch_does_not_replace_existing_binary(self) -> None:
+ self.install_dir.mkdir()
+ installed = self.install_dir / "herdr"
+ installed.write_bytes(b"existing-herdr\n")
+
+ result = self._run_installer("0" * 64)
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("checksum did not match", result.stderr)
+ self.assertEqual(installed.read_bytes(), b"existing-herdr\n")
+
+ def test_missing_checksum_fails_without_replacing_existing_binary(self) -> None:
+ self.install_dir.mkdir()
+ installed = self.install_dir / "herdr"
+ installed.write_bytes(b"existing-herdr\n")
+
+ result = self._run_installer(None)
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("valid SHA-256 checksum", result.stderr)
+ self.assertEqual(installed.read_bytes(), b"existing-herdr\n")
+
+
+if __name__ == "__main__":
+ unittest.main()