dootask/.github/workflows/app-build.yml
kuaifan 3192e43db3 feat(app): 新增 ./cmd expo dev 同时启动前端与 Metro,appexpo 命令更名为 expo
- ./cmd expo [build|dev|npm],与 ./cmd electron 同规范;CI 同步改用 ./cmd expo build
- expo dev:Metro 端口自动找空闲(指定 EXPO_PACKAGER_PROXY_URL 时固定并在占用时报错),地址按 VSCODE_PROXY_URI 推导或命令行传入
- vite 开发反代开启 gzip,减少手机经公网加载开发前端的体积
- resources/app:DooTask (dev) 变体
2026-10-21 18:41:54 +00:00

408 lines
17 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# electron/build.js 经 lib/utils.js 在加载时 require("electron"),需要完整安装(含二进制)
- name: Install build script dependencies
working-directory: electron
run: npm install --include=dev --no-package-lock --no-audit --no-fund
- name: Build and copy web assets
run: ./cmd expo 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'
- 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
# 新版 apksigner 的行首可能是 "Signer #1" 或 "Signer (minSdkVersion=…)",只按字段名匹配
certs=$("$apksigner" verify --print-certs "$apk")
echo "$certs" | grep -i 'certificate SHA-256 digest' || true
actual=$(echo "$certs" | grep -i -m1 'certificate SHA-256 digest:' | sed 's/.*: *//' | tr -d ': ' | tr 'A-F' 'a-f')
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"