iOS Sideload IPA #23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Build an arm64 iOS device .ipa on a macOS runner, with no Apple developer | |
| # account and no signing material in the repo. The .ipa is ad-hoc signed (not | |
| # team-signed): a free-signing sideload tool (Sideloadly, AltStore) re-signs it | |
| # on the user's own machine with a free Apple ID and installs it on their iPad. | |
| # See docs/19-iOS侧载安装.md for the user-side flow. | |
| # | |
| # How the ipa is produced: `tauri ios build --target aarch64 --no-sign` archives | |
| # the device build with codesigning disabled (cargo-mobile2 passes | |
| # CODE_SIGNING_ALLOWED=NO / CODE_SIGNING_REQUIRED=NO / CODE_SIGN_IDENTITY="" / | |
| # CODE_SIGN_ENTITLEMENTS="" to xcodebuild archive, so no team or provisioning | |
| # profile is needed), then zips the .app into Payload/ and writes a standard | |
| # .ipa under src-tauri/gen/apple/build/arm64/. A follow-up step ad-hoc signs the | |
| # .app so the free-signing re-signer replaces a valid signature instead of | |
| # adding one from scratch (which crashes on device — see the LINKEDIT step). | |
| # | |
| # This is a real product line, not the smoke build: VITE_SMOKE is NOT set, so | |
| # main.tsx runs the app, and the Google client ids are injected so Drive login | |
| # works in the sideloaded build. Independent of ios-simulator-smoke.yml (the | |
| # unsigned simulator engine gate) and ios-testflight.yml (the signed store line). | |
| name: iOS Sideload IPA | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: [ios-spike] | |
| concurrency: | |
| group: ios-sideload-ipa | |
| cancel-in-progress: true | |
| jobs: | |
| build: | |
| runs-on: macos-26 | |
| timeout-minutes: 90 | |
| env: | |
| # Baked into the Vite build. Same secret names as release.yml; the desktop | |
| # OAuth client secret is not confidential (PKCE is the protection) but | |
| # still lives in repo secrets. Present at job scope so tauri's | |
| # beforeBuildCommand (bun run build) inherits them. | |
| VITE_GOOGLE_CLIENT_ID: ${{ secrets.VITE_GOOGLE_CLIENT_ID }} | |
| VITE_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_GOOGLE_CLIENT_SECRET }} | |
| # iOS OAuth client id is a public value (no secret; PKCE-protected) and is | |
| # the same client whose reversed form is the deep-link scheme in | |
| # tauri.conf.json. Hardcoded here so the sideloaded build can sign in. | |
| VITE_GOOGLE_IOS_CLIENT_ID: 379091688229-esc23unqq02igufrjr9jjvtsug49j097.apps.googleusercontent.com | |
| BUNDLE_ID: com.xinyuan.readingpartner | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # Pin Xcode so an image default bump can't change the toolchain under us. | |
| - name: Pin Xcode | |
| env: | |
| XCODE_VERSION: "26.5" | |
| run: | | |
| sudo xcode-select --switch "/Applications/Xcode_${XCODE_VERSION}.app" || { | |
| echo "Xcode ${XCODE_VERSION} is not on this runner image. Installed:" | |
| ls /Applications | grep -i '^Xcode' || true | |
| exit 1 | |
| } | |
| xcodebuild -version | |
| - uses: oven-sh/setup-bun@v2 | |
| # aarch64-apple-ios is the Rust target for the physical device; the Xcode | |
| # project's Build Rust Code phase compiles the staticlib for it. | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: aarch64-apple-ios | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: src-tauri | |
| - run: bun install --frozen-lockfile | |
| # Self-host pdfium.wasm from node_modules into public/ (vite copies it into | |
| # dist). Without it the reader cannot start. | |
| - name: Copy pdfium.wasm | |
| run: bun run wasm | |
| # src-tauri/gen/apple is gitignored; generate the Xcode project fresh. | |
| # --ci answers prompts with defaults. First run installs xcodegen (and | |
| # cocoapods if missing) via Homebrew. | |
| - name: Generate the Xcode project | |
| run: bun tauri ios init --ci | |
| # `tauri ios init` fills AppIcon.appiconset from a bundled DEFAULT (Tauri | |
| # logo) template and ignores src-tauri/icons/ios/, so a bare build ships | |
| # the placeholder icon, not ours. Overwrite those PNGs with our own set; | |
| # the filenames match the template's Contents.json, so it still resolves. | |
| # Our committed set is RGB with no alpha (App-Store-valid, including the | |
| # 1024 marketing icon). See docs/pitfall/34-ios-init-default-icon-alpha.md. | |
| - name: Apply our app icon to the iOS project | |
| run: | | |
| set -euo pipefail | |
| DEST=$(find src-tauri/gen/apple -type d -name AppIcon.appiconset -print -quit) | |
| [ -n "$DEST" ] || { echo "::error::AppIcon.appiconset not found in generated project"; exit 1; } | |
| cp src-tauri/icons/ios/*.png "$DEST"/ | |
| echo "applied $(ls src-tauri/icons/ios/*.png | wc -l | tr -d ' ') icons to $DEST" | |
| # XcodeGen's iOS defaults set TARGETED_DEVICE_FAMILY "1,2" (iPhone + iPad). | |
| # Fail fast if a toolchain update ever drops iPad from the product. | |
| - name: Assert iPad support | |
| run: | | |
| grep -rq 'TARGETED_DEVICE_FAMILY = "1,2"' src-tauri/gen/apple/*.xcodeproj/project.pbxproj || { | |
| echo 'TARGETED_DEVICE_FAMILY "1,2" missing from the generated project:' | |
| grep -r 'TARGETED_DEVICE_FAMILY' src-tauri/gen/apple/*.xcodeproj/project.pbxproj || true | |
| exit 1 | |
| } | |
| # Archive the arm64 device build unsigned and zip it into a standard ipa. | |
| # --target aarch64 selects the physical-device slice (arch label arm64); | |
| # --no-sign disables codesigning during archive and makes tauri build the | |
| # Payload/ ipa itself. Runs beforeBuildCommand (bun run build) which | |
| # inherits the VITE_* env above, so Drive login is wired into the bundle. | |
| - name: Build the unsigned device ipa | |
| run: bun tauri ios build --target aarch64 --no-sign | |
| # A fully unsigned Mach-O boots in the Simulator but crashes on a real | |
| # device once a free-signing tool re-signs it. Sideloadly and Dadoum | |
| # Sideloader APPEND an LC_CODE_SIGNATURE and grow __LINKEDIT's filesize | |
| # without growing its vmsize, so dyld aborts at launch with | |
| # "segment __LINKEDIT filesize exceeds vmsize". Ad-hoc signing here gives | |
| # the binary a well-formed signature and a page-aligned __LINKEDIT | |
| # (vmsize >= filesize); the re-signers' replace-an-existing-signature path | |
| # rewrites the layout correctly, unlike their add-from-nothing path. No | |
| # entitlements are needed (the deep-link scheme is a CFBundleURLTypes | |
| # entry, not an entitlement). See docs/pitfall/35-ios-unsigned-linkedit-vmsize.md. | |
| - name: Ad-hoc sign the app so re-signing keeps a valid __LINKEDIT | |
| run: | | |
| set -euo pipefail | |
| IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -print -quit) | |
| [ -n "$IPA" ] || { echo "::error::no ipa to sign"; exit 1; } | |
| IPA_ABS="$(cd "$(dirname "$IPA")" && pwd)/$(basename "$IPA")" | |
| WORK="$RUNNER_TEMP/ipa-adhoc" | |
| rm -rf "$WORK"; mkdir -p "$WORK" | |
| unzip -q "$IPA_ABS" -d "$WORK" | |
| APP=$(ls -d "$WORK"/Payload/*.app 2>/dev/null | head -1) | |
| [ -n "$APP" ] || { echo "::error::ipa has no Payload/*.app"; exit 1; } | |
| codesign --force --sign - --deep "$APP" | |
| codesign --verify --verbose=2 "$APP" | |
| # Repack, preserving the Payload/ root and any symlinks (-y). | |
| rm -f "$IPA_ABS" | |
| ( cd "$WORK" && zip -qr -y "$IPA_ABS" Payload ) | |
| echo "ad-hoc signed and repacked: $IPA_ABS" | |
| # Prove the artifact is a sideloadable arm64 iOS ipa before uploading: | |
| # standard Payload/ layout, an arm64 *device* Mach-O (not simulator), the | |
| # right bundle id / version, and the Google deep-link scheme baked into | |
| # Info.plist (free signing needs no entitlement for a CFBundleURLTypes | |
| # custom scheme, so login survives re-signing). | |
| - name: Verify the ipa | |
| run: | | |
| set -euo pipefail | |
| IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -print -quit) | |
| if [ -z "$IPA" ]; then | |
| echo "::error::no ipa produced"; find src-tauri/gen/apple/build -maxdepth 4 -type f || true; exit 1 | |
| fi | |
| echo "ipa: $IPA" | |
| WORK="$RUNNER_TEMP/ipa-verify" | |
| rm -rf "$WORK"; mkdir -p "$WORK" | |
| unzip -q "$IPA" -d "$WORK" | |
| APP=$(ls -d "$WORK"/Payload/*.app 2>/dev/null | head -1) | |
| if [ -z "$APP" ]; then | |
| echo "::error::ipa has no Payload/*.app"; unzip -l "$IPA"; exit 1 | |
| fi | |
| echo "app bundle: $(basename "$APP")" | |
| PLIST="$APP/Info.plist" | |
| [ -f "$PLIST" ] || { echo "::error::Info.plist missing"; exit 1; } | |
| pb() { /usr/libexec/PlistBuddy -c "Print :$1" "$PLIST" 2>/dev/null || true; } | |
| EXE=$(pb CFBundleExecutable) | |
| BID=$(pb CFBundleIdentifier) | |
| SHORTVER=$(pb CFBundleShortVersionString) | |
| MINOS=$(pb MinimumOSVersion) | |
| echo "CFBundleExecutable=$EXE CFBundleIdentifier=$BID" | |
| echo "CFBundleShortVersionString=$SHORTVER MinimumOSVersion=$MINOS" | |
| [ -n "$EXE" ] || { echo "::error::CFBundleExecutable empty"; exit 1; } | |
| [ "$BID" = "$BUNDLE_ID" ] || { echo "::error::bundle id is '$BID', expected $BUNDLE_ID"; exit 1; } | |
| [ -n "$SHORTVER" ] || { echo "::error::CFBundleShortVersionString empty"; exit 1; } | |
| # The deep-link scheme (reversed iOS client id) must be present or | |
| # Google OAuth's callback is lost on device. Its presence also shows | |
| # tauri-plugin-deep-link's build.rs injected CFBundleURLTypes. | |
| SCHEMES=$(/usr/libexec/PlistBuddy -c "Print :CFBundleURLTypes" "$PLIST" 2>/dev/null || true) | |
| echo "$SCHEMES" | grep -q 'com.googleusercontent.apps.379091688229-esc23unqq02igufrjr9jjvtsug49j097' || { | |
| echo "::error::Google deep-link scheme missing from CFBundleURLTypes"; echo "$SCHEMES"; exit 1 | |
| } | |
| echo "deep-link scheme present in CFBundleURLTypes" | |
| BIN="$APP/$EXE" | |
| [ -f "$BIN" ] || { echo "::error::executable $EXE not found in app bundle"; ls -la "$APP"; exit 1; } | |
| echo "=== file ==="; file "$BIN" | |
| echo "=== lipo -archs ==="; lipo -archs "$BIN" | |
| lipo -archs "$BIN" | grep -qw arm64 || { echo "::error::binary is not arm64"; exit 1; } | |
| # arm64 alone doesn't distinguish device from simulator; the Mach-O | |
| # LC_BUILD_VERSION platform does. Device iOS must be platform IOS, not | |
| # IOSSIMULATOR. | |
| echo "=== vtool build ===" | |
| BUILD=$(vtool -arch arm64 -show-build "$BIN" 2>/dev/null || true) | |
| echo "$BUILD" | |
| echo "$BUILD" | grep -q 'platform IOS' || { | |
| echo "::error::Mach-O platform is not iOS device (a simulator slice re-signs but won't install)"; exit 1 | |
| } | |
| echo "$BUILD" | grep -qi 'SIMULATOR' && { echo "::error::binary is an iOS Simulator slice"; exit 1; } || true | |
| # Home-screen icon: SpringBoard resolves the icon from Assets.car via | |
| # CFBundleIconName. Assert both are present, and that our own icon set | |
| # (not Tauri's placeholder) was compiled in. | |
| echo "=== app icon ===" | |
| [ -f "$APP/Assets.car" ] || { echo "::error::Assets.car missing (app icon not compiled)"; ls -la "$APP"; exit 1; } | |
| ICONNAME=$(pb CFBundleIconName) | |
| echo "CFBundleIconName=$ICONNAME" | |
| [ "$ICONNAME" = "AppIcon" ] || { echo "::error::CFBundleIconName is '$ICONNAME', expected AppIcon (home-screen icon will be blank)"; exit 1; } | |
| # actool emits the primary iPhone/iPad icon loose in the bundle too. | |
| ls "$APP"/AppIcon*.png >/dev/null 2>&1 || { echo "::error::no AppIcon*.png in the bundle"; exit 1; } | |
| echo "app icon present (Assets.car + CFBundleIconName + loose PNGs)" | |
| # Signature: a fully unsigned Mach-O makes the free-signing re-signer | |
| # grow __LINKEDIT filesize past vmsize and crash on device. The ad-hoc | |
| # signing step must have left a signature and a page-aligned | |
| # __LINKEDIT (vmsize >= filesize). | |
| echo "=== codesign ===" | |
| codesign -dv "$APP" 2>&1 | sed -n '1,4p' | |
| otool -l "$BIN" | grep -q LC_CODE_SIGNATURE || { echo "::error::binary has no LC_CODE_SIGNATURE (ad-hoc signing step did not run?)"; exit 1; } | |
| echo "=== __LINKEDIT vmsize vs filesize ===" | |
| python3 .github/scripts/assert-linkedit-vmsize.py "$BIN" | |
| echo "verify OK — ad-hoc signed arm64 iOS device ipa with our app icon" | |
| # Stage the ipa under a versioned name (marketing version from | |
| # tauri.conf.json) so downloads self-describe. | |
| - name: Stage the versioned ipa | |
| run: | | |
| set -euo pipefail | |
| IPA=$(find src-tauri/gen/apple/build -name '*.ipa' -print -quit) | |
| VERSION=$(jq -r .version src-tauri/tauri.conf.json) | |
| OUT="reading-partner-${VERSION}-ios-arm64-unsigned.ipa" | |
| cp "$IPA" "$OUT" | |
| echo "staged: $OUT ($(du -h "$OUT" | cut -f1))" | |
| echo "IPA_NAME=$OUT" >> "$GITHUB_ENV" | |
| - name: Upload the unsigned ipa | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ios-sideload-ipa | |
| path: ${{ env.IPA_NAME }} | |
| if-no-files-found: error | |
| # Keep the build tree on failure for post-mortems. | |
| - name: Dump build tree on failure | |
| if: failure() | |
| run: find src-tauri/gen/apple/build -maxdepth 4 -type f 2>/dev/null || true |