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",