ci(app): App 改由 Expo 壳构建发布,移除旧 eeui 子模块 resources/mobile

- 新增可复用工作流 app-build.yml(web 资源 / Android 正式签名 APK / iOS IPA)
- publish.yml 的 Android 改用 Expo 构建并上传 R2 与 Release
- ios-publish.yml 改为构建 Expo App 并上传 App Store Connect(构建号自动递增)
- app-internal.yml 改为调用 app-build.yml 出内测包
- 移除 ./cmd appbuild/eeui 与 electron/build.js 的 eeui 打包流程
This commit is contained in:
kuaifan 2026-10-20 10:29:27 +00:00
parent a0e6d355f2
commit bad7634f27
11 changed files with 533 additions and 823 deletions

407
.github/workflows/app-build.yml vendored Normal file
View File

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

View File

@ -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

View File

@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>${IOS_TEAM_ID}</string>
<key>provisioningProfiles</key>
<dict>
<key>com.dootask.task</key>
<string>${APP_PROFILE_NAME}</string>
<key>com.dootask.task.shareExtension</key>
<string>${SHARE_PROFILE_NAME}</string>
</dict>
<key>uploadBitcode</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
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

View File

@ -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

3
.gitmodules vendored
View File

@ -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

View File

@ -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 |

22
cmd
View File

@ -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 <command> [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 <args...> 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

72
electron/build.js vendored
View File

@ -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)

@ -1 +1 @@
Subproject commit add42fcfdf199bfab86203cba01b7191aa2594e0
Subproject commit d2ff9a54a0cfc1ea9942531193cc8265d80b7135

@ -1 +0,0 @@
Subproject commit 1a8467602004ac9336619fc6aff9dfbb7143527a

View File

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