#!/usr/bin/env bash # compile-ios.sh — build a signed iOS IPA (app + PacketTunnel Network # Extension) and upload it to TestFlight (App Store Connect). # # Mirrors ~/code/jiu/scripts/ci/compile-ios.sh's shape (temp-keychain dist # cert import, provisioning-profile install by UUID, ExportOptions.plist, # `flutter build ipa`, `xcrun altool --upload-app`), adapted for pangolin: # - pangolin's iOS app ships a NEPacketTunnelProvider **app extension** # (client/ios/PacketTunnel/, bundle com.pangolin.pangolinVpn.PacketTunnel) # alongside the main app (com.pangolin.pangolinVpn) — jiu has no # extension at all, just the one app target. So this script needs TWO # distribution provisioning profiles (app + extension), not one, and the # ExportOptions.plist provisioningProfiles dict needs both bundle-id -> # profile-name mappings. # - pangolin embeds sing-box as a native core (gomobile-built # Libbox.xcframework, io.nekohasekai.libbox) — jiu's Flutter app has no # embedded core. This script MUST run `scripts/build-libbox.sh apple ios` # before `flutter build ipa`, or the IPA links no VPN kernel (mirrors the # equivalent step in compile-android.sh / compile-macos.sh). # - Team ID BYL4KQHMTN is hardcoded as a script constant rather than a # secret (unlike jiu's IOS_TEAM_ID secret): it's already public inside # this repo (CLAUDE.md, client/ios/Runner.xcodeproj/project.pbxproj # DEVELOPMENT_TEAM, scripts/local_test.sh SIGN_ID) — not sensitive, no # reason to add another secret for it. # - APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 # are shared with compile-macos.sh's notarization step (same App Store # Connect API key serves both notarization and TestFlight upload). # # Required CI secrets (missing ANY of them => SKIP gracefully, exit 0 — does # NOT fail the pipeline; Apple iOS distribution needs an enrolled account + # certs that may not exist yet, unlike macOS Developer ID which is a hard # fail-fast in compile-macos.sh): # IOS_DIST_CERT_P12_BASE64 Apple Distribution 证书(.p12)base64 # IOS_DIST_CERT_PASSWORD .p12 导出密码 # IOS_APP_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(主 app, # com.pangolin.pangolinVpn)base64 # IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64 App Store 类型描述文件(扩展, # com.pangolin.pangolinVpn.PacketTunnel)base64 # APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 # App Store Connect API Key(上传 TestFlight 用;与 compile-macos.sh 公证共用)。 # # No dist/ artifact is produced (matches jiu) — the IPA goes straight to # TestFlight via altool, nothing is uploaded to the Forgejo release. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/ci/_env.sh . "${SCRIPT_DIR}/_env.sh" TAG="${1:?usage: compile-ios.sh }" ver_file="/tmp/compile_ios_ver.$$" ver_from_tag client "$TAG" > "$ver_file" VER="" read -r VER < "$ver_file" || true # 无结尾换行的 read 退出码,见 compile-android.sh 同注释 rm -f "$ver_file" # CFBundleVersion(两个 target 均为 "$(FLUTTER_BUILD_NUMBER)",见 # client/ios/Runner.xcodeproj/project.pbxproj —— 与 macOS 的 PacketTunnel 硬编码 # CURRENT_PROJECT_VERSION 不同,这里走 flutter build --build-number 走标准路径)。 # 必须单调递增,否则 TestFlight 拒绝重复上传(同 jiu 注释),公式与 # compile-android.sh / compile-macos.sh 一致。 MAJOR="${VER%%.*}" _REST="${VER#*.}" MINOR="${_REST%%.*}" PATCH="${_REST#*.}" PATCH="${PATCH%%-*}" BUILD=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) echo "==> compile-ios: tag=${TAG} version=${VER} build=${BUILD}" # ── 凭证缺失则优雅跳过(不阻塞流水线;见头部说明)───────────────────────── if [ -z "${IOS_DIST_CERT_P12_BASE64:-}" ] || \ [ -z "${IOS_DIST_CERT_PASSWORD:-}" ] || \ [ -z "${IOS_APP_PROVISIONING_PROFILE_BASE64:-}" ] || \ [ -z "${IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64:-}" ] || \ [ -z "${APPSTORE_API_KEY_ID:-}" ] || \ [ -z "${APPSTORE_API_ISSUER_ID:-}" ] || \ [ -z "${APPSTORE_API_KEY_P8_BASE64:-}" ]; then echo "==> compile-ios: SKIP — iOS signing secrets not fully configured yet (not a failure)" exit 0 fi REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" API_URL="${PANGOLIN_API_URL:-https://api.yanmeiai.com}" TEAM_ID="BYL4KQHMTN" APP_BUNDLE_ID="com.pangolin.pangolinVpn" EXT_BUNDLE_ID="com.pangolin.pangolinVpn.PacketTunnel" WORK="$(mktemp -d)" KEYCHAIN="${WORK}/pangolin-ios-ci.keychain-db" PROFILES_DIR="${HOME}/Library/MobileDevice/Provisioning Profiles" mkdir -p "$PROFILES_DIR" cleanup_ios() { security delete-keychain "$KEYCHAIN" 2>/dev/null || true security list-keychains -d user -s login.keychain-db 2>/dev/null || true [ -n "${APP_PROFILE_UUID:-}" ] && rm -f "${PROFILES_DIR}/${APP_PROFILE_UUID}.mobileprovision" 2>/dev/null || true [ -n "${EXT_PROFILE_UUID:-}" ] && rm -f "${PROFILES_DIR}/${EXT_PROFILE_UUID}.mobileprovision" 2>/dev/null || true rm -rf "$WORK" } trap cleanup_ios EXIT # ── [1/6] 重建 libbox.xcframework(apple ios;gitignore 产物,每次都要重建) ── echo "==> compile-ios: building Libbox.xcframework (apple ios)" bash "${REPO_ROOT}/scripts/build-libbox.sh" apple ios LIBBOX_FW="${REPO_ROOT}/client/ios/Frameworks/Libbox.xcframework" if [ ! -d "$LIBBOX_FW" ]; then echo "ERROR: ${LIBBOX_FW} not found after build-libbox.sh — aborting." >&2 exit 1 fi # ── [2/6] 临时 keychain 导入 Apple Distribution 证书 ───────────────────────── KEYCHAIN_PWD="ci-temp-$$" security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN" printf '%s' "$IOS_DIST_CERT_P12_BASE64" | base64 --decode > "${WORK}/dist.p12" security import "${WORK}/dist.p12" -k "$KEYCHAIN" \ -P "$IOS_DIST_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null security list-keychains -d user -s "$KEYCHAIN" login.keychain-db # ── [3/6] 安装 2 个 App Store 类型描述文件(app + PacketTunnel 扩展) ───────── printf '%s' "$IOS_APP_PROVISIONING_PROFILE_BASE64" | base64 --decode > "${WORK}/app.mobileprovision" security cms -D -i "${WORK}/app.mobileprovision" > "${WORK}/app_profile.plist" /usr/libexec/PlistBuddy -c 'Print :Name' "${WORK}/app_profile.plist" > "${WORK}/app_name.txt" /usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/app_profile.plist" > "${WORK}/app_uuid.txt" APP_PROFILE_NAME="" read -r APP_PROFILE_NAME < "${WORK}/app_name.txt" || true APP_PROFILE_UUID="" read -r APP_PROFILE_UUID < "${WORK}/app_uuid.txt" || true cp "${WORK}/app.mobileprovision" "${PROFILES_DIR}/${APP_PROFILE_UUID}.mobileprovision" echo "==> compile-ios: app profile '${APP_PROFILE_NAME}' (${APP_PROFILE_UUID})" printf '%s' "$IOS_PACKETTUNNEL_PROVISIONING_PROFILE_BASE64" | base64 --decode > "${WORK}/ext.mobileprovision" security cms -D -i "${WORK}/ext.mobileprovision" > "${WORK}/ext_profile.plist" /usr/libexec/PlistBuddy -c 'Print :Name' "${WORK}/ext_profile.plist" > "${WORK}/ext_name.txt" /usr/libexec/PlistBuddy -c 'Print :UUID' "${WORK}/ext_profile.plist" > "${WORK}/ext_uuid.txt" EXT_PROFILE_NAME="" read -r EXT_PROFILE_NAME < "${WORK}/ext_name.txt" || true EXT_PROFILE_UUID="" read -r EXT_PROFILE_UUID < "${WORK}/ext_uuid.txt" || true cp "${WORK}/ext.mobileprovision" "${PROFILES_DIR}/${EXT_PROFILE_UUID}.mobileprovision" echo "==> compile-ios: PacketTunnel profile '${EXT_PROFILE_NAME}' (${EXT_PROFILE_UUID})" # ── [4/6] App Store Connect API Key(供 altool 上传使用)────────────────────── API_KEYS_DIR="${HOME}/.appstoreconnect/private_keys" mkdir -p "$API_KEYS_DIR" printf '%s' "$APPSTORE_API_KEY_P8_BASE64" | base64 --decode > "${API_KEYS_DIR}/AuthKey_${APPSTORE_API_KEY_ID}.p8" # ── [5/6] 同步版本号 + 生成 ExportOptions.plist(manual 签名,两个 bundle 都映射)── sed -i '' "s/^version:.*/version: ${VER}+${BUILD}/" "${REPO_ROOT}/client/pubspec.yaml" cat > "${WORK}/ExportOptions.plist" < methodapp-store teamID${TEAM_ID} signingStylemanual uploadBitcode uploadSymbols provisioningProfiles ${APP_BUNDLE_ID}${APP_PROFILE_NAME} ${EXT_BUNDLE_ID}${EXT_PROFILE_NAME} EOF # ── [6/6] flutter build ipa + 上传 TestFlight ─────────────────────────────── cd "${REPO_ROOT}/client" flutter_pub_get_retry # 预取包 + 重试,防 pub.flutter-io.cn 被 GFW 抖断(见 _env.sh) flutter build ipa --release \ --build-name="${VER}" \ --build-number="${BUILD}" \ --export-options-plist="${WORK}/ExportOptions.plist" \ "--dart-define=PANGOLIN_API_URL=${API_URL}" cd "$REPO_ROOT" IPA="" for cand in "${REPO_ROOT}"/client/build/ios/ipa/*.ipa; do if [ -f "$cand" ]; then IPA="$cand"; break; fi done if [ -z "$IPA" ]; then echo "ERROR: IPA not found under client/build/ios/ipa/" >&2 exit 1 fi echo "==> compile-ios: built ${IPA}" echo "==> compile-ios: uploading to TestFlight" xcrun altool --upload-app -f "$IPA" -t ios \ --apiKey "$APPSTORE_API_KEY_ID" \ --apiIssuer "$APPSTORE_API_ISSUER_ID" echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight"