cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/release-build-and-order
goq / scripts / verify-portal-release.py
verify-portal-release.py 14.3K executable
1#!/usr/bin/env python32"""Fail-closed policy verifier for Portal public releases."""34from __future__ import annotations56import argparse7import hashlib8import json9import pathlib10import re11import subprocess12import sys13import tomllib14from collections.abc import Callable, Sequence15from typing import Any16from urllib.parse import quote171819REPOSITORY = "FelineStateMachine/goq"20PRODUCT = "portal-client"21BUNDLE_IDENTIFIER = "sh.goq.portal"22PLATFORM = "macos"23ARCHITECTURE = "arm64"24# Portal is distributed without an Apple Developer ID. arm64 macOS refuses to25# execute a wholly unsigned binary, so the release carries the linker's ad-hoc26# signature and anchors trust in GitHub/Sigstore build provenance plus the27# published digest instead of in Gatekeeper.28SIGNING = "adhoc"29VERIFICATION = "adhoc-signed+github-attested+sha256"30NON_RELEASE_FEATURES = (31 "demo-direct-node",32 "experimental-non-macos-pointer-capture",33)34TAG_PATTERN = re.compile(35 r"^v(?P<version>(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\."36 r"(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?)$"37)38SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")39RunCommand = Callable[[Sequence[str], pathlib.Path], str]404142class VerificationError(RuntimeError):43 """A release input violates the public release contract."""444546def require(condition: bool, message: str) -> None:47 if not condition:48 raise VerificationError(message)495051def run_command(command: Sequence[str], cwd: pathlib.Path) -> str:52 result = subprocess.run(53 command,54 cwd=cwd,55 check=False,56 stdout=subprocess.PIPE,57 stderr=subprocess.PIPE,58 text=True,59 )60 if result.returncode != 0:61 detail = result.stderr.strip() or result.stdout.strip() or "command failed"62 raise VerificationError(f"{command[0]} verification failed: {detail}")63 return result.stdout.strip()646566def release_version(release_tag: str) -> str:67 match = TAG_PATTERN.fullmatch(release_tag)68 require(match is not None, "release tag must be an exact vVERSION tag")69 return match.group("version")707172def expected_asset_names(release_tag: str) -> tuple[str, str, str]:73 version = release_version(release_tag)74 stem = f"Portal-{version}-{ARCHITECTURE}"75 return f"{stem}.dmg", f"{stem}.dmg.sha256", f"{stem}.json"767778def read_toml(path: pathlib.Path) -> dict[str, Any]:79 require(path.is_file() and not path.is_symlink(), f"required TOML file is missing: {path}")80 try:81 return tomllib.loads(path.read_text(encoding="utf-8"))82 except (OSError, tomllib.TOMLDecodeError) as error:83 raise VerificationError(f"cannot read TOML file {path}: {error}") from error848586def workspace_package_versions(repo_dir: pathlib.Path) -> dict[str, str]:87 workspace = read_toml(repo_dir / "Cargo.toml")88 members = workspace.get("workspace", {}).get("members")89 require(isinstance(members, list) and members, "Cargo workspace members are missing")90 versions: dict[str, str] = {}91 for member_pattern in members:92 require(isinstance(member_pattern, str), "Cargo workspace member must be a path")93 matches = sorted(repo_dir.glob(member_pattern))94 require(matches, f"Cargo workspace member does not resolve: {member_pattern}")95 for member in matches:96 manifest = read_toml(member / "Cargo.toml")97 package = manifest.get("package", {})98 name = package.get("name")99 version = package.get("version")100 require(isinstance(name, str) and name, f"package name is missing: {member}")101 require(isinstance(version, str) and version, f"package version is missing: {member}")102 require(name not in versions, f"duplicate workspace package name: {name}")103 versions[name] = version104 return versions105106107def validate_source(108 repo_dir: pathlib.Path,109 release_tag: str,110 runner: RunCommand = run_command,111) -> dict[str, Any]:112 version = release_version(release_tag)113 repo_dir = repo_dir.resolve()114 require((repo_dir / ".git").exists(), "repository does not contain .git")115 status = runner(116 ["git", "status", "--porcelain=v1", "--untracked-files=all"], repo_dir117 )118 require(status == "", "release worktree must be clean")119 head = runner(["git", "rev-parse", "--verify", "HEAD"], repo_dir)120 tagged_commit = runner(121 ["git", "rev-list", "-n", "1", f"refs/tags/{release_tag}"], repo_dir122 )123 require(tagged_commit == head, f"release tag {release_tag} must resolve exactly to HEAD")124125 versions = workspace_package_versions(repo_dir)126 mismatched = sorted(name for name, value in versions.items() if value != version)127 require(not mismatched, f"workspace package versions do not match {version}: {', '.join(mismatched)}")128129 tauri_path = repo_dir / "src-tauri" / "tauri.conf.json"130 try:131 tauri = json.loads(tauri_path.read_text(encoding="utf-8"))132 except (OSError, json.JSONDecodeError) as error:133 raise VerificationError(f"cannot read Tauri configuration: {error}") from error134 require(tauri.get("productName") == "Portal", "unexpected Tauri product name")135 require(tauri.get("identifier") == BUNDLE_IDENTIFIER, "unexpected Tauri bundle identifier")136 require(tauri.get("version") == version, "Tauri version does not match release tag")137138 portal_manifest = read_toml(repo_dir / "src-tauri" / "Cargo.toml")139 features = portal_manifest.get("features", {})140 default_features = features.get("default", [])141 require(isinstance(default_features, list), "Portal default features must be a list")142 for feature in NON_RELEASE_FEATURES:143 require(feature in features, f"Portal {feature} feature declaration is missing")144 require(feature not in default_features, f"{feature} must not be enabled by default")145 return {146 "release_tag": release_tag,147 "version": version,148 "git_commit": head,149 "workspace_packages": versions,150 "platform": PLATFORM,151 "architecture": ARCHITECTURE,152 "demo_direct_node": False,153 }154155156def sha256_file(path: pathlib.Path) -> str:157 digest = hashlib.sha256()158 with path.open("rb") as handle:159 for chunk in iter(lambda: handle.read(1024 * 1024), b""):160 digest.update(chunk)161 return digest.hexdigest()162163164def expected_release_manifest(165 release_tag: str, git_commit: str, digest: str166) -> dict[str, Any]:167 asset, checksum_asset, _ = expected_asset_names(release_tag)168 return {169 "architecture": ARCHITECTURE,170 "architectures": [ARCHITECTURE],171 "asset": asset,172 "bundle_identifier": BUNDLE_IDENTIFIER,173 "checksum_asset": checksum_asset,174 "demo_direct_node": False,175 "developer_id_signed": False,176 "format": 2,177 "gatekeeper_verified": False,178 "git_commit": git_commit,179 "hardened_runtime": False,180 "notarized": False,181 "platform": PLATFORM,182 "product": PRODUCT,183 "release_tag": release_tag,184 "sha256": digest,185 "signing": SIGNING,186 "stapled": False,187 "version": release_version(release_tag),188 }189190191def validate_assets(192 repo_dir: pathlib.Path,193 release_tag: str,194 asset_dir: pathlib.Path,195 runner: RunCommand = run_command,196) -> dict[str, Any]:197 source = validate_source(repo_dir, release_tag, runner)198 asset_dir = asset_dir.resolve()199 require(asset_dir.is_dir() and not asset_dir.is_symlink(), "asset directory is missing or unsafe")200 asset, checksum_asset, manifest_asset = expected_asset_names(release_tag)201 expected_names = {asset, checksum_asset, manifest_asset}202 entries = list(asset_dir.iterdir())203 require({entry.name for entry in entries} == expected_names, "release directory must contain the exact Portal asset set")204 require(205 all(entry.is_file() and not entry.is_symlink() for entry in entries),206 "release assets must be regular files",207 )208 digest = sha256_file(asset_dir / asset)209 require(SHA256_PATTERN.fullmatch(digest) is not None, "invalid DMG SHA-256")210 checksum = (asset_dir / checksum_asset).read_text(encoding="utf-8")211 require(checksum == f"{digest} {asset}\n", "checksum file does not exactly match the DMG")212 try:213 manifest = json.loads((asset_dir / manifest_asset).read_text(encoding="utf-8"))214 except (OSError, json.JSONDecodeError) as error:215 raise VerificationError(f"cannot read Portal release manifest: {error}") from error216 expected_manifest = expected_release_manifest(release_tag, source["git_commit"], digest)217 require(manifest == expected_manifest, "Portal release manifest does not match the verified source and DMG")218 return expected_manifest219220221def validate_release_asset_names(release_tag: str, names: Any) -> list[str]:222 require(isinstance(names, list), "GitHub asset names must be a JSON array")223 require(all(isinstance(name, str) for name in names), "GitHub asset names must be strings")224 expected = sorted(expected_asset_names(release_tag))225 require(sorted(names) == expected, "GitHub Release must contain the exact Portal asset set")226 return expected227228229def validate_website_manifest(path: pathlib.Path) -> dict[str, Any]:230 try:231 manifest = json.loads(path.read_text(encoding="utf-8"))232 except (OSError, json.JSONDecodeError) as error:233 raise VerificationError(f"cannot read website Portal manifest: {error}") from error234 require(235 set(manifest) == {"builds", "format", "product"},236 "website Portal manifest has unexpected top-level fields",237 )238 require(manifest.get("format") == 1, "website Portal manifest format must be 1")239 require(manifest.get("product") == PRODUCT, "website Portal manifest product is invalid")240 builds = manifest.get("builds")241 require(242 isinstance(builds, dict) and set(builds) == {"macos-arm64"},243 "website manifest may describe only macOS arm64",244 )245 build = builds["macos-arm64"]246 require(isinstance(build, dict), "website macOS arm64 entry must be an object")247 available = build.get("available")248 require(isinstance(available, bool), "website availability must be boolean")249 if not available:250 require(set(build) == {"available", "reason"}, "unavailable website entry must not contain release links")251 require(isinstance(build.get("reason"), str) and build["reason"].strip(), "unavailable website entry requires a reason")252 return manifest253254 required_fields = {255 "architecture",256 "asset",257 "available",258 "checksum_asset",259 "checksum_url",260 "download_url",261 "manifest_asset",262 "manifest_url",263 "platform",264 "release_tag",265 "release_url",266 "sha256",267 "verification",268 "version",269 }270 require(set(build) == required_fields, "available website entry fields are incomplete or unexpected")271 tag = build.get("release_tag")272 require(isinstance(tag, str), "website release tag is missing")273 version = release_version(tag)274 asset, checksum_asset, manifest_asset = expected_asset_names(tag)275 require(build.get("version") == version, "website version does not match release tag")276 require(build.get("platform") == PLATFORM, "website platform must be macos")277 require(build.get("architecture") == ARCHITECTURE, "website architecture must be arm64")278 require(build.get("asset") == asset, "website DMG asset name is invalid")279 require(build.get("checksum_asset") == checksum_asset, "website checksum asset name is invalid")280 require(build.get("manifest_asset") == manifest_asset, "website release manifest asset name is invalid")281 require(282 isinstance(build.get("sha256"), str) and SHA256_PATTERN.fullmatch(build["sha256"]) is not None,283 "website SHA-256 is invalid",284 )285 encoded_tag = quote(tag, safe="")286 release_base = f"https://github.com/{REPOSITORY}/releases"287 download_base = f"{release_base}/download/{encoded_tag}"288 require(build.get("download_url") == f"{download_base}/{asset}", "website download URL is not exact")289 require(build.get("checksum_url") == f"{download_base}/{checksum_asset}", "website checksum URL is not exact")290 require(build.get("manifest_url") == f"{download_base}/{manifest_asset}", "website manifest URL is not exact")291 require(build.get("release_url") == f"{release_base}/tag/{encoded_tag}", "website release URL is not exact")292 require(build.get("verification") == VERIFICATION, "website verification claim is invalid")293 return manifest294295296def load_json(path: pathlib.Path) -> Any:297 try:298 return json.loads(path.read_text(encoding="utf-8"))299 except (OSError, json.JSONDecodeError) as error:300 raise VerificationError(f"cannot read JSON file {path}: {error}") from error301302303def parser() -> argparse.ArgumentParser:304 argument_parser = argparse.ArgumentParser(description=__doc__)305 subparsers = argument_parser.add_subparsers(dest="command", required=True)306 source_parser = subparsers.add_parser("source")307 source_parser.add_argument("--repo-dir", type=pathlib.Path, required=True)308 source_parser.add_argument("--release-tag", required=True)309 asset_parser = subparsers.add_parser("assets")310 asset_parser.add_argument("--repo-dir", type=pathlib.Path, required=True)311 asset_parser.add_argument("--release-tag", required=True)312 asset_parser.add_argument("--asset-dir", type=pathlib.Path, required=True)313 remote_parser = subparsers.add_parser("release-assets")314 remote_parser.add_argument("--release-tag", required=True)315 remote_parser.add_argument("--names-json", type=pathlib.Path, required=True)316 website_parser = subparsers.add_parser("website")317 website_parser.add_argument("--manifest", type=pathlib.Path, required=True)318 return argument_parser319320321def main(argv: Sequence[str] | None = None) -> int:322 args = parser().parse_args(argv)323 try:324 if args.command == "source":325 result = validate_source(args.repo_dir, args.release_tag)326 elif args.command == "assets":327 result = validate_assets(args.repo_dir, args.release_tag, args.asset_dir)328 elif args.command == "release-assets":329 result = validate_release_asset_names(args.release_tag, load_json(args.names_json))330 else:331 result = validate_website_manifest(args.manifest)332 except VerificationError as error:333 print(f"Portal release verification failed: {error}", file=sys.stderr)334 return 1335 print(json.dumps(result, indent=2, sort_keys=True))336 return 0337338339if __name__ == "__main__":340 raise SystemExit(main())