build: make toolchain inputs reproducible
This commit is contained in:
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate duplicate Apple SwiftPM locks against resolved Flutter plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
LOCK_PAIRS = {
|
||||
"iOS": (
|
||||
Path("ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||
Path("ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||
),
|
||||
"macOS": (
|
||||
Path("macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||
Path("macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved"),
|
||||
),
|
||||
}
|
||||
SENTRY_IDENTITY = "sentry-cocoa"
|
||||
EXACT_REQUIREMENT = re.compile(
|
||||
r"\.package\s*\(\s*url\s*:\s*[\"'][^\"']*sentry-cocoa(?:\.git)?[\"']\s*,\s*exact\s*:\s*[\"']([^\"']+)[\"']",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _load_pin_map(path: Path, errors: list[str]) -> dict[str, dict]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
errors.append(f"{path}: cannot read version-2 SwiftPM lock: {error}")
|
||||
return {}
|
||||
if not isinstance(payload, dict) or payload.get("version") != 2 or not isinstance(payload.get("pins"), list):
|
||||
errors.append(f"{path}: expected a version-2 SwiftPM lock with a pins array")
|
||||
return {}
|
||||
|
||||
pins: dict[str, dict] = {}
|
||||
for pin in payload["pins"]:
|
||||
identity = pin.get("identity") if isinstance(pin, dict) else None
|
||||
if not isinstance(identity, str):
|
||||
errors.append(f"{path}: pin without a string identity")
|
||||
continue
|
||||
canonical = {
|
||||
"kind": pin.get("kind"),
|
||||
"location": pin.get("location"),
|
||||
"state": pin.get("state"),
|
||||
}
|
||||
if identity in pins:
|
||||
errors.append(f"{path}: duplicate pin identity {identity}")
|
||||
pins[identity] = canonical
|
||||
return pins
|
||||
|
||||
|
||||
def _resolved_sentry_root(root: Path, errors: list[str]) -> Path | None:
|
||||
config_path = root / ".dart_tool/package_config.json"
|
||||
try:
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
errors.append(f"{config_path}: unavailable ({error}); run flutter pub get first")
|
||||
return None
|
||||
packages = config.get("packages") if isinstance(config, dict) else None
|
||||
if not isinstance(packages, list):
|
||||
errors.append(f"{config_path}: missing packages list; run flutter pub get first")
|
||||
return None
|
||||
package = next(
|
||||
(item for item in packages if isinstance(item, dict) and item.get("name") == "sentry_flutter"),
|
||||
None,
|
||||
)
|
||||
if package is None or not isinstance(package.get("rootUri"), str):
|
||||
errors.append(f"{config_path}: sentry_flutter is not resolved; run flutter pub get first")
|
||||
return None
|
||||
|
||||
uri = package["rootUri"]
|
||||
parsed = urlparse(uri)
|
||||
if parsed.scheme == "file":
|
||||
return Path(unquote(parsed.path))
|
||||
if parsed.scheme:
|
||||
errors.append(f"{config_path}: unsupported sentry_flutter root URI {uri!r}")
|
||||
return None
|
||||
return (config_path.parent / unquote(uri)).resolve()
|
||||
|
||||
|
||||
def _manifest_requirement(path: Path, errors: list[str]) -> str | None:
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
errors.append(f"{path}: unavailable ({error}); run flutter pub get first")
|
||||
return None
|
||||
versions = EXACT_REQUIREMENT.findall(source)
|
||||
if len(versions) != 1:
|
||||
errors.append(f"{path}: expected exactly one exact sentry-cocoa requirement, found {len(versions)}")
|
||||
return None
|
||||
return versions[0]
|
||||
|
||||
|
||||
def validate(root: Path) -> list[str]:
|
||||
root = root.resolve()
|
||||
errors: list[str] = []
|
||||
lock_maps: dict[Path, dict[str, dict]] = {}
|
||||
for platform, relative_paths in LOCK_PAIRS.items():
|
||||
first_path, second_path = (root / path for path in relative_paths)
|
||||
first = _load_pin_map(first_path, errors)
|
||||
second = _load_pin_map(second_path, errors)
|
||||
lock_maps[first_path] = first
|
||||
lock_maps[second_path] = second
|
||||
if first != second:
|
||||
identities = sorted(set(first) | set(second))
|
||||
differing = [identity for identity in identities if first.get(identity) != second.get(identity)]
|
||||
errors.append(
|
||||
f"{platform} SwiftPM locks differ between {first_path} and {second_path}: "
|
||||
+ ", ".join(differing)
|
||||
)
|
||||
|
||||
sentry_root = _resolved_sentry_root(root, errors)
|
||||
required_versions: list[str] = []
|
||||
if sentry_root is not None:
|
||||
for platform in ("ios", "macos"):
|
||||
version = _manifest_requirement(sentry_root / platform / "sentry_flutter/Package.swift", errors)
|
||||
if version is not None:
|
||||
required_versions.append(version)
|
||||
if len(set(required_versions)) > 1:
|
||||
errors.append("iOS and macOS sentry_flutter manifests require different sentry-cocoa versions")
|
||||
|
||||
required_version = required_versions[0] if required_versions and len(set(required_versions)) == 1 else None
|
||||
sentry_pins: list[tuple[Path, dict]] = []
|
||||
for path, pins in lock_maps.items():
|
||||
pin = pins.get(SENTRY_IDENTITY)
|
||||
if pin is None:
|
||||
errors.append(f"{path}: missing {SENTRY_IDENTITY} pin")
|
||||
continue
|
||||
sentry_pins.append((path, pin))
|
||||
state = pin.get("state")
|
||||
version = state.get("version") if isinstance(state, dict) else None
|
||||
if required_version is not None and version != required_version:
|
||||
errors.append(f"{path}: {SENTRY_IDENTITY} is {version!r}, manifest requires exactly {required_version}")
|
||||
|
||||
if sentry_pins:
|
||||
canonical_path, canonical_pin = sentry_pins[0]
|
||||
for path, pin in sentry_pins[1:]:
|
||||
if pin != canonical_pin:
|
||||
errors.append(f"{path}: {SENTRY_IDENTITY} state differs from {canonical_path}")
|
||||
return errors
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
args = parser.parse_args(argv)
|
||||
errors = validate(args.root)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"error: {error}")
|
||||
return 1
|
||||
print("Apple SwiftPM locks match each other and the resolved Sentry manifests.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user