Files
Codex-X/scripts/validate_updater_release.py

234 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""Validate Codex-X updater metadata before a draft release is published."""
from __future__ import annotations
import argparse
import base64
import binascii
import json
from pathlib import Path
from typing import Any
from urllib.parse import quote, unquote, urlparse
REQUIRED_PLATFORMS = {
"darwin-aarch64": ".app.tar.gz",
"darwin-aarch64-app": ".app.tar.gz",
"darwin-x86_64": ".app.tar.gz",
"darwin-x86_64-app": ".app.tar.gz",
"windows-x86_64": ".exe",
"windows-x86_64-nsis": ".exe",
# Older MSI clients select this key, then identify EXE/MSI by file content.
"windows-x86_64-msi": ".exe",
"linux-x86_64": ".AppImage",
"linux-x86_64-deb": ".deb",
"linux-x86_64-rpm": ".rpm",
"linux-x86_64-appimage": ".AppImage",
}
WINDOWS_PLATFORMS = (
"windows-x86_64", "windows-x86_64-nsis", "windows-x86_64-msi"
)
def migrate_windows_to_nsis(manifest: dict[str, Any]) -> None:
"""Serve the same signed NSIS installer to old MSI and new NSIS clients."""
platforms = manifest.get("platforms")
if not isinstance(platforms, dict):
fail("latest.json has no platforms object")
source = platforms.get("windows-x86_64-nsis")
if not isinstance(source, dict):
fail("missing signed NSIS platform for Windows migration")
for platform in WINDOWS_PLATFORMS:
platforms[platform] = dict(source)
def fail(message: str) -> None:
raise SystemExit(f"Updater release validation failed: {message}")
def load_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
fail(f"cannot read {path}: {error}")
def validate_signature(platform: str, value: Any) -> None:
if not isinstance(value, str) or not value.strip():
fail(f"{platform} has no signature")
compact = "".join(value.split())
try:
decoded = base64.b64decode(compact, validate=True)
except (ValueError, binascii.Error) as error:
fail(f"{platform} signature is not valid Base64: {error}")
if len(decoded) < 64:
fail(f"{platform} signature is unexpectedly short")
def canonical_asset_url(repository: str, release_tag: str, asset_name: str) -> str:
return (
f"https://github.com/{repository}/releases/download/"
f"{quote(release_tag, safe='')}/{quote(asset_name, safe='')}"
)
def rewrite_download_urls(
manifest_path: Path,
manifest: dict[str, Any],
assets_by_url: dict[str, dict[str, Any]],
assets_by_name: dict[str, dict[str, Any]],
repository: str,
release_tag: str,
) -> int:
platforms = manifest.get("platforms")
if not isinstance(platforms, dict):
fail("latest.json has no platforms object")
rewritten = 0
for platform, entry in platforms.items():
if not isinstance(entry, dict):
fail(f"invalid platform entry {platform}")
current_url = entry.get("url")
if not isinstance(current_url, str):
fail(f"{platform} has an invalid download URL")
asset = assets_by_url.get(current_url)
if asset is None:
parsed_url = urlparse(current_url)
asset_name = unquote(parsed_url.path.rsplit("/", 1)[-1])
if parsed_url.scheme == "https" and parsed_url.netloc == "github.com":
asset = assets_by_name.get(asset_name)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
download_url = canonical_asset_url(repository, release_tag, asset["name"])
if current_url != download_url:
entry["url"] = download_url
rewritten += 1
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return rewritten
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--assets", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--release-tag", required=True)
parser.add_argument("--rewrite-download-urls", action="store_true")
parser.add_argument("--migrate-windows-to-nsis", action="store_true")
parser.add_argument("--require-signature-assets", action="store_true")
args = parser.parse_args()
repository_parts = args.repository.split("/")
if len(repository_parts) != 2 or not all(repository_parts):
fail(f"invalid GitHub repository {args.repository!r}")
manifest = load_json(args.manifest)
assets = load_json(args.assets)
if not isinstance(manifest, dict):
fail("latest.json must contain an object")
if not isinstance(assets, list):
fail("release assets response must contain a list")
if manifest.get("version") != args.version:
fail(
f"latest.json version {manifest.get('version')!r} does not match {args.version!r}"
)
asset_names: set[str] = set()
assets_by_name: dict[str, dict[str, Any]] = {}
assets_by_url: dict[str, dict[str, Any]] = {}
for asset in assets:
if not isinstance(asset, dict) or not isinstance(asset.get("name"), str):
fail("release assets response contains an invalid entry")
asset_names.add(asset["name"])
assets_by_name[asset["name"]] = asset
for field in ("url", "browser_download_url"):
value = asset.get(field)
if isinstance(value, str) and value:
assets_by_url[value] = asset
assets_by_url[
canonical_asset_url(args.repository, args.release_tag, asset["name"])
] = asset
if "latest.json" not in asset_names:
fail("the draft release does not contain latest.json")
platforms = manifest.get("platforms")
if not isinstance(platforms, dict):
fail("latest.json has no platforms object")
if args.migrate_windows_to_nsis:
if not args.rewrite_download_urls:
fail("--migrate-windows-to-nsis requires --rewrite-download-urls")
migrate_windows_to_nsis(manifest)
if args.rewrite_download_urls:
rewritten = rewrite_download_urls(
args.manifest,
manifest,
assets_by_url,
assets_by_name,
args.repository,
args.release_tag,
)
print(f"Rewrote {rewritten} updater asset URLs to public download URLs.")
for platform, entry in platforms.items():
if not isinstance(entry, dict):
fail(f"invalid platform entry {platform}")
validate_signature(platform, entry.get("signature"))
url = entry.get("url")
if not isinstance(url, str) or not url.startswith("https://"):
fail(f"{platform} has an invalid download URL")
asset = assets_by_url.get(url)
if asset is None:
fail(f"{platform} URL does not point to an asset in this release")
expected_url = canonical_asset_url(
args.repository,
args.release_tag,
asset["name"],
)
if url != expected_url:
fail(f"{platform} URL does not use the stable release tag download URL")
for platform, suffix in REQUIRED_PLATFORMS.items():
entry = platforms.get(platform)
if not isinstance(entry, dict):
fail(f"missing platform {platform}")
asset = assets_by_url[entry["url"]]
asset_name = asset["name"]
if not asset_name.endswith(suffix):
fail(f"{platform} points to {asset_name!r}, expected a {suffix} updater")
if args.require_signature_assets and f"{asset_name}.sig" not in asset_names:
fail(f"signature asset is missing for {asset_name}")
windows_entries = {
(platforms[key]["url"], platforms[key]["signature"])
for key in WINDOWS_PLATFORMS
}
if len(windows_entries) != 1:
fail("all Windows updater keys must use the same signed NSIS installer")
if any(name.lower().endswith((".msi", ".msi.zip")) for name in asset_names):
fail("legacy MSI installers must not be mixed into an NSIS release")
signature_status = (
"signature assets required"
if args.require_signature_assets
else "signature assets optional"
)
print(
f"Validated updater {args.version}: "
f"{len(REQUIRED_PLATFORMS)} platform installers are complete; "
f"{signature_status}."
)
if __name__ == "__main__":
main()