Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions .github/scripts/build-ios-release.sh
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions .github/workflows/ios-release.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 5 additions & 4 deletions GdeiAssistant-iOS/Core/Config/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 模式:适合接入真实后端接口
Expand Down