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

192 lines
6.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate the macOS build host and the App's SDK/deployment contract."""
from __future__ import annotations
import argparse
import pathlib
import re
import subprocess
import sys
DEFAULT_MINIMUM_SWIFT = "6.2"
DEFAULT_MINIMUM_SDK = "26.0"
DEFAULT_DEPLOYMENT_TARGET = "14.0"
LIQUID_GLASS_SYMBOLS = (
"SwiftUI4ViewPAAE11glassEffect_2in",
"_OBJC_CLASS_$_NSGlassEffectView",
)
def parse_version(value: str, label: str) -> tuple[int, ...]:
match = re.fullmatch(r"([0-9]+(?:[.][0-9]+)*)", value.strip())
if match is None:
raise ValueError(f"{label} is not a dotted numeric version: {value!r}")
return tuple(int(component) for component in match.group(1).split("."))
def version_at_least(actual: tuple[int, ...], minimum: tuple[int, ...]) -> bool:
width = max(len(actual), len(minimum))
return actual + (0,) * (width - len(actual)) >= minimum + (0,) * (
width - len(minimum)
)
def parse_swift_version(output: str) -> tuple[int, ...]:
match = re.search(r"Apple Swift version ([0-9]+(?:[.][0-9]+)+)", output)
if match is None:
raise ValueError("swift --version did not report an Apple Swift version")
return parse_version(match.group(1), "Swift version")
def parse_macho_build_versions(output: str) -> list[tuple[str, str]]:
results: list[tuple[str, str]] = []
blocks = output.split("cmd LC_BUILD_VERSION")
for block in blocks[1:]:
platform = re.search(r"(?m)^\s*platform\s+([0-9]+)\s*$", block)
minos = re.search(r"(?m)^\s*minos\s+([^\s]+)\s*$", block)
sdk = re.search(r"(?m)^\s*sdk\s+([^\s]+)\s*$", block)
if platform is None or minos is None or sdk is None:
raise ValueError("Mach-O LC_BUILD_VERSION is incomplete")
if platform.group(1) != "1":
continue
results.append((minos.group(1), sdk.group(1)))
if not results:
raise ValueError("Mach-O has no macOS LC_BUILD_VERSION command")
return results
def validate_toolchain_outputs(
swift_output: str,
sdk_output: str,
minimum_swift: str,
minimum_sdk: str,
) -> tuple[str, str]:
swift = parse_swift_version(swift_output)
sdk_text = sdk_output.strip()
sdk = parse_version(sdk_text, "macOS SDK version")
required_swift = parse_version(minimum_swift, "minimum Swift version")
required_sdk = parse_version(minimum_sdk, "minimum macOS SDK version")
if not version_at_least(swift, required_swift):
raise ValueError(
f"Apple Swift {'.'.join(map(str, swift))} is older than required {minimum_swift}"
)
if not version_at_least(sdk, required_sdk):
raise ValueError(
f"macOS SDK {sdk_text} is older than required {minimum_sdk}"
)
return ".".join(map(str, swift)), sdk_text
def validate_artifact_outputs(
otool_output: str,
nm_output: str,
deployment_target: str,
minimum_sdk: str,
) -> list[tuple[str, str]]:
required_deployment = parse_version(
deployment_target, "required deployment target"
)
required_sdk = parse_version(minimum_sdk, "minimum macOS SDK version")
build_versions = parse_macho_build_versions(otool_output)
for minos_text, sdk_text in build_versions:
minos = parse_version(minos_text, "Mach-O deployment target")
sdk = parse_version(sdk_text, "Mach-O SDK version")
if minos != required_deployment:
raise ValueError(
f"Mach-O deployment target is {minos_text}, expected {deployment_target}"
)
if not version_at_least(sdk, required_sdk):
raise ValueError(
f"Mach-O SDK is {sdk_text}, older than required {minimum_sdk}"
)
weak_lines = [
line
for line in nm_output.splitlines()
if "(undefined) weak external" in line
]
for symbol in LIQUID_GLASS_SYMBOLS:
if not any(symbol in line for line in weak_lines):
raise ValueError(
f"Liquid Glass symbol is absent or not weak-linked: {symbol}"
)
return build_versions
def run(command: list[str]) -> str:
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
)
return completed.stdout
def validate_toolchain(arguments: argparse.Namespace) -> None:
swift_output = run(["swift", "--version"])
sdk_output = run(["xcrun", "--sdk", "macosx", "--show-sdk-version"])
swift, sdk = validate_toolchain_outputs(
swift_output,
sdk_output,
arguments.minimum_swift,
arguments.minimum_sdk,
)
print(
"macOS build toolchain contract ok: "
f"Apple Swift {swift}, macOS SDK {sdk}"
)
def validate_artifact(arguments: argparse.Namespace) -> None:
binary = arguments.binary
if binary.is_symlink() or not binary.is_file():
raise ValueError("--binary must be a regular file")
otool_output = run(["/usr/bin/otool", "-l", str(binary)])
nm_output = run(["/usr/bin/nm", "-m", "-u", str(binary)])
build_versions = validate_artifact_outputs(
otool_output,
nm_output,
arguments.deployment_target,
arguments.minimum_sdk,
)
summary = ", ".join(
f"minos {minos} / sdk {sdk}" for minos, sdk in build_versions
)
print(
"macOS App compatibility contract ok: "
f"{summary}; Liquid Glass symbols are weak-linked"
)
def main() -> int:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
toolchain = subparsers.add_parser("toolchain")
toolchain.add_argument("--minimum-swift", default=DEFAULT_MINIMUM_SWIFT)
toolchain.add_argument("--minimum-sdk", default=DEFAULT_MINIMUM_SDK)
toolchain.set_defaults(handler=validate_toolchain)
artifact = subparsers.add_parser("artifact")
artifact.add_argument("--binary", required=True, type=pathlib.Path)
artifact.add_argument(
"--deployment-target", default=DEFAULT_DEPLOYMENT_TARGET
)
artifact.add_argument("--minimum-sdk", default=DEFAULT_MINIMUM_SDK)
artifact.set_defaults(handler=validate_artifact)
arguments = parser.parse_args()
try:
arguments.handler(arguments)
except (OSError, subprocess.CalledProcessError, ValueError) as error:
print(f"macOS build contract validation failed: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())