Files
agent-pet-companion/script/validate_release_identity.py

108 lines
3.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Bind one extracted App to one exact GitHub Release source identity."""
from __future__ import annotations
import argparse
import json
import pathlib
import plistlib
import re
import sys
def load_json_object(path: pathlib.Path) -> dict:
def reject_duplicates(pairs: list[tuple[str, object]]) -> dict:
result: dict = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key {key!r}")
result[key] = value
return result
with path.open(encoding="utf-8") as source:
value = json.load(source, object_pairs_hook=reject_duplicates)
if not isinstance(value, dict):
raise ValueError("top-level JSON value is not an object")
return value
def validate(
app: pathlib.Path,
architecture: str,
version: str,
build: str,
commit: str,
) -> str:
expected_build_id = f"{version}.{build}.{commit}"
with (app / "Contents/Info.plist").open("rb") as source:
info = plistlib.load(source)
manifest = load_json_object(app / "Contents/Resources/runtime-manifest.json")
expected_info = {
"CFBundleShortVersionString": version,
"CFBundleVersion": build,
"APCBuildID": expected_build_id,
"APCReleaseChannel": "release",
"APCRuntimeManifestSchemaVersion": "apc.runtime-manifest.v1",
}
for key, expected in expected_info.items():
if info.get(key) != expected:
raise ValueError(
f"Info.plist {key}={info.get(key)!r}, expected {expected!r}"
)
expected_manifest = {
"schema_version": "apc.runtime-manifest.v1",
"release_channel": "release",
"app_version": version,
"app_build": build,
"build_id": expected_build_id,
"petcore_build_id": expected_build_id,
"petcore_cli_build_id": expected_build_id,
}
for key, expected in expected_manifest.items():
if manifest.get(key) != expected:
raise ValueError(
f"runtime manifest {key}={manifest.get(key)!r}, expected {expected!r}"
)
if architecture not in ("arm64", "x86_64"):
raise ValueError(f"unsupported architecture {architecture!r}")
return expected_build_id
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--app", required=True, type=pathlib.Path)
parser.add_argument("--architecture", required=True, choices=("arm64", "x86_64"))
parser.add_argument("--version", required=True)
parser.add_argument("--build", required=True)
parser.add_argument("--commit", required=True)
arguments = parser.parse_args()
if not re.fullmatch(r"[0-9]+(?:[.][0-9]+){2}", arguments.version):
parser.error("--version must be a three-component semantic version")
if not re.fullmatch(r"[1-9][0-9]*", arguments.build):
parser.error("--build must be a positive integer")
if not re.fullmatch(r"[0-9a-f]{40}", arguments.commit):
parser.error("--commit must be a full lowercase Git commit")
if not arguments.app.is_dir() or arguments.app.is_symlink():
parser.error("--app must be a regular App bundle directory")
try:
build_id = validate(
arguments.app,
arguments.architecture,
arguments.version,
arguments.build,
arguments.commit,
)
except (OSError, ValueError, json.JSONDecodeError, plistlib.InvalidFileException) as error:
print(f"release identity validation failed: {error}", file=sys.stderr)
return 1
print(f"Release identity validation ok ({build_id}, {arguments.architecture})")
return 0
if __name__ == "__main__":
raise SystemExit(main())