diff --git a/docs/paykit-ios-sdk-preparation.md b/docs/paykit-ios-sdk-preparation.md new file mode 100644 index 0000000..7ae65cb --- /dev/null +++ b/docs/paykit-ios-sdk-preparation.md @@ -0,0 +1,36 @@ +# Prepare the Paykit iOS SDK + +`pubky/paykit-rs` is an SDK, not an installable companion app. Its iOS artifact +is `Paykit.xcframework.zip`, so the `.app` companion cache does not represent its +availability. The existing `paykit-server` fixture is a separate component. + +For the trusted `v0.1.0-rc73` release, prepare a companion checkout at +`0eff3d4e694ba0dbbea2d47852fe796e456671b0` using the review stack's +`prepare_test_checkout.py --layout worktrees --companion --repo pubky/paykit-rs +--head 0eff3d4e694ba0dbbea2d47852fe796e456671b0 --pr ` command. +Use the printed `path` with the helper on a Mac with Xcode: + +```bash +python3 scripts/prepare-paykit-ios-sdk.py \ + --checkout /path/to/paykit-rs-checkout \ + --output artifacts/paykit-rc73 +``` + +The helper verifies the checkout revision, unchanged tracked files, SwiftPM +manifest URL and checksum, and the downloaded archive's published SHA256 +`511715acf3ca84e322d19ea6651c767c98b5325845d571abe2a9527b1eb6ae26`. +It then builds and links the published Swift bindings and upstream compile +fixtures against both arm64 iOS slices, without a simulator or device. +`verified.json` records the exact revision, archive, checksum and checks. +A repeat invocation verifies the retained archive again before using it. + +Keep the app's pinned SwiftPM dependency and release URL when building it. +The extracted framework proves artifact availability; it is not a local package +override or a replacement for SwiftPM's checksum verification. This helper +does not build the app, install anything on a device, or drive a QA item. + +The helper's corruption check runs with: + +```bash +python3 -B scripts/test_prepare_paykit_ios_sdk.py +``` diff --git a/scripts/prepare-paykit-ios-sdk.py b/scripts/prepare-paykit-ios-sdk.py new file mode 100644 index 0000000..aa87f7a --- /dev/null +++ b/scripts/prepare-paykit-ios-sdk.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Prepare the published rc73 SDK without changing an app's SwiftPM dependencies.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import zipfile + +COMMIT = "0eff3d4e694ba0dbbea2d47852fe796e456671b0" +TAG = "v0.1.0-rc73" +CHECKSUM = "511715acf3ca84e322d19ea6651c767c98b5325845d571abe2a9527b1eb6ae26" +URL = f"https://github.com/pubky/paykit-rs/releases/download/{TAG}/Paykit.xcframework.zip" + + +def capture(*args): + return subprocess.check_output(args, text=True).strip() + + +def verify_archive(archive): + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + if digest != CHECKSUM: + raise ValueError(f"Archive checksum mismatch: {digest}") + with zipfile.ZipFile(archive) as zipped: + for name in zipped.namelist(): + if Path(name).is_absolute() or ".." in Path(name).parts: + raise ValueError(f"Unsafe archive entry: {name}") + return digest + + +def prepare(checkout, output): + checkout = checkout.resolve() + output = output.resolve() + if capture("git", "-C", str(checkout), "rev-parse", "HEAD") != COMMIT: + raise ValueError(f"SDK checkout must be at {COMMIT}") + subprocess.run(["git", "-C", str(checkout), "diff", "--quiet", "HEAD", "--"], check=True) + manifest = json.loads(capture("swift", "package", "--package-path", str(checkout), "dump-package")) + binary = next(t for t in manifest["targets"] if t["name"] == "PaykitFFI") + if binary.get("url") != URL or binary.get("checksum") != CHECKSUM: + raise ValueError("SwiftPM manifest does not name the published rc73 archive") + output.mkdir(parents=True, exist_ok=True) + archive = output / "Paykit.xcframework.zip" + if not archive.exists(): + subprocess.run([ + "curl", "--fail", "--location", "--retry", "1", "--connect-timeout", "15", + "--max-time", "240", "--output", str(archive), URL, + ], check=True) + digest = verify_archive(archive) + # The checksum is pinned before extraction or compilation. + with zipfile.ZipFile(archive) as zipped: + zipped.extractall(output) + bindings = checkout / "paykit-ffi/bindings/ios" + sources = [bindings / name for name in ("paykit.swift", "PaykitPublicKeys.swift", "PaykitRedaction.swift")] + sources += [checkout / "paykit-ffi/src/swift" / name for name in ( + "AllowanceBindingsCompile.swift", "PaymentConversionBindingsCompile.swift", + )] + for sdk, target, slice_name in ( + ("iphonesimulator", "arm64-apple-ios15.0-simulator", "ios-arm64-simulator"), + ("iphoneos", "arm64-apple-ios15.0", "ios-arm64"), + ): + library = output / "Paykit.xcframework" / slice_name + subprocess.run([ + "xcrun", "--sdk", sdk, "swiftc", "-emit-library", "-module-name", "Paykit", + "-target", target, "-sdk", capture("xcrun", "--sdk", sdk, "--show-sdk-path"), + "-module-cache-path", str(output / "module-cache"), + "-I", str(library / "Headers"), "-L", str(library), "-lpaykit", + *map(str, sources), "-o", str(output / f"Paykit-{slice_name}.dylib"), + ], check=True) + result = {"repo": "pubky/paykit-rs", "commit": COMMIT, "tag": TAG, "sha256": digest, + "archive": str(archive), "xcframework": str(output / "Paykit.xcframework"), + "checks": "published bindings and upstream compile fixtures linked for iOS device and simulator"} + (output / "verified.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkout", type=Path, required=True, help="trusted paykit-rs checkout at rc73") + parser.add_argument("--output", type=Path, required=True, help="run-owned artifact directory") + args = parser.parse_args() + prepare(args.checkout, args.output) diff --git a/scripts/test_prepare_paykit_ios_sdk.py b/scripts/test_prepare_paykit_ios_sdk.py new file mode 100644 index 0000000..9592c1a --- /dev/null +++ b/scripts/test_prepare_paykit_ios_sdk.py @@ -0,0 +1,22 @@ +"""The SDK preparer must reject a corrupt archive before extracting it.""" +import importlib.util +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location("prepare", Path(__file__).with_name("prepare-paykit-ios-sdk.py")) +prepare = importlib.util.module_from_spec(spec) +spec.loader.exec_module(prepare) + + +class ArchiveChecks(unittest.TestCase): + def test_corrupt_archive_is_rejected(self): + with tempfile.TemporaryDirectory() as folder: + archive = Path(folder) / "Paykit.xcframework.zip" + archive.write_bytes(b"corrupt release archive") + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + prepare.verify_archive(archive) + + +if __name__ == "__main__": + unittest.main()