#!/usr/bin/env bash # Move Plezy's native mpv dependency to an exact commit of the unified # mpv-build repository (github.com/edde746/mpv-build). # # Why this exists: the unified repo publishes content-addressed binaries on # every push to main -- each asset is named after a hash of the inputs that # produced it, so the artifacts for any commit stay downloadable forever and # are never overwritten. A commit, not a semver tag, is therefore the unit # Plezy pins: picking up an mpv or FFmpeg patch no longer requires cutting a # release upstream, and the pin names the exact bytes we ship on every # platform, not just Apple. # # Ten tracked files carry that pin and must move together: #

/Runner.xcodeproj/project.pbxproj the requirement #

/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/... the SwiftPM lock #

/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved Xcode's duplicate lock # for p in ios, macos, tvos, plus the root mpv-build.lock.json. The duplicate # SwiftPM locks are not redundant to us: scripts/checks/check_apple_spm_locks.py # fails the build when a pair drifts, and tvos/scripts/test_wire_mpv.rb asserts # all ten sites name one commit. # # mpv-build.lock.json is how the non-Apple platforms consume the same pin. # JSON cannot carry comments, so its schema lives here: # # { # "formatVersion": 1, # "repo": "edde746/mpv-build", the GitHub repo the commit lives in # "commit": "", must equal the nine Apple pin sites # "artifacts": { one entry per published non-apple group # "": { android | linux | windows # "key": "<12-hex>", the group's content-address key # "assetBase": "https://...", release download base for the assets # "assets": { one entry per platform variant # "": { e.g. an Android ABI # "asset": "", downloaded as / # "checksum": "" of the complete asset file # } # } # } # } # } # # The artifacts map is extracted from artifacts.json at the pinned commit; # groups that commit has not published yet are omitted, and each omission is # noted on stdout. Serialization is deterministic: two-space indent, sorted # keys, trailing newline. Consumers must fail when their group is missing # rather than fall back to an unpinned source. # # Usage: # scripts/set_native_revision.sh [--repo ] # # The commit's artifacts.json and versions.json are read from, in order: the # --repo override (a local checkout or an https URL), a local checkout at # ../mpv-build that contains the commit, else # https://raw.githubusercontent.com/edde746/mpv-build. A commit without those # manifests is refused: it predates the unified repo and cannot be pinned by # this script. # # The pin names a repository as well as a commit. The target repo is derived # from the source actually used: a GitHub --repo URL names it directly, a # local checkout is asked for its origin remote, and anything else falls back # to edde746/mpv-build. When the target differs from the repo the pin sites # currently carry (e.g. the first flip away from edde746/MPVKit), the same # all-or-nothing pass also rewrites each pbxproj repositoryURL and the # package-name comments Xcode derives from it, and each Package.resolved # location plus the identity SwiftPM derives from the URL's last path # component. # # Rerunning with the same sha is a no-op and says so. Edits to the Apple pin # sites are targeted text replacements on purpose: `plutil -convert` # round-trips would reformat an entire pbxproj, and re-serializing # Package.resolved would churn every unrelated pin. Each file must match # exactly once; anything else aborts before a byte is written -- the lock file # included. The locks name the new revision, but no local SwiftPM mirror # fetches it on its own: a macOS Flutter build resolves with # `-skipPackageUpdates` and aborts with "could not find the commit " # until the mirror catches up. Run scripts/refresh_apple_spm.sh afterwards (or # open the project in Xcode once). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$ROOT" usage() { echo "usage: scripts/set_native_revision.sh [--repo ]" >&2 exit 2 } REVISION="" REPO_ARG="" while [ "$#" -gt 0 ]; do case "$1" in --repo) [ "$#" -ge 2 ] || usage REPO_ARG="$2" shift 2 ;; --*) usage ;; *) [ -z "$REVISION" ] || usage REVISION="$1" shift ;; esac done [ -n "$REVISION" ] || usage if [[ ! "$REVISION" =~ ^[0-9a-f]{40}$ ]]; then echo "error: '$REVISION' is not a full 40-character lowercase commit sha" >&2 echo "hint: git -C ../mpv-build rev-parse HEAD" >&2 exit 2 fi # ---- locate the commit's manifests ---------------------------------------- MANIFEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/set-native-revision.XXXXXX")" trap 'rm -rf "$MANIFEST_DIR"' EXIT local_has_commit() { git -C "$1" rev-parse --git-dir >/dev/null 2>&1 \ && git -C "$1" cat-file -e "$REVISION^{commit}" 2>/dev/null } # Extract one manifest file from the resolved source into MANIFEST_DIR. # A missing file means the commit predates the unified repo; refuse it. fetch_manifest() { local name="$1" case "$SOURCE_KIND" in local) if ! git -C "$SOURCE" cat-file blob "$REVISION:$name" >"$MANIFEST_DIR/$name" 2>/dev/null; then echo "error: $REVISION has no $name in $SOURCE; not a unified mpv-build commit" >&2 exit 1 fi ;; remote) if ! curl -fsSL "$SOURCE/$REVISION/$name" -o "$MANIFEST_DIR/$name"; then echo "error: could not fetch $SOURCE/$REVISION/$name; not a unified mpv-build commit?" >&2 exit 1 fi ;; esac } # GitHub repo URLs serve files through raw.githubusercontent.com; anything # else is taken as a base already serving //. remote_base() { local url="${1%/}" url="${url%.git}" case "$url" in https://github.com/*) echo "https://raw.githubusercontent.com/${url#https://github.com/}" ;; *) echo "$url" ;; esac } # owner/name from a GitHub remote URL (https, ssh, or raw form); empty when # the URL does not name a GitHub repo. github_slug() { local url="${1%/}" url="${url%.git}" case "$url" in https://github.com/*/*) url="${url#https://github.com/}" ;; https://raw.githubusercontent.com/*/*) url="${url#https://raw.githubusercontent.com/}" ;; ssh://git@github.com/*/*) url="${url#ssh://git@github.com/}" ;; git@github.com:*/*) url="${url#git@github.com:}" ;; *) return 0 ;; esac local owner="${url%%/*}" rest="${url#*/}" echo "$owner/${rest%%/*}" } SOURCE_KIND="" SOURCE="" if [ -n "$REPO_ARG" ]; then case "$REPO_ARG" in http://*|https://*) SOURCE_KIND="remote" SOURCE="$(remote_base "$REPO_ARG")" ;; *) if [ ! -d "$REPO_ARG" ]; then echo "error: --repo '$REPO_ARG' is neither a directory nor an https URL" >&2 exit 2 fi if ! local_has_commit "$REPO_ARG"; then echo "error: $REPO_ARG does not contain commit $REVISION" >&2 echo "hint: git -C $REPO_ARG fetch" >&2 exit 1 fi SOURCE_KIND="local" SOURCE="$REPO_ARG" ;; esac else if [ -d ../mpv-build ] && local_has_commit ../mpv-build; then SOURCE_KIND="local" SOURCE="../mpv-build" else SOURCE_KIND="remote" SOURCE="https://raw.githubusercontent.com/edde746/mpv-build" fi fi # The GitHub repo the pins will name, derived from the actual source used. CANONICAL_REPO="edde746/mpv-build" TARGET_REPO="" case "$SOURCE_KIND" in local) TARGET_REPO="$(github_slug "$(git -C "$SOURCE" remote get-url origin 2>/dev/null || true)")" ;; remote) TARGET_REPO="$(github_slug "$SOURCE")" ;; esac [ -n "$TARGET_REPO" ] || TARGET_REPO="$CANONICAL_REPO" echo "reading manifests for ${REVISION:0:12} from $SOURCE" fetch_manifest artifacts.json fetch_manifest versions.json # ---- collect the Apple pin sites ------------------------------------------ PROJECTS=() LOCKS=() MISSING=() for platform in ios macos tvos; do PROJECTS+=("$platform/Runner.xcodeproj/project.pbxproj") LOCKS+=("$platform/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved") LOCKS+=("$platform/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved") done for target in "${PROJECTS[@]}" "${LOCKS[@]}"; do if [ ! -f "$target" ]; then MISSING+=("$target") fi done if [ "${#MISSING[@]}" -gt 0 ]; then echo "error: missing native pin site(s); refusing to run:" >&2 printf ' %s\n' "${MISSING[@]}" >&2 exit 1 fi MPV_BUILD_REVISION="$REVISION" MPV_BUILD_MANIFEST_DIR="$MANIFEST_DIR" MPV_BUILD_REPO="$TARGET_REPO" \ python3 - "${#PROJECTS[@]}" "${PROJECTS[@]}" "${LOCKS[@]}" <<'PY' """Rewrite the native requirement, SwiftPM pins, and mpv-build.lock.json. Every file is parsed and rewritten in memory first; nothing is written unless all nine Apple edits are unambiguous and the lock can be derived completely, so a malformed file can never leave the pin half-moved. When the target repo differs from the one the pin sites carry, the pbxproj repositoryURL and its package-name comments, the Package.resolved location, and the identity SwiftPM derives from the URL move in the same pass. """ from __future__ import annotations import json import os import re import sys from pathlib import Path revision = os.environ["MPV_BUILD_REVISION"] manifest_dir = Path(os.environ["MPV_BUILD_MANIFEST_DIR"]) project_count = int(sys.argv[1]) projects = sys.argv[2 : 2 + project_count] locks = sys.argv[2 + project_count :] LOCK_PATH = "mpv-build.lock.json" LOCK_REPO = os.environ["MPV_BUILD_REPO"] TARGET_URL = f"https://github.com/{LOCK_REPO}" # Xcode names the package after the URL's last path component; SwiftPM # lowercases that same component into the pin identity. TARGET_NAME = LOCK_REPO.rsplit("/", 1)[-1] TARGET_IDENTITY = TARGET_NAME.lower() LEGACY_IDENTITY = "mpvkit" # the pre-flip edde746/MPVKit pin ACCEPTED_IDENTITIES = {LEGACY_IDENTITY, TARGET_IDENTITY} EXPECTED_GROUPS = ("android", "linux", "windows") KEY_RE = re.compile(r"\A[0-9a-f]{12}\Z") CHECKSUM_RE = re.compile(r"\A[0-9a-f]{64}\Z") def url_identity(url: str) -> str: """The identity SwiftPM derives from a repository URL.""" tail = url.rstrip("/") if tail.endswith(".git"): tail = tail[:-4] return tail.rsplit("/", 1)[-1].lower() # The `*/ = {` suffix is what makes this anchor unique: the same comment appears # in packageReferences lists and product dependencies, but only the object # definition opens a brace. The package name is not anchored -- it tracks the # repo and moves with it -- so matches are filtered by the URL's identity. PACKAGE_REFERENCE = re.compile( r'/\* XCRemoteSwiftPackageReference "(?P[^"]+)" \*/ = \{\n' r"(?:[^\n]*\n)*?" r'[\t ]*repositoryURL = "(?P[^"]+)";\n' r"(?:[^\n]*\n)*?" r"(?P[\t ]*)requirement = \{\n" r"(?P(?:[^\n]*\n)*?)" r"(?P=indent)\};\n" ) PIN = re.compile( r'"identity"[ \t]*:[ \t]*"(?P[^"]+)",\n' r"(?:[^\n]*\n)*?" r'[ \t]*"location"[ \t]*:[ \t]*"(?P[^"]+)",\n' r"(?:[^\n]*\n)*?" r'(?P[ \t]*)"state"[ \t]*:[ \t]*\{\n' r"(?P(?:[^\n]*\n)*?)" r"(?P=indent)\}" ) errors: list[str] = [] notes: list[str] = [] writes: list[tuple[str, str]] = [] unchanged: list[str] = [] def sole_pin(pattern: re.Pattern[str], text: str, path: str) -> re.Match[str] | None: """The one match that is the native pin, judged by its URL's identity.""" matches = [ match for match in pattern.finditer(text) if url_identity(match.group("url")) in ACCEPTED_IDENTITIES ] if len(matches) != 1: errors.append(f"{path}: expected exactly 1 native pin, found {len(matches)}") return None return matches[0] def splice(text: str, match: re.Match[str], replacements: list[tuple[str, str]]) -> str: """text with each named group's matched span replaced by new content.""" parts = [] cursor = 0 for group, new in sorted(replacements, key=lambda item: match.start(item[0])): start, end = match.span(group) parts.append(text[cursor:start]) parts.append(new) cursor = end parts.append(text[cursor:]) return "".join(parts) def check_lock_schema(path: str, text: str) -> bool: try: payload = json.loads(text) except json.JSONDecodeError as error: errors.append(f"{path}: not valid JSON ({error})") return False version = payload.get("version") if isinstance(payload, dict) else None if version not in (2, 3): errors.append(f"{path}: unsupported SwiftPM lock schema version {version!r}") return False pins = payload.get("pins") if not isinstance(pins, list): errors.append(f"{path}: missing pins array") return False pin = next( ( item for item in pins if isinstance(item, dict) and item.get("identity") in ACCEPTED_IDENTITIES ), None, ) if pin is None: errors.append(f"{path}: no native pin") return False if pin.get("kind") != "remoteSourceControl": errors.append(f"{path}: native pin is {pin.get('kind')!r}, expected remoteSourceControl") return False return True def load_manifest(name: str, label: str) -> dict | None: try: payload = json.loads((manifest_dir / name).read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: errors.append(f"{label} at {revision[:12]}: not valid JSON ({error})") return None if not isinstance(payload, dict): errors.append(f"{label} at {revision[:12]}: expected a JSON object") return None return payload def build_lock() -> str | None: """The lock file's content for this commit, or None with errors recorded. Schema documented in this script's header comment. The versions.json read is a guard, not a data source: a commit without formatVersion 1 predates the unified repo, and pinning it would name artifacts that do not exist. """ versions = load_manifest("versions.json", "versions.json") manifest = load_manifest("artifacts.json", "artifacts.json") if versions is None or manifest is None: return None if versions.get("formatVersion") != 1: errors.append( f"versions.json at {revision[:12]}: formatVersion is " f"{versions.get('formatVersion')!r}, expected 1" ) return None if manifest.get("schema") != 2: errors.append( f"artifacts.json at {revision[:12]}: schema is " f"{manifest.get('schema')!r}, expected 2" ) return None platforms = manifest.get("platforms") if not isinstance(platforms, dict): errors.append(f"artifacts.json at {revision[:12]}: no platforms object") return None artifacts: dict[str, dict] = {} for group in sorted(platforms): if group == "apple": continue # Apple consumes the SwiftPM pin sites, not the lock. section = platforms[group] label = f"artifacts.json platforms.{group}" if not isinstance(section, dict): errors.append(f"{label}: expected an object") continue asset_base = section.get("assetBase") if not isinstance(asset_base, str) or not asset_base: errors.append(f"{label}.assetBase: missing") continue libraries = section.get("libraries") if not isinstance(libraries, dict): errors.append(f"{label}.libraries: expected an object") continue if not libraries: notes.append( f"note: {group} has published no libraries at this commit; " f"omitted from {LOCK_PATH}" ) continue if len(libraries) != 1: errors.append( f"{label}: {LOCK_PATH} formatVersion 1 records exactly one " f"library per group, found {sorted(libraries)}; bump the lock format" ) continue ((library, entry),) = libraries.items() label = f"{label}.libraries.{library}" if not isinstance(entry, dict): errors.append(f"{label}: expected an object") continue key = entry.get("key") if not isinstance(key, str) or not KEY_RE.fullmatch(key): errors.append(f"{label}.key: {key!r} is not a 12-hex content key") continue prebuilt = entry.get("prebuilt") if not isinstance(prebuilt, dict) or not prebuilt: errors.append(f"{label}.prebuilt: no per-variant assets recorded") continue assets: dict[str, dict[str, str]] = {} for variant in sorted(prebuilt): value = prebuilt[variant] if not isinstance(value, dict): errors.append( f"{label}.prebuilt.{variant}: {value!r} carries no checksum; " f"refusing an unverifiable pin" ) continue asset = value.get("asset") checksum = value.get("checksum") if not isinstance(asset, str) or not asset: errors.append(f"{label}.prebuilt.{variant}.asset: missing") continue if not isinstance(checksum, str) or not CHECKSUM_RE.fullmatch(checksum): errors.append( f"{label}.prebuilt.{variant}.checksum: {checksum!r} is not a " f"full lowercase SHA-256" ) continue assets[variant] = {"asset": asset, "checksum": checksum} if len(assets) != len(prebuilt): continue artifacts[group] = {"assetBase": asset_base, "assets": assets, "key": key} for group in EXPECTED_GROUPS: if group not in artifacts and group not in platforms: notes.append( f"note: no {group} artifacts published at this commit; " f"omitted from {LOCK_PATH}" ) lock = { "artifacts": artifacts, "commit": revision, "formatVersion": 1, "repo": LOCK_REPO, } return json.dumps(lock, indent=2, sort_keys=True) + "\n" for path in projects: with open(path, encoding="utf-8") as handle: original = handle.read() match = sole_pin(PACKAGE_REFERENCE, original, path) if match is None: continue indent = match.group("indent") updated = splice( original, match, [ ("url", TARGET_URL), ("body", f"{indent}\tkind = revision;\n{indent}\trevision = {revision};\n"), ], ) # Xcode derives the `XCRemoteSwiftPackageReference ""` comments from # the URL; every mention of the old name refers to this one reference, so # renaming them all keeps the file exactly as Xcode would regenerate it. old_name = match.group("name") if old_name != TARGET_NAME: updated = updated.replace( f'/* XCRemoteSwiftPackageReference "{old_name}" */', f'/* XCRemoteSwiftPackageReference "{TARGET_NAME}" */', ) if updated == original: unchanged.append(path) else: writes.append((path, updated)) for path in locks: with open(path, encoding="utf-8") as handle: original = handle.read() if not check_lock_schema(path, original): continue match = sole_pin(PIN, original, path) if match is None: continue indent = match.group("indent") updated = splice( original, match, [ ("identity", TARGET_IDENTITY), ("url", TARGET_URL), ("body", f'{indent} "revision" : "{revision}"\n'), ], ) if updated == original: unchanged.append(path) else: writes.append((path, updated)) lock_content = build_lock() if lock_content is not None: try: existing_lock = Path(LOCK_PATH).read_text(encoding="utf-8") except OSError: existing_lock = None if lock_content == existing_lock: unchanged.append(LOCK_PATH) else: writes.append((LOCK_PATH, lock_content)) if errors: for error in errors: print(f"error: {error}", file=sys.stderr) sys.exit(1) for path, content in writes: with open(path, "w", encoding="utf-8") as handle: handle.write(content) print(f" updated {path}") for path in unchanged: print(f" unchanged {path}") for note in notes: print(note) short = f"{LOCK_REPO}@{revision[:12]}" if writes: print(f"\nNative pin moved to {short} ({len(writes)} file(s) rewritten, {len(unchanged)} already correct).") else: print(f"\nNative pin was already at {short}; nothing to do.") PY