diff --git a/.github/scripts/build-ios-release.sh b/.github/scripts/build-ios-release.sh new file mode 100644 index 0000000..215c03a --- /dev/null +++ b/.github/scripts/build-ios-release.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +PROJECT_NAME="GdeiAssistant-iOS" +PROJECT_FILE="${PROJECT_NAME}.xcodeproj" +SCHEME_NAME="GdeiAssistant-iOS" +BUNDLE_ID="${IOS_APP_BUNDLE_ID:-cn.gdeiassistant.GdeiAssistant-iOS}" +ARCHIVE_PATH="${IOS_ARCHIVE_PATH:-$RUNNER_TEMP/${PROJECT_NAME}.xcarchive}" +EXPORT_PATH="${IOS_EXPORT_PATH:-$RUNNER_TEMP/ios-export}" +EXPORT_OPTIONS_PLIST="${RUNNER_TEMP:-/tmp}/ExportOptions.plist" +KEYCHAIN_PATH="${RUNNER_TEMP:-/tmp}/ios-release.keychain-db" +CERTIFICATE_PATH="${RUNNER_TEMP:-/tmp}/ios-distribution.p12" +PROFILE_PATH="${RUNNER_TEMP:-/tmp}/ios-release.mobileprovision" +PROFILE_PLIST="${RUNNER_TEMP:-/tmp}/ios-release-profile.plist" +UPLOAD_TO_TESTFLIGHT="${IOS_UPLOAD_TO_TESTFLIGHT:-true}" +ORIGINAL_DEFAULT_KEYCHAIN="" +ORIGINAL_KEYCHAINS="" + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "::error::$name is required" + exit 1 + fi +} + +decode_base64() { + if printf '' | base64 --decode >/dev/null 2>&1; then + base64 --decode + else + base64 -D + fi +} + +require_env IOS_CERTIFICATE_P12_BASE64 +require_env IOS_CERTIFICATE_PASSWORD +require_env IOS_PROVISIONING_PROFILE_BASE64 +require_env IOS_DEVELOPMENT_TEAM +require_env IOS_VERSION_NAME +require_env IOS_BUILD_NUMBER + +if [[ "$UPLOAD_TO_TESTFLIGHT" == "true" ]]; then + require_env APP_STORE_CONNECT_API_KEY_ID + require_env APP_STORE_CONNECT_ISSUER_ID + require_env APP_STORE_CONNECT_API_KEY_BASE64 +fi + +cleanup() { + if [[ -n "$ORIGINAL_DEFAULT_KEYCHAIN" ]]; then + security default-keychain -d user -s "$ORIGINAL_DEFAULT_KEYCHAIN" >/dev/null 2>&1 || true + fi + if [[ -n "$ORIGINAL_KEYCHAINS" ]]; then + # shellcheck disable=SC2086 + security list-keychains -d user -s $ORIGINAL_KEYCHAINS >/dev/null 2>&1 || true + fi + security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +cd "$ROOT_DIR" + +if [[ ! -d "${DEVELOPER_DIR:-}" ]]; then + echo "::error::DEVELOPER_DIR must point to a full Xcode installation" + exit 1 +fi + +xcodebuild -version + +printf '%s' "$IOS_CERTIFICATE_P12_BASE64" | decode_base64 > "$CERTIFICATE_PATH" +printf '%s' "$IOS_PROVISIONING_PROFILE_BASE64" | decode_base64 > "$PROFILE_PATH" +security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST" + +PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print UUID' "$PROFILE_PLIST")" +PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print Name' "$PROFILE_PLIST")" +PROFILE_TEAM="$(/usr/libexec/PlistBuddy -c 'Print TeamIdentifier:0' "$PROFILE_PLIST")" + +if [[ "$PROFILE_TEAM" != "$IOS_DEVELOPMENT_TEAM" ]]; then + echo "::error::Provisioning profile team $PROFILE_TEAM does not match IOS_DEVELOPMENT_TEAM $IOS_DEVELOPMENT_TEAM" + exit 1 +fi + +KEYCHAIN_PASSWORD="$(uuidgen)" +ORIGINAL_DEFAULT_KEYCHAIN="$(security default-keychain -d user | tr -d '"')" +ORIGINAL_KEYCHAINS="$(security list-keychains -d user | tr -d '"')" +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" +security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" +security default-keychain -d user -s "$KEYCHAIN_PATH" +# shellcheck disable=SC2086 +security list-keychains -d user -s "$KEYCHAIN_PATH" $ORIGINAL_KEYCHAINS +security import "$CERTIFICATE_PATH" \ + -k "$KEYCHAIN_PATH" \ + -P "$IOS_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + +mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles" +cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/${PROFILE_UUID}.mobileprovision" + +python3 - "$EXPORT_OPTIONS_PLIST" "$BUNDLE_ID" "$PROFILE_NAME" "$IOS_DEVELOPMENT_TEAM" <<'PY' +import plistlib +import sys + +export_options_path, bundle_id, profile_name, team_id = sys.argv[1:5] +export_options = { + "destination": "export", + "method": "app-store-connect", + "provisioningProfiles": {bundle_id: profile_name}, + "signingCertificate": "Apple Distribution", + "signingStyle": "manual", + "stripSwiftSymbols": True, + "teamID": team_id, + "uploadSymbols": True, +} + +with open(export_options_path, "wb") as plist_file: + plistlib.dump(export_options, plist_file) +PY + +rm -rf "$ARCHIVE_PATH" "$EXPORT_PATH" +mkdir -p "$EXPORT_PATH" + +xcodebuild archive \ + -project "$PROJECT_FILE" \ + -scheme "$SCHEME_NAME" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath "$ARCHIVE_PATH" \ + DEVELOPMENT_TEAM="$IOS_DEVELOPMENT_TEAM" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + PROVISIONING_PROFILE_SPECIFIER="$PROFILE_NAME" \ + MARKETING_VERSION="$IOS_VERSION_NAME" \ + CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" + +xcodebuild -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_PATH" \ + -exportOptionsPlist "$EXPORT_OPTIONS_PLIST" + +IPA_PATH="$(find "$EXPORT_PATH" -maxdepth 1 -name '*.ipa' -print -quit)" +if [[ -z "$IPA_PATH" ]]; then + echo "::error::No IPA was exported to $EXPORT_PATH" + exit 1 +fi + +echo "Exported IPA: $IPA_PATH" + +if [[ "$UPLOAD_TO_TESTFLIGHT" != "true" ]]; then + echo "Skipping TestFlight upload because IOS_UPLOAD_TO_TESTFLIGHT is not true." + exit 0 +fi + +AUTH_KEY_DIR="$HOME/.appstoreconnect/private_keys" +AUTH_KEY_PATH="$AUTH_KEY_DIR/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8" +mkdir -p "$AUTH_KEY_DIR" +printf '%s' "$APP_STORE_CONNECT_API_KEY_BASE64" | decode_base64 > "$AUTH_KEY_PATH" +chmod 600 "$AUTH_KEY_PATH" + +xcrun altool --upload-app \ + --type ios \ + --file "$IPA_PATH" \ + --apiKey "$APP_STORE_CONNECT_API_KEY_ID" \ + --apiIssuer "$APP_STORE_CONNECT_ISSUER_ID" \ + --show-progress diff --git a/.github/workflows/ios-release.yml b/.github/workflows/ios-release.yml new file mode 100644 index 0000000..73e3374 --- /dev/null +++ b/.github/workflows/ios-release.yml @@ -0,0 +1,78 @@ +name: iOS TestFlight Release + +on: + workflow_dispatch: + inputs: + version: + description: iOS marketing version, for example 1.0.1 + required: true + default: "1.0" + type: string + build_number: + description: iOS build number + required: true + default: "1" + type: string + upload_to_testflight: + description: Upload the exported IPA to TestFlight + required: true + default: true + type: boolean + +jobs: + testflight: + name: Build and Upload + runs-on: macos-15 + environment: ios-production + permissions: + contents: read + env: + DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer + IOS_APP_BUNDLE_ID: ${{ vars.IOS_APP_BUNDLE_ID || 'cn.gdeiassistant.GdeiAssistant-iOS' }} + IOS_VERSION_NAME: ${{ inputs.version }} + IOS_BUILD_NUMBER: ${{ inputs.build_number }} + IOS_UPLOAD_TO_TESTFLIGHT: ${{ inputs.upload_to_testflight }} + IOS_ARCHIVE_PATH: ${{ runner.temp }}/GdeiAssistant-iOS.xcarchive + IOS_EXPORT_PATH: ${{ runner.temp }}/ios-export + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Select pinned Xcode + run: | + if [[ ! -d "$DEVELOPER_DIR" ]]; then + echo "Expected Xcode not found: $DEVELOPER_DIR" + ls -d /Applications/Xcode*.app || true + exit 1 + fi + xcodebuild -version + + - name: Run Swift style checks + run: | + chmod +x Tools/check_style.sh + ./Tools/check_style.sh + + - name: Build and upload release + env: + IOS_CERTIFICATE_P12_BASE64: ${{ secrets.IOS_CERTIFICATE_P12_BASE64 }} + IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} + IOS_PROVISIONING_PROFILE_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }} + IOS_DEVELOPMENT_TEAM: ${{ secrets.IOS_DEVELOPMENT_TEAM }} + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_BASE64 }} + run: | + chmod +x .github/scripts/build-ios-release.sh + .github/scripts/build-ios-release.sh + + - name: Upload IPA artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: ios-release-${{ inputs.version }}-${{ inputs.build_number }} + path: | + ${{ runner.temp }}/ios-export/*.ipa + ${{ runner.temp }}/GdeiAssistant-iOS.xcarchive/dSYMs + if-no-files-found: ignore + retention-days: 30 diff --git a/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift b/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift index abc3051..099c1fd 100644 --- a/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift +++ b/GdeiAssistant-iOS/Core/Config/AppEnvironment.swift @@ -54,13 +54,14 @@ final class AppEnvironment: ObservableObject { clientType: String? = nil ) { let resolvedIsDebug = isDebug ?? _isDebugAssertConfiguration() - self.isDebug = resolvedIsDebug - self.clientType = clientType ?? AppConstants.API.clientType - self.networkEnvironment = Self.sanitizedNetworkEnvironment( + let resolvedNetworkEnvironment = Self.sanitizedNetworkEnvironment( networkEnvironment, isDebug: resolvedIsDebug ) - self.baseURL = self.networkEnvironment.baseURL + self.isDebug = resolvedIsDebug + self.clientType = clientType ?? AppConstants.API.clientType + self.networkEnvironment = resolvedNetworkEnvironment + self.baseURL = resolvedNetworkEnvironment.baseURL self.dataSourceMode = Self.sanitizedDataSourceMode( dataSourceMode, isDebug: resolvedIsDebug diff --git a/README.md b/README.md index 710a956..b299371 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,29 @@ xcodebuild -project GdeiAssistant-iOS.xcodeproj \ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build ``` -### 3. 运行方式 +### 3. 发布 TestFlight + +仓库提供 `iOS TestFlight Release` GitHub Actions 手动工作流,用于签名归档、 +导出 IPA,并按需上传到 TestFlight。发布前需要在仓库或 `ios-production` +环境中配置: + +- `IOS_CERTIFICATE_P12_BASE64`:Apple Distribution 证书 p12 的 base64 内容 +- `IOS_CERTIFICATE_PASSWORD`:p12 密码 +- `IOS_PROVISIONING_PROFILE_BASE64`:App Store provisioning profile 的 base64 内容 +- `IOS_DEVELOPMENT_TEAM`:Apple Developer Team ID +- `APP_STORE_CONNECT_API_KEY_ID`:App Store Connect API Key ID +- `APP_STORE_CONNECT_ISSUER_ID`:App Store Connect Issuer ID +- `APP_STORE_CONNECT_API_KEY_BASE64`:App Store Connect `.p8` 私钥的 base64 内容 + +可选变量: + +- `IOS_APP_BUNDLE_ID`:覆盖默认 Bundle ID,默认 `cn.gdeiassistant.GdeiAssistant-iOS` + +工作流输入包括 `version`、`build_number` 和是否上传到 TestFlight。工作流会先运行 +Swift 样式检查,再执行 Release archive/export;即使跳过 TestFlight 上传,也会保留 +导出的 IPA artifact 便于排查签名问题。 + +### 4. 运行方式 - `mock` 模式:适合本地联调 UI 和主链路验证 - `remote` 模式:适合接入真实后端接口