diff --git a/.github/workflows/app-build.yml b/.github/workflows/app-build.yml new file mode 100644 index 000000000..40547a10f --- /dev/null +++ b/.github/workflows/app-build.yml @@ -0,0 +1,407 @@ +name: "App Build" + +# Expo App(resources/app 子模块)构建,供 publish.yml(Android 发版)、ios-publish.yml(App Store) +# 与 app-internal.yml(内测)调用;只产出 artifact(app-android / app-ios),上传发布由调用方负责。 +# +# Secrets(仓库级,调用方 secrets: inherit): +# ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD Android 正式签名 +# IOS_CERTIFICATE_BASE64 / IOS_CERTIFICATE_PASSWORD Apple Distribution 证书 +# IOS_PROVISION_PROFILE_BASE64 / IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 App Store profiles +# IOS_INTERNAL_PROVISION_PROFILE_BASE64 / IOS_INTERNAL_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 内测 profiles(可选) +# ASC_API_KEY_ID / ASC_ISSUER_ID / ASC_API_KEY_P8_BASE64 App Store 构建号查询 + +on: + workflow_call: + inputs: + platform: + description: all / android / ios + type: string + default: all + ios_export_method: + description: app-store / ad-hoc / development + type: string + default: ad-hoc + release: + description: 正式发布:签名缺失或不符时失败,不退化为 debug 签名或跳过 + type: boolean + default: false + outputs: + version: + value: ${{ jobs.prepare.outputs.version }} + +permissions: + contents: read + +env: + CI: '1' + NODE_ENV: production + # 必须使用包内前端,禁止开发机 .env 覆盖 WebView 地址 + EXPO_NO_DOTENV: '1' + EXPO_PUBLIC_WEB_URL: '' + # 旧版正式包(eeui)使用的签名证书;新包必须一致才能覆盖升级 + ANDROID_CERT_SHA256: CE:30:13:6F:B9:28:56:13:C7:41:CB:CE:E3:6B:3C:DB:1F:01:D5:26:A7:3F:17:4E:1E:B2:B8:37:F1:E4:A2:B6 + +jobs: + prepare: + name: Prepare bundled web assets + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + version: ${{ steps.version.outputs.version }} + ios_ready: ${{ steps.signing.outputs.ios_ready }} + steps: + - uses: actions/checkout@v4 + + # 只取 Expo 子模块(drawio 体积大且与 App 无关),使用主仓库锁定的提交 + - name: Checkout Expo submodule + run: git submodule update --init resources/app + + - 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: + PLATFORM: ${{ inputs.platform }} + RELEASE: ${{ inputs.release }} + IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} + APP_PROFILE: ${{ inputs.ios_export_method == 'app-store' && secrets.IOS_PROVISION_PROFILE_BASE64 || secrets.IOS_INTERNAL_PROVISION_PROFILE_BASE64 }} + SHARE_PROFILE: ${{ inputs.ios_export_method == 'app-store' && secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 || secrets.IOS_INTERNAL_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }} + run: | + if [ "$PLATFORM" = android ]; then + echo 'ios_ready=false' >> "$GITHUB_OUTPUT" + elif [ -n "$IOS_CERTIFICATE_BASE64" ] && [ -n "$APP_PROFILE" ] && [ -n "$SHARE_PROFILE" ]; then + echo 'ios_ready=true' >> "$GITHUB_OUTPUT" + elif [ "$RELEASE" = true ]; then + echo '::error::缺少 iOS 证书或 App Store profiles(IOS_CERTIFICATE_BASE64 / IOS_PROVISION_PROFILE_BASE64 / IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64)' + exit 1 + else + echo 'ios_ready=false' >> "$GITHUB_OUTPUT" + echo '::notice::跳过 iOS:缺少证书或内测 profiles(IOS_INTERNAL_PROVISION_PROFILE_BASE64 / IOS_INTERNAL_SHARE_EXTENSION_PROVISION_PROFILE_BASE64)' + 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: app-web-assets + path: | + resources/app/assets/web/ + resources/app/version.json + if-no-files-found: error + retention-days: 3 + + android: + name: Android APK + needs: prepare + if: inputs.platform != 'ios' + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Free disk space + run: sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/share/boost /opt/hostedtoolcache/CodeQL + + - uses: actions/checkout@v4 + + - name: Checkout Expo submodule + run: git submodule update --init resources/app + + - uses: actions/download-artifact@v4 + with: + name: app-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 Android keystore + id: signing + env: + RELEASE: ${{ inputs.release }} + 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-release.jks" + echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/dootask-release.jks" >> "$GITHUB_ENV" + echo 'mode=release' >> "$GITHUB_OUTPUT" + elif [ "$RELEASE" = true ]; then + echo '::error::缺少 Android 签名 secrets(ANDROID_KEYSTORE_BASE64 / ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD)' + exit 1 + else + echo 'mode=debug' >> "$GITHUB_OUTPUT" + echo '::notice::Android 签名 secrets 不完整,使用 debug 签名(不能覆盖安装正式包)' + 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 }} + # R8 混淆元空间占用大,默认 512m 会 OOM + run: ./gradlew :app:assembleRelease --no-daemon --init-script ../scripts/android-signing.gradle "-Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=2g" + + - name: Rename and verify APK + env: + VERSION: ${{ needs.prepare.outputs.version }} + MODE: ${{ steps.signing.outputs.mode }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/apk" + # 文件名与旧版一致:官网下载索引与 App 检查更新按 android 平台取 .apk + apk="$RUNNER_TEMP/apk/DooTask-v${VERSION}-android.apk" + mv resources/app/android/app/build/outputs/apk/release/app-release.apk "$apk" + apksigner=$(ls "$ANDROID_HOME"/build-tools/*/apksigner | sort -V | tail -1) + "$apksigner" verify "$apk" + if [ "$MODE" = release ]; then + actual=$("$apksigner" verify --print-certs "$apk" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p' | head -1) + expected=$(echo "$ANDROID_CERT_SHA256" | tr -d ':' | tr 'A-F' 'a-f') + if [ "$actual" != "$expected" ]; then + echo "::error::APK 签名证书与正式包不一致($actual),无法覆盖升级" + exit 1 + fi + fi + ls -la "$RUNNER_TEMP/apk" + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: app-android + path: ${{ runner.temp }}/apk/*.apk + if-no-files-found: error + retention-days: 14 + + - name: Remove Android keystore + if: always() + run: rm -f "$RUNNER_TEMP/dootask-release.jks" + + ios: + name: iOS IPA + needs: prepare + if: needs.prepare.outputs.ios_ready == 'true' + runs-on: macos-26 + timeout-minutes: 120 + env: + IOS_EXPORT_METHOD: ${{ inputs.ios_export_method }} + steps: + - uses: actions/checkout@v4 + + - name: Checkout Expo submodule + run: git submodule update --init resources/app + + - uses: actions/download-artifact@v4 + with: + name: app-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: Resolve App Store build number + if: inputs.ios_export_method == 'app-store' + env: + ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} + ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} + run: | + set -euo pipefail + ruby <<'RUBY' + require 'base64' + require 'json' + require 'net/http' + require 'openssl' + require 'uri' + + BUNDLE_ID = 'com.dootask.task' + + def base64url(value) + Base64.urlsafe_encode64(value).delete('=') + end + + def asc_token + private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64'))) + now = Time.now.to_i + header = { alg: 'ES256', kid: ENV.fetch('ASC_API_KEY_ID'), typ: 'JWT' } + payload = { iss: ENV.fetch('ASC_ISSUER_ID'), iat: now, exp: now + 20 * 60, aud: 'appstoreconnect-v1' } + unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}" + # ES256 签名须为 r||s 各 32 字节,而非 DER + der = OpenSSL::ASN1.decode(private_key.sign('SHA256', unsigned)) + signature = der.value.map { |integer| integer.value.to_s(2).rjust(32, "\0")[-32, 32] }.join + "#{unsigned}.#{base64url(signature)}" + end + + def asc_get(path, params, token) + uri = URI::HTTPS.build(host: 'api.appstoreconnect.apple.com', path: path, query: URI.encode_www_form(params)) + loop do + response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| + request = Net::HTTP::Get.new(uri) + request['Authorization'] = "Bearer #{token}" + http.request(request) + end + abort "App Store Connect API request failed: #{response.code} #{response.body}" unless response.is_a?(Net::HTTPSuccess) + parsed = JSON.parse(response.body) + yield parsed + next_link = parsed.dig('links', 'next') + break unless next_link + uri = URI(next_link) + end + end + + token = asc_token + app_id = nil + asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page| + app_id = page.fetch('data').first&.fetch('id') + end + abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id + + versions = [] + asc_get('/v1/builds', { 'filter[app]' => app_id, 'fields[builds]' => 'version', 'limit' => 200 }, token) do |page| + versions.concat(page.fetch('data').map { |build| build.dig('attributes', 'version').to_s }) + end + max_build = versions.select { |v| v.match?(/\A\d+\z/) }.map(&:to_i).max || 0 + code = JSON.parse(File.read('resources/app/version.json')).fetch('codeVerson').to_i + next_build = [max_build + 1, code].max + File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build}" } + puts "App Store Connect 最大构建号 #{max_build},本次构建号 #{next_build}" + RUBY + + - 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 profiles + working-directory: resources/app + env: + IOS_PROVISION_PROFILE_BASE64: ${{ inputs.ios_export_method == 'app-store' && secrets.IOS_PROVISION_PROFILE_BASE64 || secrets.IOS_INTERNAL_PROVISION_PROFILE_BASE64 }} + IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ inputs.ios_export_method == 'app-store' && secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 || secrets.IOS_INTERNAL_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 -quiet \ + -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 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: app-ios + 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" diff --git a/.github/workflows/app-internal.yml b/.github/workflows/app-internal.yml index a83ac6e24..ea22d661a 100644 --- a/.github/workflows/app-internal.yml +++ b/.github/workflows/app-internal.yml @@ -1,6 +1,8 @@ -name: Expo App Internal +name: "App Internal" -# 仅内测 artifact;与 eeui 正式发布并行,不创建 Release,也不上传 R2 / 商店。 +# 内测出包:只产出 artifact,不创建 Release,也不上传 R2 / 商店。 +# Android 使用正式签名(可覆盖安装正式包);iOS 需另配内测 profiles(含测试设备 UDID),未配置时跳过: +# IOS_INTERNAL_PROVISION_PROFILE_BASE64 / IOS_INTERNAL_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 on: workflow_dispatch: inputs: @@ -10,7 +12,7 @@ on: options: [all, android, ios] default: all ios_export_method: - description: iOS profiles 类型(必须包含测试设备 UDID) + description: iOS 内测 profiles 类型(必须包含测试设备 UDID) type: choice options: [ad-hoc, development] default: ad-hoc @@ -22,279 +24,11 @@ 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" + build: + name: Build + uses: ./.github/workflows/app-build.yml + with: + platform: ${{ inputs.platform || 'all' }} + ios_export_method: ${{ inputs.ios_export_method || 'ad-hoc' }} + secrets: inherit diff --git a/.github/workflows/ios-publish.yml b/.github/workflows/ios-publish.yml index 25ddebfe0..c207f551a 100644 --- a/.github/workflows/ios-publish.yml +++ b/.github/workflows/ios-publish.yml @@ -1,5 +1,7 @@ name: "iOS Publish" +# 构建 Expo App 并上传 App Store Connect(之后在 App Store Connect 提交审核)。 +# # Required GitHub Secrets: # # IOS_CERTIFICATE_BASE64 - Apple distribution certificate (.p12) encoded in base64 @@ -12,6 +14,11 @@ name: "iOS Publish" on: workflow_dispatch: + inputs: + upload: + description: 上传到 App Store Connect(关闭则只构建 IPA artifact) + type: boolean + default: true permissions: contents: read @@ -21,198 +28,34 @@ concurrency: cancel-in-progress: false jobs: - prepare-assets: - name: Prepare iOS Assets - runs-on: ubuntu-latest - timeout-minutes: 30 - outputs: - version: ${{ steps.get-version.outputs.version }} + build: + name: Build + uses: ./.github/workflows/app-build.yml + with: + platform: ios + ios_export_method: app-store + release: true + secrets: inherit - steps: - - uses: actions/checkout@v4 - - - name: Get version from package.json - id: get-version - run: | - VERSION=$(node -p "require('./package.json').version") - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install dependencies - run: npm install - - - name: Install electron dependencies - run: | - pushd electron - npm install - popd - - - name: Init mobile submodule - run: | - git submodule init - git submodule update --remote "resources/mobile" - - - name: Build app assets - run: ./cmd appbuild publish - - - name: Upload iOS platform artifacts - uses: actions/upload-artifact@v4 - with: - name: ios-platform - path: resources/mobile/platforms/ios/ - retention-days: 1 - - build-ios: - name: Build & Submit iOS - needs: prepare-assets + submit: + name: Submit to App Store Connect + needs: build + if: inputs.upload runs-on: macos-26 timeout-minutes: 60 environment: build steps: - - uses: actions/checkout@v4 - - - name: Init mobile submodule - run: | - git submodule init - git submodule update --remote "resources/mobile" - - - name: Download prepared assets - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: - name: ios-platform - path: resources/mobile/platforms/ios/ + name: app-ios + path: ipa-output - - name: Select Xcode - uses: maxim-lobanov/setup-xcode@v1 + - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - - name: Install CocoaPods - run: | - if [ -f "resources/mobile/platforms/ios/eeuiApp/Podfile" ]; then - cd resources/mobile/platforms/ios/eeuiApp - pod install - fi - - - name: Import signing certificate - env: - IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} - IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} - run: | - # Create temporary keychain - KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - KEYCHAIN_PASSWORD=$(openssl rand -hex 20) - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Import certificate - CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 - echo "$IOS_CERTIFICATE_BASE64" | base64 --decode > "$CERTIFICATE_PATH" - security import "$CERTIFICATE_PATH" \ - -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-keychain -d user -s "$KEYCHAIN_PATH" - - - name: Import provisioning profile - env: - IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} - IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64 }} - run: | - set -euo pipefail - - APP_PROFILE_PATH=$RUNNER_TEMP/app.mobileprovision - SHARE_PROFILE_PATH=$RUNNER_TEMP/share-extension.mobileprovision - APP_PROFILE_PLIST=$RUNNER_TEMP/app-profile.plist - SHARE_PROFILE_PLIST=$RUNNER_TEMP/share-extension-profile.plist - - echo "$IOS_PROVISION_PROFILE_BASE64" | base64 --decode > "$APP_PROFILE_PATH" - echo "$IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64" | base64 --decode > "$SHARE_PROFILE_PATH" - - security cms -D -i "$APP_PROFILE_PATH" > "$APP_PROFILE_PLIST" - security cms -D -i "$SHARE_PROFILE_PATH" > "$SHARE_PROFILE_PLIST" - - APP_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$APP_PROFILE_PLIST") - SHARE_PROFILE_NAME=$(/usr/libexec/PlistBuddy -c "Print :Name" "$SHARE_PROFILE_PLIST") - IOS_TEAM_ID=$(/usr/libexec/PlistBuddy -c "Print :TeamIdentifier:0" "$APP_PROFILE_PLIST") - APP_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$APP_PROFILE_PLIST") - SHARE_PROFILE_APP_ID=$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$SHARE_PROFILE_PLIST") - - if [ "$APP_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task" ]; then - echo "Expected app profile for $IOS_TEAM_ID.com.dootask.task, got $APP_PROFILE_APP_ID" - exit 1 - fi - - if [ "$SHARE_PROFILE_APP_ID" != "$IOS_TEAM_ID.com.dootask.task.shareExtension" ]; then - echo "Expected share extension profile for $IOS_TEAM_ID.com.dootask.task.shareExtension, got $SHARE_PROFILE_APP_ID" - exit 1 - fi - - if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$APP_PROFILE_PLIST" >/dev/null; then - echo "The DooTask app profile must include Push Notifications." - exit 1 - fi - - if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$APP_PROFILE_PLIST" | grep -q "group.im.dootask"; then - echo "The DooTask app profile must include App Group group.im.dootask." - exit 1 - fi - - if ! /usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.security.application-groups" "$SHARE_PROFILE_PLIST" | grep -q "group.im.dootask"; then - echo "The share extension profile must include App Group group.im.dootask." - exit 1 - fi - - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - cp "$APP_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ - cp "$SHARE_PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/ - - echo "APP_PROFILE_NAME=$APP_PROFILE_NAME" >> $GITHUB_ENV - echo "SHARE_PROFILE_NAME=$SHARE_PROFILE_NAME" >> $GITHUB_ENV - echo "IOS_TEAM_ID=$IOS_TEAM_ID" >> $GITHUB_ENV - - - name: Configure manual signing - run: | - set -euo pipefail - - ruby <<'RUBY' - require 'xcodeproj' - - project_path = 'resources/mobile/platforms/ios/eeuiApp/eeuiApp.xcodeproj' - project = Xcodeproj::Project.open(project_path) - - { - 'DooTask' => ENV.fetch('APP_PROFILE_NAME'), - 'ShareExtension' => ENV.fetch('SHARE_PROFILE_NAME') - }.each do |target_name, profile_name| - target = project.targets.find { |item| item.name == target_name } - abort "Target #{target_name} not found in #{project_path}" unless target - - target.build_configurations.each do |config| - next unless config.name == 'Release' - - config.build_settings['CODE_SIGN_STYLE'] = 'Manual' - config.build_settings['DEVELOPMENT_TEAM'] = ENV.fetch('IOS_TEAM_ID') - config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution' - config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile_name - end - end - - project.save - RUBY - - - name: Resolve iOS build number + - name: Upload IPA env: ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} @@ -220,196 +63,12 @@ jobs: run: | set -euo pipefail - ruby <<'RUBY' - require 'base64' - require 'json' - require 'net/http' - require 'openssl' - require 'uri' - - BUNDLE_ID = 'com.dootask.task' - VERSION_CONFIG_PATH = 'resources/mobile/platforms/ios/eeuiApp/Config/Version.xcconfig' - - def base64url(value) - Base64.urlsafe_encode64(value).delete('=') - end - - def jwt_es256_signature(private_key, unsigned) - der_signature = private_key.sign('SHA256', unsigned) - sequence = OpenSSL::ASN1.decode(der_signature) - - sequence.value.map { |integer| - integer.value.to_s(2).rjust(32, "\0")[-32, 32] - }.join - end - - def asc_token - key_id = ENV.fetch('ASC_API_KEY_ID') - issuer_id = ENV.fetch('ASC_ISSUER_ID') - private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch('ASC_API_KEY_P8_BASE64'))) - now = Time.now.to_i - - header = { alg: 'ES256', kid: key_id, typ: 'JWT' } - payload = { - iss: issuer_id, - iat: now, - exp: now + 20 * 60, - aud: 'appstoreconnect-v1' - } - - unsigned = "#{base64url(header.to_json)}.#{base64url(payload.to_json)}" - signature = jwt_es256_signature(private_key, unsigned) - "#{unsigned}.#{base64url(signature)}" - end - - def asc_get(path, params, token) - uri = URI::HTTPS.build( - host: 'api.appstoreconnect.apple.com', - path: path, - query: URI.encode_www_form(params) - ) - - request_uri = uri - loop do - response = Net::HTTP.start(request_uri.host, request_uri.port, use_ssl: true) do |http| - request = Net::HTTP::Get.new(request_uri) - request['Authorization'] = "Bearer #{token}" - http.request(request) - end - - unless response.is_a?(Net::HTTPSuccess) - abort "App Store Connect API request failed: #{response.code} #{response.body}" - end - - parsed = JSON.parse(response.body) - yield parsed - - next_link = parsed.dig('links', 'next') - break unless next_link - - request_uri = URI(next_link) - end - end - - token = asc_token - app_id = nil - - asc_get('/v1/apps', { 'filter[bundleId]' => BUNDLE_ID, 'limit' => 1 }, token) do |page| - app_id = page.fetch('data').first&.fetch('id') - end - - abort "App Store Connect app not found for bundle id #{BUNDLE_ID}" unless app_id - - existing_versions = [] - asc_get('/v1/builds', { - 'filter[app]' => app_id, - 'fields[builds]' => 'version', - 'limit' => 200 - }, token) do |page| - existing_versions.concat( - page.fetch('data').map { |build| build.dig('attributes', 'version').to_s } - ) - end - - max_build_number = existing_versions - .select { |version| version.match?(/\A\d+\z/) } - .map(&:to_i) - .max || 0 - - next_build_number = max_build_number + 1 - config_content = File.exist?(VERSION_CONFIG_PATH) ? File.read(VERSION_CONFIG_PATH) : '' - - if config_content.match?(/^VERSION_CODE\s*=/) - config_content = config_content.gsub(/^VERSION_CODE\s*=.*$/, "VERSION_CODE = #{next_build_number}") - else - config_content = "#{config_content.rstrip}\nVERSION_CODE = #{next_build_number}\n" - end - - File.write(VERSION_CONFIG_PATH, config_content) - File.open(ENV.fetch('GITHUB_ENV'), 'a') { |file| file.puts "IOS_BUILD_NUMBER=#{next_build_number}" } - - puts "Latest App Store Connect build number: #{max_build_number}" - puts "Resolved iOS build number: #{next_build_number}" - RUBY - - - name: Build archive - run: | - set -euo pipefail - - cd resources/mobile/platforms/ios/eeuiApp - xcodebuild archive \ - -workspace eeuiApp.xcworkspace \ - -scheme eeuiApp \ - -configuration Release \ - -destination "generic/platform=iOS" \ - -archivePath $RUNNER_TEMP/eeuiApp.xcarchive \ - -allowProvisioningUpdates \ - DEVELOPMENT_TEAM=$IOS_TEAM_ID \ - CODE_SIGN_IDENTITY="Apple Distribution" \ - CODE_SIGN_STYLE=Manual \ - | xcpretty - - if [ ! -d "$RUNNER_TEMP/eeuiApp.xcarchive" ]; then - echo "Archive was not created at $RUNNER_TEMP/eeuiApp.xcarchive" - exit 1 - fi - - - name: Export IPA - run: | - set -euo pipefail - - cd resources/mobile/platforms/ios/eeuiApp - - # Generate ExportOptions.plist - cat > $RUNNER_TEMP/ExportOptions.plist << PLIST - - - - - method - app-store - signingStyle - manual - teamID - ${IOS_TEAM_ID} - provisioningProfiles - - com.dootask.task - ${APP_PROFILE_NAME} - com.dootask.task.shareExtension - ${SHARE_PROFILE_NAME} - - uploadBitcode - - uploadSymbols - - - - PLIST - - xcodebuild -exportArchive \ - -archivePath $RUNNER_TEMP/eeuiApp.xcarchive \ - -exportOptionsPlist $RUNNER_TEMP/ExportOptions.plist \ - -exportPath $RUNNER_TEMP/ipa-output \ - -allowProvisioningUpdates \ - | xcpretty - - - name: Submit to App Store Connect - env: - ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} - ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} - ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} - run: | - set -euo pipefail - - # Prepare API key mkdir -p ~/private_keys echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > ~/private_keys/AuthKey_${ASC_API_KEY_ID}.p8 - # Find and upload IPA - IPA_PATH=$(find $RUNNER_TEMP/ipa-output -name "*.ipa" | head -1) + IPA_PATH=$(find ipa-output -name "*.ipa" | head -1) if [ -z "$IPA_PATH" ]; then - echo "No IPA file found in $RUNNER_TEMP/ipa-output" + echo "No IPA file found in ipa-output" exit 1 fi echo "Uploading: $IPA_PATH" @@ -422,11 +81,4 @@ jobs: - name: Clean up if: always() - run: | - security delete-keychain $RUNNER_TEMP/app-signing.keychain-db 2>/dev/null || true - rm -f $RUNNER_TEMP/certificate.p12 - rm -f $RUNNER_TEMP/app.mobileprovision - rm -f $RUNNER_TEMP/share-extension.mobileprovision - rm -f $RUNNER_TEMP/app-profile.plist - rm -f $RUNNER_TEMP/share-extension-profile.plist - rm -rf ~/private_keys + run: rm -rf ~/private_keys diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0914acfcb..a58e8223d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -131,8 +131,6 @@ jobs: include: - platform: "macos-latest" build_type: "mac" - - platform: "ubuntu-latest" - build_type: "android" - platform: "windows-latest" build_type: "windows" @@ -147,73 +145,6 @@ jobs: with: node-version: 20.x - # Android 构建步骤 - - name: (Android) Build Js - if: matrix.build_type == 'android' - uses: nick-fields/retry@v2 - with: - timeout_minutes: 10 - max_attempts: 3 - command: | - git submodule init - git submodule update --remote "resources/mobile" - ./cmd appbuild publish - - - name: (Android) Setup JDK 11 - if: matrix.build_type == 'android' - uses: actions/setup-java@v3 - with: - distribution: "zulu" - java-version: "11" - - - name: (Android) Build App - if: matrix.build_type == 'android' - uses: nick-fields/retry@v2 - with: - timeout_minutes: 20 - max_attempts: 5 - command: | - cd resources/mobile/platforms/android/eeuiApp - chmod +x ./gradlew - ./gradlew assembleRelease --quiet - - - name: (Android) Upload File - if: matrix.build_type == 'android' - env: - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} - R2_BUCKET: ${{ secrets.R2_BUCKET }} - R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }} - run: | - node ./electron/build.js android-upload - - - name: (Android) Upload Release - if: matrix.build_type == 'android' - uses: actions/github-script@v7 - env: - RELEASE_ID: ${{ needs.create-release.outputs.release_id }} - with: - script: | - const fs = require('fs'); - const path = require('path'); - const globby = require('globby'); - - // 查找 APK 文件 - const files = await globby('resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release/*.apk'); - - for (const file of files) { - const data = await fs.promises.readFile(file); - - await github.rest.repos.uploadReleaseAsset({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: process.env.RELEASE_ID, - name: path.basename(file), - data: data - }); - } - # Mac 构建步骤 - name: (Mac) Build Client if: matrix.build_type == 'mac' @@ -247,8 +178,75 @@ jobs: run: | ./cmd electron win + build-android: + needs: [ check-version, create-release ] + if: needs.check-version.outputs.should_release == 'true' + permissions: + contents: read + uses: ./.github/workflows/app-build.yml + with: + platform: android + release: true + secrets: inherit + + upload-android: + needs: [ check-version, create-release, build-android ] + if: needs.check-version.outputs.should_release == 'true' + permissions: + contents: write + runs-on: ubuntu-latest + environment: build + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20.x + + - uses: actions/download-artifact@v4 + with: + name: app-android + path: dist-android + + - name: Upload File + env: + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} + R2_BUCKET: ${{ secrets.R2_BUCKET }} + R2_PUBLIC_URL: ${{ secrets.R2_PUBLIC_URL }} + run: | + pushd electron || exit + npm install + popd || exit + node ./electron/build.js android-upload dist-android + + - name: Upload Release + uses: actions/github-script@v7 + env: + RELEASE_ID: ${{ needs.create-release.outputs.release_id }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const files = fs.readdirSync('dist-android').filter(name => name.endsWith('.apk')); + if (!files.length) { + throw new Error('APK 未找到'); + } + for (const name of files) { + await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: process.env.RELEASE_ID, + name: name, + data: await fs.promises.readFile(path.join('dist-android', name)) + }); + } + publish-release: - needs: [ check-version, create-release, pack-vendor, build-client ] + needs: [ check-version, create-release, pack-vendor, build-client, upload-android ] if: needs.check-version.outputs.should_release == 'true' && github.ref == 'refs/heads/pro' permissions: contents: write diff --git a/.gitmodules b/.gitmodules index cd4834c49..42c725a96 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ [submodule "resources/drawio"] path = resources/drawio url = https://github.com/jgraph/drawio.git -[submodule "resources/mobile"] - path = resources/mobile - url = https://github.com/kuaifan/dootask-app.git [submodule "resources/app"] path = resources/app url = https://github.com/dootask/app.git diff --git a/README_PUBLISH.md b/README_PUBLISH.md index fd2f56d6d..780d6419a 100644 --- a/README_PUBLISH.md +++ b/README_PUBLISH.md @@ -22,10 +22,17 @@ npm run build # 编译前端 - 如果想手动生成客户端执行 `./cmd electron` 根据提示选择操作。 -## 编译 App +## App(Expo,`resources/app` 子模块) -```shell -./cmd appbuild publish # 编译生成App需要的资源 -``` +- **Android**:推送到 `pro` 时随 Publish 工作流自动构建(`.github/workflows/app-build.yml`),以正式签名出包,上传官网下载(R2)并附到 GitHub Release;App 内「检查更新」读取官网下载索引。 +- **iOS**:手动运行 **iOS Publish** 工作流(`gh workflow run ios-publish.yml --ref pro`),构建后上传 App Store Connect,再到 App Store Connect 提交审核。 +- **内测包**:手动运行 **App Internal** 工作流,只产出 artifact。 +- 本地出包见 `resources/app/scripts/mac-build.sh`(Android 正式签名读取构建机 iCloud 云盘 `DooTaskKeys/android/`)。 -编译完后进入 `resources/mobile` EEUI框架目录内打包 Android 或 iOS 应用(Android 以实现 GitHub Actions 自动发布) +| Secret | 用途 | +|---|---| +| `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD` | Android 正式签名(与旧版证书一致,CI 校验指纹) | +| `IOS_CERTIFICATE_BASE64` / `IOS_CERTIFICATE_PASSWORD` | Apple Distribution 证书 | +| `IOS_PROVISION_PROFILE_BASE64` / `IOS_SHARE_EXTENSION_PROVISION_PROFILE_BASE64` | App Store profiles(主 App 需含推送与 App Group `group.im.dootask`) | +| `ASC_API_KEY_ID` / `ASC_ISSUER_ID` / `ASC_API_KEY_P8_BASE64` | App Store Connect API(构建号查询与上传) | +| `IOS_INTERNAL_PROVISION_PROFILE_BASE64` / `IOS_INTERNAL_SHARE_EXTENSION_PROVISION_PROFILE_BASE64` | 可选,iOS 内测 profiles | diff --git a/cmd b/cmd index c7142d012..24059217a 100755 --- a/cmd +++ b/cmd @@ -429,7 +429,7 @@ electron_operate() { env BUILD_FRONTEND=$BUILD_FRONTEND node ./electron/build.js $argv } -# Expo 内测:同 electron 使用本机 Node,不依赖 Docker 或旧 eeui 容器。 +# Expo App 前端:同 electron 使用本机 Node,不依赖 Docker。 appexpo_operate() { check_node case "${1:-build}" in @@ -711,7 +711,7 @@ DooTask 管理脚本 serve, dev 启动开发模式 build, prod 生产环境构建 electron 构建桌面应用 - appexpo [build] 编译并同步 Expo 内测前端 + appexpo [build] 编译并同步 Expo App 前端 appexpo npm <参数...> 在 Expo 子模块执行本机 npm 🔧 服务管理: @@ -764,7 +764,7 @@ Usage: ./cmd [options] serve, dev Start dev mode build, prod Production build electron Build desktop app - appexpo [build] Build and copy Expo internal web assets + appexpo [build] Build and copy Expo App web assets appexpo npm Run local npm in the Expo submodule 🔧 Services: @@ -1288,10 +1288,6 @@ case "$1" in shift 1 web_build prod ;; - "appbuild"|"buildapp") - shift 1 - electron_operate app "$@" - ;; "appexpo") shift 1 appexpo_operate "$@" @@ -1301,24 +1297,12 @@ case "$1" in shift 1 electron_operate "$@" ;; - "eeui") - shift 1 - cli="$@" - por="" - if [[ "$cli" == "build" ]]; then - cli="build --simple" - elif [[ "$cli" == "dev" ]]; then - por="-p 8880:8880" - fi - docker run $TTY_FLAG --rm -v ${WORK_DIR}/resources/mobile:/work -w /work ${por} kuaifan/eeui-cli:0.0.1 eeui ${cli} - ;; "npm") shift 1 npm "$@" pushd electron || exit npm "$@" popd || exit - docker run $TTY_FLAG --rm -v ${WORK_DIR}/resources/mobile:/work -w /work --entrypoint=/bin/bash node:16 -c "npm $@" ;; "doc") shift 1 diff --git a/electron/build.js b/electron/build.js index de34a8556..cec05d3b3 100644 --- a/electron/build.js +++ b/electron/build.js @@ -26,7 +26,6 @@ const architectures = ["arm64", "x64"]; let buildChecked = false, updaterChecked = false; -const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`; const elapsedSeconds = (startTime) => `${((Date.now() - startTime) / 1000).toFixed(1)}s`; /** @@ -359,7 +358,7 @@ async function startBuild(data) { apiUrl: utils.formatUrl(data.url) + "api/", } // information - if (['app', 'app-expo'].includes(data.id)) { + if (data.id === 'app-expo') { console.log("\n=== 编译信息 ==="); console.log("名称:", data.name); console.log("版本:", config.version + ` (${config.codeVerson})`); @@ -422,51 +421,6 @@ async function startBuild(data) { console.log("Expo 前端资源已同步至 resources/app/assets/web/"); return; } - if (data.id === 'app') { - const eeuiDir = path.resolve(__dirname, "../resources/mobile"); - const publicDir = path.resolve(__dirname, "../resources/mobile/src/public"); - const containerName = `dootask-eeui-${Date.now()}-${process.pid}`; - fse.removeSync(publicDir) - fse.copySync(electronDir, publicDir) - if (argv[3] === "publish") { - // Android config - const gradleFile = path.resolve(eeuiDir, "platforms/android/eeuiApp/local.properties") - let gradleResult = fs.existsSync(gradleFile) ? fs.readFileSync(gradleFile, 'utf8') : ""; - gradleResult = gradleResult.replace(/(versionCode|versionName)\s*=\s*(.+?)(\n|$)/g, '') - gradleResult += `versionCode = ${config.codeVerson}\nversionName = ${config.version}\n` - fs.writeFileSync(gradleFile, gradleResult, 'utf8') - // iOS config - const xcconfigFile = path.resolve(eeuiDir, "platforms/ios/eeuiApp/Config/Version.xcconfig") - let xcconfigResult = fs.existsSync(xcconfigFile) ? fs.readFileSync(xcconfigFile, 'utf8') : ""; - xcconfigResult = xcconfigResult.replace(/(VERSION_CODE|VERSION_NAME)\s*=\s*(.+?)(\n|$)/g, '') - xcconfigResult += `VERSION_CODE = ${config.codeVerson}\nVERSION_NAME = ${config.version}\n` - fs.writeFileSync(xcconfigFile, xcconfigResult, 'utf8') - } - if (['build', 'publish'].includes(argv[3])) { - child_process.execSync( - `docker run -d --name ${containerName} -v ${shellQuote(eeuiDir)}:/work -w /work kuaifan/eeui-cli:0.0.1 sleep infinity`, - {stdio: "ignore", cwd: "resources/mobile"} - ); - try { - if (!fs.existsSync(path.resolve(eeuiDir, "node_modules"))) { - child_process.execSync(`docker exec ${containerName} npm install`, {stdio: "inherit", cwd: "resources/mobile"}); - } - child_process.execSync(`docker exec ${containerName} node /work/scripts/patch-eeui-build.js`, {stdio: "inherit", cwd: "resources/mobile"}); - child_process.execSync(`docker exec ${containerName} eeui build --simple`, {stdio: "inherit", cwd: "resources/mobile"}); - } finally { - child_process.execSync(`docker rm -f ${containerName}`, {stdio: "ignore", cwd: "resources/mobile"}); - } - } else { - [ - path.resolve(publicDir, "../../platforms/ios/eeuiApp/bundlejs/eeui/public"), - path.resolve(publicDir, "../../platforms/android/eeuiApp/app/src/main/assets/eeui/public"), - ].some(dir => { - fse.removeSync(dir) - fse.copySync(electronDir, dir) - }) - } - return; - } const output = `dist/${data.id.replace(/\./g, '-')}/${platform}` // package.json Backup fse.copySync(packageFile, packageBakFile) @@ -554,26 +508,6 @@ if (["dev"].includes(argv[2])) { fs.writeFileSync(devloadCachePath, utils.formatUrl("localhost:" + env.parsed.APP_PORT), 'utf8'); child_process.spawn("npx", ["vite", "--", "fromcmd", "electronDev"], {stdio: "inherit"}); child_process.spawn("npm", ["run", "start-quiet"], {stdio: "inherit", cwd: "electron"}); -} else if (["app"].includes(argv[2])) { - // 编译前端页面给 App - let mobileSrcDir = path.resolve(__dirname, "../resources/mobile"); - if (!fs.existsSync(mobileSrcDir)) { - console.error("resources/mobile 未找到"); - process.exit() - } - startBuild({ - name: 'App', - id: 'app', - platform: '', - url: 'http://public/', - configure: { - platform: '', - archs: [], - publish: false, - release: true, - notarize: false, - } - }) } else if (argv[2] === 'app-expo') { if ((argv[3] && argv[3] !== 'build') || argv.length > 4) { console.error("用法: node ./electron/build.js app-expo [build]"); @@ -601,14 +535,14 @@ if (["dev"].includes(argv[2])) { process.exitCode = 1; }); } else if (["android-upload"].includes(argv[2])) { - // 上传安卓文件到 R2 draft(GitHub Actions) + // 上传安卓文件到 R2 draft(GitHub Actions):参数为 APK 所在目录(app-build.yml 产出的 app-android) (async () => { if (!r2.r2Configured()) { console.error("缺少 R2_* 环境变量(R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_ENDPOINT/R2_BUCKET)") process.exit(1) } const client = r2.createR2Client() - const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release"); + const releaseDir = path.resolve(argv[3] || path.resolve(__dirname, "../resources/app/android/app/build/outputs/apk/release")); if (!fs.existsSync(releaseDir)) { console.error("发布文件未找到") process.exit(1) diff --git a/resources/app b/resources/app index add42fcfd..d2ff9a54a 160000 --- a/resources/app +++ b/resources/app @@ -1 +1 @@ -Subproject commit add42fcfdf199bfab86203cba01b7191aa2594e0 +Subproject commit d2ff9a54a0cfc1ea9942531193cc8265d80b7135 diff --git a/resources/mobile b/resources/mobile deleted file mode 160000 index 1a8467602..000000000 --- a/resources/mobile +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1a8467602004ac9336619fc6aff9dfbb7143527a diff --git a/scripts/check-language.mjs b/scripts/check-language.mjs index db77d6621..90558f064 100644 --- a/scripts/check-language.mjs +++ b/scripts/check-language.mjs @@ -34,8 +34,6 @@ const ACTUAL_SOURCE_ROOTS = [ "electron", "resources/assets/js", "resources/assets/sass", - "resources/mobile/src", - "resources/mobile/scripts", "resources/views", "routes", "bin",