name: Expo App Internal # 仅内测 artifact;与 eeui 正式发布并行,不创建 Release,也不上传 R2 / 商店。 on: workflow_dispatch: inputs: platform: description: 构建平台 type: choice options: [all, android, ios] default: all ios_export_method: description: iOS profiles 类型(必须包含测试设备 UDID) type: choice options: [ad-hoc, development] default: ad-hoc permissions: contents: read concurrency: group: app-internal-${{ github.ref }} cancel-in-progress: false env: CI: '1' NODE_ENV: production # 内测 Release 必须使用包内前端,禁止开发机 .env 覆盖 WebView 地址。 EXPO_NO_DOTENV: '1' EXPO_PUBLIC_WEB_URL: '' jobs: prepare: name: Prepare bundled web assets runs-on: ubuntu-latest timeout-minutes: 30 # 同名 iOS secrets 放在独立环境,避免覆盖 build 环境中的 App Store profiles。 environment: app-internal outputs: version: ${{ steps.version.outputs.version }} ios_ready: ${{ steps.signing.outputs.ios_ready }} steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Check Expo checkout run: test -f resources/app/app.config.ts - uses: actions/setup-node@v4 with: # Expo 58 / RN 0.88 需要 Node ^22.13 / ^24.3 或更新版本。 node-version: '24' - name: Read version id: version run: node -e 'require("fs").appendFileSync(process.env.GITHUB_OUTPUT, `version=${require("./package.json").version}\n`)' - name: Check iOS signing inputs id: signing env: IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }} BUILD_PLATFORM: ${{ inputs.platform }} run: | if [ "$BUILD_PLATFORM" = android ]; then echo 'ios_ready=false' >> "$GITHUB_OUTPUT" elif [ -n "$IOS_CERTIFICATE_BASE64" ] && [ -n "$IOS_PROVISION_PROFILE_BASE64" ] && [ -n "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" ]; then echo 'ios_ready=true' >> "$GITHUB_OUTPUT" else echo 'ios_ready=false' >> "$GITHUB_OUTPUT" echo '::notice::跳过 iOS:缺少签名证书或主 App / 分享扩展 profile。' echo 'iOS 已跳过:需要 IOS_CERTIFICATE_BASE64、IOS_PROVISION_PROFILE_BASE64、IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64;密码通过 IOS_CERTIFICATE_PASSWORD 提供。profiles 必须为包含设备 UDID 的内测类型,不能使用 App Store profiles。' >> "$GITHUB_STEP_SUMMARY" fi - name: Install frontend dependencies # 主仓库没有追踪 npm lockfile;须显式包含 Vite 等 devDependencies。 run: npm install --include=dev --no-package-lock --no-audit --no-fund - name: Install build script dependencies working-directory: electron env: ELECTRON_SKIP_BINARY_DOWNLOAD: '1' run: npm install --include=dev --no-package-lock --no-audit --no-fund - name: Build and copy web assets run: ./cmd appexpo build - name: Upload bundled web assets uses: actions/upload-artifact@v4 with: name: expo-web-assets path: | resources/app/assets/web/ resources/app/version.json if-no-files-found: error retention-days: 7 android: name: Android internal APK needs: prepare if: inputs.platform != 'ios' runs-on: ubuntu-latest timeout-minutes: 90 environment: app-internal steps: - uses: actions/checkout@v4 with: submodules: recursive - uses: actions/download-artifact@v4 with: name: expo-web-assets path: resources/app - uses: actions/setup-node@v4 with: node-version: '24' cache: npm cache-dependency-path: resources/app/package-lock.json - uses: actions/setup-java@v4 with: distribution: temurin java-version: '17' - uses: android-actions/setup-android@v3 - name: Install Expo dependencies working-directory: resources/app run: npm ci --include=dev --no-audit --no-fund - name: Prebuild Android working-directory: resources/app run: npx expo prebuild --platform android --clean --no-install - name: Prepare optional Android keystore id: signing env: # 旧 publish.yml 未定义 Android 签名 secrets;这四项仅供新链路使用。 ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | if [ -n "$ANDROID_KEYSTORE_BASE64" ] && [ -n "$ANDROID_KEYSTORE_PASSWORD" ] && [ -n "$ANDROID_KEY_ALIAS" ] && [ -n "$ANDROID_KEY_PASSWORD" ]; then umask 077 echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/dootask-internal.jks" echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/dootask-internal.jks" >> "$GITHUB_ENV" echo 'mode=keystore' >> "$GITHUB_OUTPUT" echo 'Android Release 包使用提供的 keystore 签名。' >> "$GITHUB_STEP_SUMMARY" else echo 'mode=debug' >> "$GITHUB_OUTPUT" echo '::notice::Android 签名 secrets 不完整,Release 包使用 debug 签名。' echo 'Android Release 包使用 debug 签名(签名 secrets 缺失或不完整),用于全新安装内测;与正式包签名不同,不能直接覆盖正式安装。' >> "$GITHUB_STEP_SUMMARY" fi - name: Assemble Release working-directory: resources/app/android env: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: ./gradlew :app:assembleRelease --no-daemon --init-script ../scripts/android-signing.gradle - name: Upload APK uses: actions/upload-artifact@v4 with: name: dootask-expo-android-${{ needs.prepare.outputs.version }}-${{ steps.signing.outputs.mode }} path: resources/app/android/app/build/outputs/apk/release/*.apk if-no-files-found: error retention-days: 14 - name: Remove Android keystore if: always() run: rm -f "$RUNNER_TEMP/dootask-internal.jks" ios: name: iOS internal IPA needs: prepare if: needs.prepare.outputs.ios_ready == 'true' runs-on: macos-26 timeout-minutes: 90 environment: app-internal env: IOS_EXPORT_METHOD: ${{ inputs.ios_export_method }} steps: - uses: actions/checkout@v4 with: submodules: recursive - uses: actions/download-artifact@v4 with: name: expo-web-assets path: resources/app - uses: actions/setup-node@v4 with: node-version: '24' cache: npm cache-dependency-path: resources/app/package-lock.json - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: Install Expo dependencies working-directory: resources/app run: npm ci --include=dev --no-audit --no-fund - name: Prebuild iOS working-directory: resources/app run: npx expo prebuild --platform ios --clean --no-install - name: Install CocoaPods working-directory: resources/app/ios run: pod install - name: Import signing certificate env: IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} run: | set -euo pipefail umask 077 SIGNING_DIR="$RUNNER_TEMP/dootask-ios-signing" mkdir -p "$SIGNING_DIR" KEYCHAIN_PATH="$SIGNING_DIR/signing.keychain-db" KEYCHAIN_PASSWORD=$(openssl rand -hex 20) security list-keychains -d user > "$SIGNING_DIR/original-keychains.txt" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$SIGNING_DIR/certificate.p12" security import "$SIGNING_DIR/certificate.p12" -P "$IOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db - name: Install and validate internal profiles working-directory: resources/app env: IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }} run: python3 scripts/prepare-ios-signing.py "$RUNNER_TEMP/dootask-ios-signing" - name: Configure product target signing via Expo mod working-directory: resources/app run: node scripts/configure-ios-signing.js "$RUNNER_TEMP/dootask-ios-signing/signing.json" - name: Archive iOS Release working-directory: resources/app run: | xcodebuild archive \ -workspace ios/DooTask.xcworkspace \ -scheme DooTask \ -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath "$RUNNER_TEMP/DooTask.xcarchive" \ -derivedDataPath "$RUNNER_TEMP/dootask-derived-data" - name: Export internal IPA run: | xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/DooTask.xcarchive" \ -exportOptionsPlist "$RUNNER_TEMP/dootask-ios-signing/ExportOptions.plist" \ -exportPath "$RUNNER_TEMP/dootask-ipa" - name: Upload IPA uses: actions/upload-artifact@v4 with: name: dootask-expo-ios-${{ needs.prepare.outputs.version }}-${{ inputs.ios_export_method }} path: ${{ runner.temp }}/dootask-ipa/*.ipa if-no-files-found: error retention-days: 14 - name: Remove signing material if: always() run: | SIGNING_DIR="$RUNNER_TEMP/dootask-ios-signing" if [ -f "$SIGNING_DIR/signing.json" ]; then python3 - "$SIGNING_DIR/signing.json" <<'PY' import json from pathlib import Path import sys for filename in json.loads(Path(sys.argv[1]).read_text())["installed_profiles"]: Path(filename).unlink(missing_ok=True) PY fi if [ -f "$SIGNING_DIR/original-keychains.txt" ]; then python3 - "$SIGNING_DIR/original-keychains.txt" <<'PY' from pathlib import Path import shlex import subprocess import sys subprocess.run(["security", "list-keychains", "-d", "user", "-s", *shlex.split(Path(sys.argv[1]).read_text())], check=True) PY fi security delete-keychain "$SIGNING_DIR/signing.keychain-db" 2>/dev/null || true rm -rf "$SIGNING_DIR"