diff --git a/.github/workflows/auto_upstream_release.yml b/.github/workflows/auto_upstream_release.yml index f2da0d1..51fa135 100644 --- a/.github/workflows/auto_upstream_release.yml +++ b/.github/workflows/auto_upstream_release.yml @@ -84,7 +84,7 @@ jobs: fi - name: Dispatch native release - if: steps.existing.outputs.exists == 'false' || steps.complete.outputs.complete == 'false' + if: steps.existing.outputs.exists == 'false' env: GH_TOKEN: ${{ secrets.LITERT_LM_RELEASE_TOKEN || github.token }} run: | @@ -92,6 +92,12 @@ jobs: --repo "${{ github.repository }}" \ -f upstream_tag="${{ steps.upstream.outputs.tag }}" + - name: Existing release is incomplete + if: steps.existing.outputs.exists == 'true' && steps.complete.outputs.complete == 'false' + run: | + echo "Release ${{ steps.upstream.outputs.tag }} exists but does not match current validation." + echo "Skipping automatic rebuild to avoid mutating an existing release tag." + - name: No-op when release is complete if: steps.existing.outputs.exists == 'true' && steps.complete.outputs.complete == 'true' run: echo "Release ${{ steps.upstream.outputs.tag }} already exists and is complete." diff --git a/.github/workflows/native_release.yml b/.github/workflows/native_release.yml index c5a2d1b..57741a4 100644 --- a/.github/workflows/native_release.yml +++ b/.github/workflows/native_release.yml @@ -7,6 +7,11 @@ on: description: "google-ai-edge/LiteRT-LM tag to package" required: true type: string + release_tag: + description: "Native release tag to publish; defaults to upstream_tag" + required: false + default: "" + type: string prerelease: description: "Publish as a prerelease" required: false @@ -136,6 +141,16 @@ jobs: git fetch --depth=1 origin "${{ github.sha }}" git checkout --detach FETCH_HEAD + - name: Resolve native release tag + id: release + shell: bash + run: | + release_tag="${{ inputs.release_tag }}" + if [ -z "$release_tag" ]; then + release_tag="${{ inputs.upstream_tag }}" + fi + echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" + - name: Package upstream prebuilt libraries run: | python3 tools/package_upstream_prebuilts.py \ @@ -169,36 +184,44 @@ jobs: --upstream-tag "${{ inputs.upstream_tag }}" \ --clean + - name: Package official macOS runtime + run: | + python3 tools/package_macos_runtime.py \ + --upstream-tag "${{ inputs.upstream_tag }}" \ + --clean + - name: Package Apple SPM XCFrameworks run: | python3 tools/package_apple_xcframeworks.py \ - --upstream-tag "${{ inputs.upstream_tag }}" \ + --release-tag "${{ steps.release.outputs.tag }}" \ --clean - name: Generate manifest and checksums run: | python3 tools/validate_runtime_artifacts.py --upstream-tag "${{ inputs.upstream_tag }}" python3 tools/validate_runtime_dependencies.py - python3 tools/package_release.py --upstream-tag "${{ inputs.upstream_tag }}" + python3 tools/package_release.py \ + --upstream-tag "${{ inputs.upstream_tag }}" \ + --release-tag "${{ steps.release.outputs.tag }}" python3 tools/validate_artifacts.py - name: Create release archives run: | mkdir -p release - tar -czf "release/litert-lm-native-prebuilts-${{ inputs.upstream_tag }}.tar.gz" \ + tar -czf "release/litert-lm-native-prebuilts-${{ steps.release.outputs.tag }}.tar.gz" \ bin manifest.json SHA256SUMS while IFS= read -r runtime_dir; do platform="$(basename "$(dirname "$runtime_dir")")" arch="$(basename "$runtime_dir")" - tar -czf "release/litert-lm-native-runtime-${platform}-${arch}-${{ inputs.upstream_tag }}.tar.gz" \ + tar -czf "release/litert-lm-native-runtime-${platform}-${arch}-${{ steps.release.outputs.tag }}.tar.gz" \ -C bin "${platform}/${arch}" done < <(find bin -mindepth 2 -maxdepth 2 -type d | sort) if [ -d dist ]; then - tar -czf "release/litert-lm-native-official-assets-${{ inputs.upstream_tag }}.tar.gz" \ + tar -czf "release/litert-lm-native-official-assets-${{ steps.release.outputs.tag }}.tar.gz" \ dist fi - if [ -d "dist/spm/${{ inputs.upstream_tag }}" ]; then - cp dist/spm/${{ inputs.upstream_tag }}/*.zip release/ + if [ -d "dist/spm/${{ steps.release.outputs.tag }}" ]; then + cp dist/spm/${{ steps.release.outputs.tag }}/*.zip release/ fi - name: Publish GitHub release @@ -209,24 +232,41 @@ jobs: if [ "${{ inputs.prerelease }}" = "true" ]; then args+=(--prerelease) fi - notes="Packaged LiteRT-LM upstream runtime libraries, upstream prebuilts, Apple SPM XCFrameworks, and official release assets for ${{ inputs.upstream_tag }}." - if gh release view "${{ inputs.upstream_tag }}" \ + notes="Packaged LiteRT-LM upstream ${{ inputs.upstream_tag }} runtime libraries, upstream prebuilts, Apple SPM XCFrameworks, and official release assets as ${{ steps.release.outputs.tag }}." + if gh release view "${{ steps.release.outputs.tag }}" \ --repo "${{ github.repository }}" >/dev/null 2>&1; then - gh release edit "${{ inputs.upstream_tag }}" \ + gh release edit "${{ steps.release.outputs.tag }}" \ --repo "${{ github.repository }}" \ - --title "LiteRT-LM ${{ inputs.upstream_tag }}" \ + --title "LiteRT-LM ${{ steps.release.outputs.tag }}" \ --notes "$notes" \ "${args[@]}" - gh release upload "${{ inputs.upstream_tag }}" \ + intended_assets="$(mktemp)" + { + find release -maxdepth 1 -type f -exec basename {} \; + printf '%s\n' manifest.json SHA256SUMS + } | sort > "$intended_assets" + while IFS= read -r asset; do + if ! grep -Fxq "$asset" "$intended_assets"; then + gh release delete-asset "${{ steps.release.outputs.tag }}" "$asset" \ + --repo "${{ github.repository }}" \ + --yes + fi + done < <( + gh release view "${{ steps.release.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --json assets \ + --jq '.assets[].name' + ) + gh release upload "${{ steps.release.outputs.tag }}" \ --repo "${{ github.repository }}" \ --clobber \ release/* \ manifest.json \ SHA256SUMS else - gh release create "${{ inputs.upstream_tag }}" \ + gh release create "${{ steps.release.outputs.tag }}" \ --repo "${{ github.repository }}" \ - --title "LiteRT-LM ${{ inputs.upstream_tag }}" \ + --title "LiteRT-LM ${{ steps.release.outputs.tag }}" \ --notes "$notes" \ "${args[@]}" \ release/* \ @@ -244,7 +284,7 @@ jobs: -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ inputs.upstream_tag }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ steps.release.outputs.tag }}" \ -o published-release-check/release.json manifest_url="$(python3 - <<'PY' import json @@ -260,4 +300,5 @@ jobs: python3 tools/validate_release_manifest.py \ published-release-check/manifest.json \ --upstream-tag "${{ inputs.upstream_tag }}" \ + --release-tag "${{ steps.release.outputs.tag }}" \ --release-metadata published-release-check/release.json diff --git a/README.md b/README.md index 7244420..99f5948 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,11 @@ GPU/NPU validation; web should use JavaScript interop instead of FFI. `CLiteRTLM.xcframework` slices and stages `LiteRtLm.framework` plus `CLiteRTLM.framework`, with the wrapper embedding LiteRtLmBridge symbols and re-exporting `CLiteRTLM`. -- `tools/package_apple_xcframeworks.py`: packages iOS framework wrappers, a - macOS `LiteRtLm.framework` wrapper around the source-built runtime, and macOS - companion dylibs as SPM-compatible XCFramework zip assets. +- `tools/package_macos_runtime.py`: extracts official upstream + `CLiteRTLM_mac.xcframework` slices and stages bridge-enabled + `libLiteRtLm.dylib` wrappers that re-export `libCLiteRTLM_mac.dylib`. +- `tools/package_apple_xcframeworks.py`: packages iOS framework wrappers and + macOS bridge wrappers as SPM-compatible XCFramework zip assets. - `tools/package_release.py`: builds local manifest and checksums. - `tools/validate_artifacts.py`: validates manifest, checksums, and layout. - `docs/platform_strategy.md`: platform and distribution strategy. @@ -99,27 +101,51 @@ python3 tools/validate_artifacts.py Android arm64/x64, macOS arm64/x64, Linux x64/arm64, and Windows x64, copies upstream `prebuilt/` companion libraries for Android, Apple, Linux, and Windows, converts official upstream `CLiteRTLM.xcframework` slices into iOS - framework runtime archives with an embedded LiteRtLmBridge wrapper, packages - Apple SPM XCFramework zips from the same runtime payloads, includes the - official upstream release assets, then publishes a GitHub release with - `manifest.json` and `SHA256SUMS`. + framework runtime archives with an embedded LiteRtLmBridge wrapper, converts + official upstream `CLiteRTLM_mac.xcframework` slices into macOS runtime + archives with an embedded LiteRtLmBridge wrapper, packages Apple SPM + XCFramework zips from the same runtime payloads, includes the official + upstream release assets, then publishes a GitHub release with `manifest.json` + and `SHA256SUMS`. The workflow accepts a separate `release_tag`; use it when + repackaging the same upstream tag without mutating an existing native release. - `Auto Upstream Release`: runs daily and dispatches `Native Build & Release` when `google-ai-edge/LiteRT-LM` has a latest release tag that this repo has - not published yet. + not published yet. Existing releases are treated as immutable; if validation + rules change and an existing release no longer matches, the scheduled workflow + reports it but does not overwrite the tag automatically. ## Native Version Management -The published upstream tag is the native version contract consumed by downstream -package hooks and Swift Package manifests. When moving to a new LiteRT-LM tag: +The published native release tag is the version contract consumed by downstream +package hooks and Swift Package manifests. For the first package of an upstream +LiteRT-LM tag, the native release tag normally matches the upstream tag. If a +packaging fix is needed for the same upstream sources, publish a new native +release tag such as `v0.13.1-native.1` instead of overwriting `v0.13.1`. -1. Run `Native Build & Release` for `upstream_tag`, or let - `Auto Upstream Release` dispatch it for the latest upstream release. +When moving to a new LiteRT-LM tag: + +1. Run `Native Build & Release` for `upstream_tag`, or let `Auto Upstream + Release` dispatch it for the latest upstream release. 2. Verify the release contains runtime archives, official upstream assets, Apple SPM XCFramework zips, `manifest.json`, and `SHA256SUMS`. 3. Update downstream `llamadart` hook pins, SPM URLs, and SPM checksums together so native-assets and SPM consumers use the same bridge-enabled runtime build. +To publish a corrected package for existing upstream sources without breaking +downstream checksum pins, dispatch the workflow with both tags: + +```bash +gh workflow run native_release.yml \ + --repo leehack/litert-lm-native \ + --ref main \ + -f upstream_tag=v0.13.1 \ + -f release_tag=v0.13.1-native.1 \ + -f prerelease=false \ + -f target_platform=all \ + -f target_arch=all +``` + The release workflow uses upstream's public C API (`c/engine.h`) as the production FFI boundary. Downstream loaders should bind directly to the runtime library for the selected platform. Source-built native runtimes are assembled @@ -133,13 +159,11 @@ asynchronous callback loaders. Apple SPM consumers should depend on the release's direct `litert-lm-native-apple-*-xcframework-.zip` assets. The `LiteRtLm` XCFramework contains the primary iOS wrapper and a macOS framework wrapper -around the source-built runtime. `CLiteRTLM` is iOS-only and is re-exported by -the iOS wrapper. macOS companion dylibs are published as separate XCFramework -targets when the native release payload contains them. Downstream macOS SPM -integration must account for architecture coverage and deployment targets; for -example, upstream `v0.13.1` provides extra macOS companion dylibs for arm64, -while x64 only requires `libLiteRtLm.dylib` plus `libLiteRt.dylib`, and the -macOS dylibs are built for macOS 14. +around the official `CLiteRTLM_mac` runtime. `CLiteRTLM` is re-exported by the +iOS wrapper, and `CLiteRTLMMac` is re-exported by the macOS wrapper. Downstream +macOS SPM integration must account for architecture coverage and deployment +targets; upstream `v0.13.1` provides a universal `CLiteRTLM_mac.xcframework`, +and the packaged macOS wrapper targets macOS 14. ## Consumer Contract diff --git a/docs/platform_strategy.md b/docs/platform_strategy.md index be5f8b6..98e5dcc 100644 --- a/docs/platform_strategy.md +++ b/docs/platform_strategy.md @@ -17,9 +17,16 @@ The release automation publishes these runtime artifact groups: - iOS framework-style runtime wrappers derived from `CLiteRTLM.xcframework`, with LiteRtLmBridge symbols embedded in `LiteRtLm.framework/LiteRtLm` and upstream symbols re-exported from `CLiteRTLM.framework/CLiteRTLM` +- macOS dylib runtime wrappers derived from `CLiteRTLM_mac.xcframework`, with + LiteRtLmBridge symbols embedded in `libLiteRtLm.dylib` and upstream symbols + re-exported from `libCLiteRTLM_mac.dylib` - Apple Swift Package Manager XCFramework zips produced from the same iOS - wrappers, macOS source-built runtime, and macOS companion dylibs used by the - native-assets payloads + wrappers and official macOS wrappers used by the native-assets payloads + +Native release tags are immutable consumer contracts. Use a separate native +release tag when repackaging the same upstream source tag, for example +`upstream_tag=v0.13.1` with `release_tag=v0.13.1-native.1`, so downstream +packages with pinned checksums keep resolving the original artifacts. The upstream C runtime is the production FFI target for downstream packages. LiteRtLmBridge is limited to narrow FFI helpers around that runtime surface. It @@ -29,17 +36,17 @@ build the repo-owned bridge package alongside upstream LiteRT-LM, without patching upstream source files. SPM artifacts are intentionally split by binary target. `LiteRtLm` carries the -primary iOS runtime/wrapper and a macOS framework wrapper around the -source-built runtime. `CLiteRTLM` is published for iOS re-export support. macOS -companion dylibs are published as separate XCFramework targets when the native -release payload contains them. +primary iOS runtime/wrapper and a macOS framework wrapper around the official +macOS runtime. `CLiteRTLM` is published for iOS re-export support, and +`CLiteRTLMMac` is published for macOS re-export support. The macOS LiteRT-LM SPM path must account for the architecture coverage of the -native payload. Upstream `v0.13.1` publishes arm64 macOS companion dylibs, while -the x64 source-built runtime links only to `libLiteRt.dylib`; those macOS dylibs -are built for macOS 14. Keep native-assets runtime archives as the source of -truth, and only wire macOS SPM dependencies in downstream packages when the -required binary targets cover the selected architecture and deployment target. +native payload. Upstream `v0.13.1` publishes a universal +`CLiteRTLM_mac.xcframework`; the packaged macOS wrapper is universal across +arm64 and x64 and targets macOS 14. Keep native-assets runtime archives as the +source of truth, and only wire macOS SPM dependencies in downstream packages +when the required binary targets cover the selected architecture and deployment +target. Initial native targets: @@ -75,6 +82,7 @@ Each artifact entry records: - runtime: `native` or `web` - platform and architecture +- native release tag - upstream LiteRT-LM tag - file name and SHA-256 - library names required by loaders diff --git a/tools/package_apple_xcframeworks.py b/tools/package_apple_xcframeworks.py index 385fc44..2e371a2 100644 --- a/tools/package_apple_xcframeworks.py +++ b/tools/package_apple_xcframeworks.py @@ -306,8 +306,8 @@ def package_macos_companions( return packaged -def package_all(upstream_tag: str, clean: bool) -> list[Path]: - output_dir = DIST_DIR / upstream_tag +def package_all(release_tag: str, clean: bool) -> list[Path]: + output_dir = DIST_DIR / release_tag if clean and WORK_DIR.exists(): shutil.rmtree(WORK_DIR) if clean and output_dir.exists(): @@ -325,7 +325,7 @@ def package_all(upstream_tag: str, clean: bool) -> list[Path]: PRIMARY_MODULE, WORK_DIR, output_dir, - upstream_tag, + release_tag, extra_args=primary_macos_args, ) if primary is None and primary_macos_args: @@ -334,7 +334,7 @@ def package_all(upstream_tag: str, clean: bool) -> list[Path]: primary_macos_args, WORK_DIR, output_dir, - upstream_tag, + release_tag, ) if primary is None: raise RuntimeError("Could not package LiteRtLm: no iOS or macOS runtime found") @@ -344,12 +344,12 @@ def package_all(upstream_tag: str, clean: bool) -> list[Path]: IOS_REEXPORT_MODULE, WORK_DIR, output_dir, - upstream_tag, + release_tag, ) if clitertlm is not None: packaged.append(clitertlm) - packaged.extend(package_macos_companions(WORK_DIR, output_dir, upstream_tag)) + packaged.extend(package_macos_companions(WORK_DIR, output_dir, release_tag)) return packaged @@ -357,11 +357,21 @@ def main() -> int: parser = argparse.ArgumentParser( description="Package Apple LiteRT-LM runtimes as SPM-compatible XCFramework zips." ) - parser.add_argument("--upstream-tag", required=True) + parser.add_argument( + "--release-tag", + help="Native release tag to use for output directory and asset names.", + ) + parser.add_argument( + "--upstream-tag", + help="Deprecated alias for --release-tag.", + ) parser.add_argument("--clean", action="store_true") args = parser.parse_args() - packaged = package_all(args.upstream_tag, clean=args.clean) + release_tag = args.release_tag or args.upstream_tag + if not release_tag: + parser.error("--release-tag is required") + packaged = package_all(release_tag, clean=args.clean) if not packaged: raise RuntimeError("No Apple XCFramework zips were produced") for path in packaged: diff --git a/tools/package_macos_runtime.py b/tools/package_macos_runtime.py new file mode 100644 index 0000000..403df87 --- /dev/null +++ b/tools/package_macos_runtime.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import plistlib +import shutil +import subprocess +import tempfile +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +BIN_DIR = REPO_ROOT / "bin" +DIST_OFFICIAL_DIR = REPO_ROOT / "dist" / "official" +BRIDGE_SOURCE = REPO_ROOT / "native" / "bridge" / "litert_lm_bridge.c" +DEFAULT_MACOS_MINIMUM_OS = "14.0" + +REQUIRED_C_API_SYMBOLS = [ + b"litert_lm_engine_settings_create", + b"litert_lm_engine_create", + b"litert_lm_conversation_create", + b"litert_lm_conversation_send_message_stream", +] + +REQUIRED_PROVIDER_SYMBOLS = [ + b"LiteRtLmGemmaModelConstraintProvider_Create", + b"LiteRtLmGemmaModelConstraintProvider_CreateConstraintFromTools", + b"LiteRtLmGemmaModelConstraintProvider_Destroy", +] + +REQUIRED_BRIDGE_SYMBOLS = [ + b"stream_proxy_load_global", + b"stream_proxy_create", + b"stream_proxy_delete", + b"stream_proxy_free_string", +] + +LITERTLM_LIBRARY = "libLiteRtLm.dylib" +CLITERTLM_MAC_LIBRARY = "libCLiteRTLM_mac.dylib" +LITERTLM_INSTALL_NAME = f"@rpath/{LITERTLM_LIBRARY}" +CLITERTLM_MAC_INSTALL_NAME = f"@rpath/{CLITERTLM_MAC_LIBRARY}" +CLITERTLM_MAC_REEXPORT_NAME = CLITERTLM_MAC_INSTALL_NAME.encode("ascii") + +MACOS_ARCH_ORDER = {"arm64": 0, "x64": 1} +REQUIRED_MACOS_ARCHES = set(MACOS_ARCH_ORDER) +XCFRAMEWORK_ARCH_TO_RUNTIME_ARCH = { + "arm64": "arm64", + "x86_64": "x64", +} +RUNTIME_ARCH_TO_MACHO_ARCH = { + "arm64": "arm64", + "x64": "x86_64", +} + + +def run(command: list[str]) -> None: + print("+ " + " ".join(command), flush=True) + subprocess.run(command, check=True) + + +def validate_exported_symbols(output: Path) -> None: + data = output.read_bytes() + missing = [ + symbol.decode("ascii") + for symbol in REQUIRED_BRIDGE_SYMBOLS + if symbol not in data + ] + if missing: + raise RuntimeError( + f"{output} does not contain required LiteRtLmBridge symbols: " + + ", ".join(missing) + ) + if CLITERTLM_MAC_REEXPORT_NAME not in data: + raise RuntimeError( + f"{output} does not re-export " + f"{CLITERTLM_MAC_REEXPORT_NAME.decode('ascii')}" + ) + print(f"Validated LiteRtLmBridge wrapper symbols in {output}", flush=True) + + +def validate_upstream_symbols(output: Path) -> None: + data = output.read_bytes() + required_symbols = REQUIRED_C_API_SYMBOLS + REQUIRED_PROVIDER_SYMBOLS + missing = [ + symbol.decode("ascii") + for symbol in required_symbols + if symbol not in data + ] + if missing: + raise RuntimeError( + f"{output} does not contain required LiteRT-LM macOS symbols: " + + ", ".join(missing) + ) + print(f"Validated upstream LiteRT-LM macOS symbols in {output}", flush=True) + + +def discover_macos_slices(extracted_root: Path) -> list[dict]: + xcframework_root = extracted_root / "CLiteRTLM_mac.xcframework" + info_plist = xcframework_root / "Info.plist" + if not info_plist.is_file(): + raise RuntimeError( + f"Missing CLiteRTLM_mac.xcframework Info.plist: {info_plist}" + ) + + metadata = plistlib.loads(info_plist.read_bytes()) + specs: list[dict] = [] + for library in metadata.get("AvailableLibraries", []): + if library.get("SupportedPlatform") != "macos": + continue + library_identifier = library.get("LibraryIdentifier") + library_path = library.get("LibraryPath") + if not library_identifier or not library_path: + continue + + source = xcframework_root / library_identifier / library_path + source_arches = library.get("SupportedArchitectures", []) + for source_arch in source_arches: + runtime_arch = XCFRAMEWORK_ARCH_TO_RUNTIME_ARCH.get(source_arch) + if runtime_arch is None: + continue + specs.append( + { + "arch": runtime_arch, + "source": source, + "source_arch": source_arch, + } + ) + + found = {spec["arch"] for spec in specs} + missing = sorted(REQUIRED_MACOS_ARCHES - found) + if missing: + raise RuntimeError( + "Missing required CLiteRTLM_mac macOS slices: " + ", ".join(missing) + ) + return sorted(specs, key=lambda spec: MACOS_ARCH_ORDER.get(spec["arch"], 99)) + + +def create_universal_upstream(specs: list[dict], work_dir: Path) -> Path: + slices: list[Path] = [] + for spec in specs: + source = spec["source"] + if not source.is_file(): + raise RuntimeError(f"Missing CLiteRTLM_mac binary: {source}") + thin_output = work_dir / f"{spec['arch']}-{CLITERTLM_MAC_LIBRARY}" + run( + [ + "lipo", + str(source), + "-thin", + spec["source_arch"], + "-output", + str(thin_output), + ] + ) + slices.append(thin_output) + + upstream = work_dir / CLITERTLM_MAC_LIBRARY + if len(slices) == 1: + shutil.copy2(slices[0], upstream) + else: + run( + [ + "xcrun", + "lipo", + "-create", + *[str(path) for path in slices], + "-output", + str(upstream), + ] + ) + run(["install_name_tool", "-id", CLITERTLM_MAC_INSTALL_NAME, str(upstream)]) + validate_upstream_symbols(upstream) + return upstream + + +def build_wrapper(upstream: Path, work_dir: Path, target_arches: list[str]) -> Path: + output = work_dir / LITERTLM_LIBRARY + arch_args: list[str] = [] + for target_arch in target_arches: + arch_args.extend(["-arch", target_arch]) + + run( + [ + "xcrun", + "clang", + "-dynamiclib", + "-O2", + "-std=c11", + "-fvisibility=hidden", + *arch_args, + f"-mmacosx-version-min={DEFAULT_MACOS_MINIMUM_OS}", + "-install_name", + LITERTLM_INSTALL_NAME, + "-Wl,-rpath,@loader_path", + "-Wl,-reexport_library," + str(upstream), + "-o", + str(output), + str(BRIDGE_SOURCE), + ] + ) + validate_exported_symbols(output) + return output + + +def thin_to_runtime_arch(source: Path, arch: str, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + run( + [ + "lipo", + str(source), + "-thin", + RUNTIME_ARCH_TO_MACHO_ARCH[arch], + "-output", + str(destination), + ] + ) + + +def stage_runtime( + upstream: Path, + wrapper: Path, + specs: list[dict], + clean: bool, +) -> list[Path]: + staged: list[Path] = [] + for spec in specs: + arch = spec["arch"] + target_dir = BIN_DIR / "macos" / arch + if clean and target_dir.exists(): + shutil.rmtree(target_dir) + target_dir.mkdir(parents=True, exist_ok=True) + + wrapper_output = target_dir / LITERTLM_LIBRARY + upstream_output = target_dir / CLITERTLM_MAC_LIBRARY + thin_to_runtime_arch(wrapper, arch, wrapper_output) + thin_to_runtime_arch(upstream, arch, upstream_output) + staged.extend([wrapper_output, upstream_output]) + print(f"Staged {wrapper_output}", flush=True) + print(f"Staged {upstream_output}", flush=True) + return staged + + +def package_macos_runtime(archive: Path, clean: bool) -> list[Path]: + if not archive.is_file(): + raise RuntimeError(f"Missing upstream macOS xcframework archive: {archive}") + + with tempfile.TemporaryDirectory(prefix="litert-lm-native-macos-") as temp: + temp_dir = Path(temp) + with zipfile.ZipFile(archive) as zip_file: + zip_file.extractall(temp_dir) + specs = discover_macos_slices(temp_dir) + upstream = create_universal_upstream(specs, temp_dir) + target_arches = [RUNTIME_ARCH_TO_MACHO_ARCH[spec["arch"]] for spec in specs] + wrapper = build_wrapper(upstream, temp_dir, target_arches) + if clean: + for arch in MACOS_ARCH_ORDER: + target_dir = BIN_DIR / "macos" / arch + if target_dir.exists(): + shutil.rmtree(target_dir) + return stage_runtime(upstream, wrapper, specs, clean=False) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Package official upstream CLiteRTLM_mac.xcframework slices as " + "bridge-enabled macOS runtime dylibs." + ) + ) + parser.add_argument("--upstream-tag", required=True) + parser.add_argument( + "--archive", + type=Path, + help=( + "Path to CLiteRTLM_mac.xcframework.zip. Defaults to " + "dist/official//CLiteRTLM_mac.xcframework.zip." + ), + ) + parser.add_argument("--clean", action="store_true") + args = parser.parse_args() + + archive = args.archive or ( + DIST_OFFICIAL_DIR / args.upstream_tag / "CLiteRTLM_mac.xcframework.zip" + ) + staged = package_macos_runtime(archive.resolve(), clean=args.clean) + print(f"Packaged {len(staged)} macOS LiteRT-LM runtime files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/package_release.py b/tools/package_release.py index 4e73ff9..107262c 100755 --- a/tools/package_release.py +++ b/tools/package_release.py @@ -68,7 +68,7 @@ def iter_artifacts() -> list[Path]: return sorted(artifacts) -def build_manifest(upstream_tag: str | None) -> dict: +def build_manifest(upstream_tag: str | None, release_tag: str | None) -> dict: entries = [] sums = [] for path in iter_artifacts(): @@ -89,6 +89,7 @@ def build_manifest(upstream_tag: str | None) -> dict: "fileName": path.name, "sha256": checksum, "upstreamTag": upstream_tag, + "releaseTag": release_tag, "accelerators": [], } ) @@ -103,6 +104,9 @@ def build_manifest(upstream_tag: str | None) -> dict: "tag": upstream_tag, "commit": None, }, + "release": { + "tag": release_tag, + }, "artifacts": entries, } @@ -110,9 +114,11 @@ def build_manifest(upstream_tag: str | None) -> dict: def main() -> int: parser = argparse.ArgumentParser(description="Generate manifest and checksums.") parser.add_argument("--upstream-tag", default=None) + parser.add_argument("--release-tag", default=None) args = parser.parse_args() - manifest = build_manifest(args.upstream_tag) + release_tag = args.release_tag or args.upstream_tag + manifest = build_manifest(args.upstream_tag, release_tag) MANIFEST_PATH.write_text( json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8", diff --git a/tools/validate_release_manifest.py b/tools/validate_release_manifest.py index 5461714..ef3ddba 100644 --- a/tools/validate_release_manifest.py +++ b/tools/validate_release_manifest.py @@ -22,13 +22,8 @@ REQUIRED_SPM_XCFRAMEWORKS = [ "litert-lm-native-apple-CLiteRTLM-xcframework-{tag}.zip", - "litert-lm-native-apple-GemmaModelConstraintProvider-xcframework-{tag}.zip", - "litert-lm-native-apple-LiteRt-xcframework-{tag}.zip", + "litert-lm-native-apple-CLiteRTLMMac-xcframework-{tag}.zip", "litert-lm-native-apple-LiteRtLm-xcframework-{tag}.zip", - "litert-lm-native-apple-LiteRtMetalAccelerator-xcframework-{tag}.zip", - "litert-lm-native-apple-LiteRtTopKMetalSampler-xcframework-{tag}.zip", - "litert-lm-native-apple-LiteRtTopKWebGpuSampler-xcframework-{tag}.zip", - "litert-lm-native-apple-LiteRtWebGpuAccelerator-xcframework-{tag}.zip", ] REQUIRED_RELEASE_ASSETS = [ @@ -50,23 +45,47 @@ def main() -> int: ) parser.add_argument("manifest", type=Path) parser.add_argument("--upstream-tag", required=True) + parser.add_argument( + "--release-tag", + help="Native release tag used in release asset names. Defaults to upstream-tag.", + ) parser.add_argument( "--release-metadata", type=Path, help="JSON from `gh release view --json assets`.", ) args = parser.parse_args() + release_tag = args.release_tag or args.upstream_tag manifest = json.loads(args.manifest.read_text(encoding="utf-8")) + upstream = manifest.get("upstream", {}) + if not isinstance(upstream, dict) or upstream.get("tag") != args.upstream_tag: + actual = upstream.get("tag") if isinstance(upstream, dict) else None + raise SystemExit( + "Release manifest upstream tag mismatch: " + f"expected {args.upstream_tag}, got {actual}" + ) + release = manifest.get("release", {}) + if not isinstance(release, dict) or release.get("tag") != release_tag: + actual = release.get("tag") if isinstance(release, dict) else None + raise SystemExit( + "Release manifest native release tag mismatch: " + f"expected {release_tag}, got {actual}" + ) paths = { artifact.get("path") for artifact in manifest.get("artifacts", []) if isinstance(artifact, dict) } required = [path.as_posix() for path in REQUIRED_RUNTIME_ARTIFACTS] - required.append(f"dist/official/{args.upstream_tag}/CLiteRTLM.xcframework.zip") required.extend( - f"dist/spm/{args.upstream_tag}/{asset.format(tag=args.upstream_tag)}" + [ + f"dist/official/{args.upstream_tag}/CLiteRTLM.xcframework.zip", + f"dist/official/{args.upstream_tag}/CLiteRTLM_mac.xcframework.zip", + ] + ) + required.extend( + f"dist/spm/{release_tag}/{asset.format(tag=release_tag)}" for asset in REQUIRED_SPM_XCFRAMEWORKS ) missing = [path for path in required if path not in paths] @@ -84,7 +103,7 @@ def main() -> int: if isinstance(asset, dict) } required_assets = [ - pattern.format(tag=args.upstream_tag) for pattern in REQUIRED_RELEASE_ASSETS + pattern.format(tag=release_tag) for pattern in REQUIRED_RELEASE_ASSETS ] missing_assets = [ asset for asset in required_assets if asset not in asset_names @@ -92,6 +111,10 @@ def main() -> int: if missing_assets: formatted = "\n".join(f"- {asset}" for asset in missing_assets) raise SystemExit(f"Release is missing required assets:\n{formatted}") + unexpected_assets = sorted(asset_names - set(required_assets)) + if unexpected_assets: + formatted = "\n".join(f"- {asset}" for asset in unexpected_assets) + raise SystemExit(f"Release has unexpected assets:\n{formatted}") print(f"Release metadata lists {len(required_assets)} required assets") return 0 diff --git a/tools/validate_runtime_artifacts.py b/tools/validate_runtime_artifacts.py index 171918a..a159335 100644 --- a/tools/validate_runtime_artifacts.py +++ b/tools/validate_runtime_artifacts.py @@ -15,14 +15,9 @@ Path("bin/ios/arm64-sim/CLiteRTLM.framework/CLiteRTLM"), Path("bin/linux/arm64/libLiteRtLm.so"), Path("bin/linux/x64/libLiteRtLm.so"), - Path("bin/macos/arm64/libGemmaModelConstraintProvider.dylib"), - Path("bin/macos/arm64/libLiteRt.dylib"), + Path("bin/macos/arm64/libCLiteRTLM_mac.dylib"), Path("bin/macos/arm64/libLiteRtLm.dylib"), - Path("bin/macos/arm64/libLiteRtMetalAccelerator.dylib"), - Path("bin/macos/arm64/libLiteRtTopKMetalSampler.dylib"), - Path("bin/macos/arm64/libLiteRtTopKWebGpuSampler.dylib"), - Path("bin/macos/arm64/libLiteRtWebGpuAccelerator.dylib"), - Path("bin/macos/x64/libLiteRt.dylib"), + Path("bin/macos/x64/libCLiteRTLM_mac.dylib"), Path("bin/macos/x64/libLiteRtLm.dylib"), Path("bin/windows/x64/LiteRtLm.dll"), ] @@ -36,8 +31,17 @@ def main() -> int: args = parser.parse_args() required = list(REQUIRED_RUNTIME_ARTIFACTS) - required.append( - Path("dist") / "official" / args.upstream_tag / "CLiteRTLM.xcframework.zip" + required.extend( + [ + Path("dist") + / "official" + / args.upstream_tag + / "CLiteRTLM.xcframework.zip", + Path("dist") + / "official" + / args.upstream_tag + / "CLiteRTLM_mac.xcframework.zip", + ] ) missing = [path for path in required if not (REPO_ROOT / path).is_file()] diff --git a/tools/validate_runtime_dependencies.py b/tools/validate_runtime_dependencies.py index 7341dfe..e3b6d79 100644 --- a/tools/validate_runtime_dependencies.py +++ b/tools/validate_runtime_dependencies.py @@ -2,6 +2,9 @@ from __future__ import annotations import argparse +import re +import shutil +import subprocess from pathlib import Path from runtime_dependency_utils import ( @@ -13,6 +16,11 @@ REPO_ROOT = Path(__file__).resolve().parents[1] ANDROID_MIN_LOAD_ALIGNMENT = 0x4000 +MACOS_UNRESOLVED_PROVIDER_SYMBOLS = { + "_LiteRtLmGemmaModelConstraintProvider_Create", + "_LiteRtLmGemmaModelConstraintProvider_CreateConstraintFromTools", + "_LiteRtLmGemmaModelConstraintProvider_Destroy", +} def fail(message: str) -> None: @@ -74,6 +82,95 @@ def validate_elf_dependencies(root: Path) -> int: return checked +def iter_macho_libraries(root: Path) -> list[Path]: + bin_dir = root / "bin" / "macos" + if not bin_dir.exists() or shutil.which("otool") is None: + return [] + return sorted(path for path in bin_dir.rglob("*.dylib") if path.is_file()) + + +def macho_needed_libraries(path: Path) -> list[str]: + result = subprocess.run( + ["otool", "-L", str(path)], + check=True, + capture_output=True, + text=True, + ) + libraries: list[str] = [] + for line in result.stdout.splitlines(): + if not line[:1].isspace(): + continue + stripped = line.strip() + if not stripped: + continue + library = stripped.split(" ", 1)[0] + if library not in libraries: + libraries.append(library) + return libraries + + +def is_system_macho_needed(install_name: str) -> bool: + return ( + install_name.startswith("/System/Library/") + or install_name.startswith("/usr/lib/") + or install_name == Path(install_name).name + ) + + +def unresolved_dynamic_lookup_symbols(path: Path) -> list[str]: + if shutil.which("nm") is None: + return [] + result = subprocess.run( + ["nm", "-m", str(path)], + check=True, + capture_output=True, + text=True, + ) + symbols: list[str] = [] + for line in result.stdout.splitlines(): + if "(undefined)" not in line or "(dynamically looked up)" not in line: + continue + match = re.search(r"\b(_[A-Za-z0-9_]+)\s+\(dynamically looked up\)", line) + if match: + symbols.append(match.group(1)) + return symbols + + +def validate_macho_dependencies(root: Path) -> int: + checked = 0 + errors: list[str] = [] + for library in iter_macho_libraries(root): + checked += 1 + for needed in macho_needed_libraries(library): + if is_system_macho_needed(needed): + continue + dependency_path = library.parent / Path(needed).name + if not dependency_path.is_file(): + errors.append( + f"{library.relative_to(root).as_posix()} needs {needed}, " + "but it is missing from the same runtime directory." + ) + + unresolved = [ + symbol + for symbol in unresolved_dynamic_lookup_symbols(library) + if symbol in MACOS_UNRESOLVED_PROVIDER_SYMBOLS + ] + if unresolved: + formatted = ", ".join(sorted(unresolved)) + errors.append( + f"{library.relative_to(root).as_posix()} leaves required Gemma " + f"constraint provider symbols unresolved: {formatted}" + ) + + if errors: + fail( + "Runtime dependency validation failed:\n" + + "\n".join(f"- {e}" for e in errors) + ) + return checked + + def main() -> int: parser = argparse.ArgumentParser( description=( @@ -85,8 +182,12 @@ def main() -> int: args = parser.parse_args() root = args.root.resolve() - checked = validate_elf_dependencies(root) - print(f"Validated runtime dependencies for {checked} ELF libraries") + checked_elf = validate_elf_dependencies(root) + checked_macho = validate_macho_dependencies(root) + print( + "Validated runtime dependencies for " + f"{checked_elf} ELF libraries and {checked_macho} Mach-O libraries" + ) return 0