diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/changelog.py | 46 | ||||
| -rw-r--r-- | scripts/test_changelog.py | 77 | ||||
| -rw-r--r-- | scripts/test_unix_installer.py | 171 |
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() |
