feat(app): 新增 Expo 内测构建链路并更新 resources/app

- ./cmd appexpo [build] / appexpo npm:编译前端并同步到 resources/app/assets/web
- electron/build.js 新增 app-expo 目标,旧 eeui 流程不变
- 新增仅手动触发的 Expo App Internal workflow(APK / IPA artifact)
- resources/app 更新至 M3 会议与 M4 签名脚本
This commit is contained in:
kuaifan 2026-10-17 23:08:24 +00:00
parent 7ee2aed8d7
commit 945e5b10f2
4 changed files with 391 additions and 7 deletions

300
.github/workflows/app-internal.yml vendored Normal file
View File

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

41
cmd
View File

@ -429,6 +429,34 @@ electron_operate() {
env BUILD_FRONTEND=$BUILD_FRONTEND node ./electron/build.js $argv
}
# Expo 内测:同 electron 使用本机 Node,不依赖 Docker 或旧 eeui 容器。
appexpo_operate() {
check_node
case "${1:-build}" in
"build")
if [[ $# -gt 1 ]]; then
error "用法: ./cmd appexpo [build] | ./cmd appexpo npm <参数...>"
return 1
fi
if [ ! -d "./node_modules" ]; then
npm install --no-package-lock || return $?
fi
if [ ! -d "./electron/node_modules" ]; then
(cd electron && ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm install --no-package-lock) || return $?
fi
node ./electron/build.js app-expo build
;;
"npm")
shift
(cd "${WORK_DIR}/resources/app" && npm "$@")
;;
*)
error "用法: ./cmd appexpo [build] | ./cmd appexpo npm <参数...>"
return 1
;;
esac
}
# 执行容器命令
container_exec() {
local container=$1
@ -683,6 +711,8 @@ DooTask 管理脚本
serve, dev 启动开发模式
build, prod 生产环境构建
electron 构建桌面应用
appexpo [build] 编译并同步 Expo 内测前端
appexpo npm <参数...> 在 Expo 子模块执行本机 npm
🔧 服务管理:
up [服务名] 启动容器
@ -734,6 +764,8 @@ 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 npm <args...> Run local npm in the Expo submodule
🔧 Services:
up [service] Start containers
@ -1196,8 +1228,8 @@ if [[ "$1" == "help" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] || [[ $#
exit 0
fi
# 非electron和permission命令需要检查Docker环境
if [[ "$1" != "electron" ]] && [[ "$1" != "permission" ]]; then
# 非electron、appexpo和permission命令需要检查Docker环境
if [[ "$1" != "electron" ]] && [[ "$1" != "appexpo" ]] && [[ "$1" != "permission" ]]; then
check_docker
env_init
fi
@ -1260,6 +1292,11 @@ case "$1" in
shift 1
electron_operate app "$@"
;;
"appexpo")
shift 1
appexpo_operate "$@"
exit $?
;;
"electron")
shift 1
electron_operate "$@"

55
electron/build.js vendored
View File

@ -336,8 +336,12 @@ async function uploadDraftFile(client, localFile, version) {
* @param data
*/
async function startBuild(data) {
if (BUILD_FRONTEND === 'build' && !buildChecked) {
if ((BUILD_FRONTEND === 'build' || data.id === 'app-expo') && !buildChecked) {
buildChecked = true
// Expo 每次重新编译,避免把上次桌面端或旧版本的文件带进内测包。
if (data.id === 'app-expo') {
fse.emptyDirSync(electronDir);
}
fs.mkdirSync(electronDir, { recursive: true });
fse.copySync(path.resolve(__dirname, "index.html"), path.resolve(electronDir, "index.html"))
child_process.execSync("npx vite build -- fromcmd electronBuild", {stdio: "inherit"});
@ -355,7 +359,7 @@ async function startBuild(data) {
apiUrl: utils.formatUrl(data.url) + "api/",
}
// information
if (data.id === 'app') {
if (['app', 'app-expo'].includes(data.id)) {
console.log("\n=== 编译信息 ===");
console.log("名称:", data.name);
console.log("版本:", config.version + ` (${config.codeVerson})`);
@ -385,11 +389,16 @@ async function startBuild(data) {
// config.js
fs.writeFileSync(electronDir + "/config.js", "window.systemInfo = " + JSON.stringify(systemInfo), 'utf8');
fs.writeFileSync(electronDir + "/dark", '', 'utf8');
fs.writeFileSync(nativeCachePath, utils.formatUrl(data.url));
fs.writeFileSync(devloadCachePath, "", 'utf8');
if (data.id !== 'app-expo') {
fs.writeFileSync(nativeCachePath, utils.formatUrl(data.url));
fs.writeFileSync(devloadCachePath, "", 'utf8');
}
// index.html
let manifestFile = path.resolve(electronDir, "manifest.json");
if (!fs.existsSync(manifestFile)) {
if (data.id === 'app-expo') {
throw new Error("manifest.json 未找到,Expo 前端构建失败");
}
console.error("manifest.json 未找到");
return;
}
@ -401,6 +410,18 @@ async function startBuild(data) {
indexString = indexString.replace("<!--script-->", `<script type="module" src="./${manifestContent['resources/assets/js/app.js']['file']}"></script>`);
fs.writeFileSync(indexFile, indexString, 'utf8');
//
if (data.id === 'app-expo') {
const appDir = path.resolve(__dirname, "../resources/app");
const webDir = path.join(appDir, "assets/web");
fse.emptyDirSync(webDir);
fse.copySync(electronDir, webDir);
fs.writeFileSync(path.join(appDir, "version.json"), JSON.stringify({
version: config.version,
codeVerson: config.codeVerson,
}, null, 2) + "\n", 'utf8');
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");
@ -553,6 +574,32 @@ if (["dev"].includes(argv[2])) {
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]");
process.exit(1);
}
if (!fs.existsSync(path.resolve(__dirname, "../resources/app/app.config.ts"))) {
console.error("resources/app 未找到,请先初始化 Expo 子模块");
process.exit(1);
}
startBuild({
name: 'App',
id: 'app-expo',
// public 是前端“尚未设置服务器”的占位域名,不是静态资源的 origin。
// electronBuild 的 ./ 资源路径在 localhost:22223/ 下加载,路由自动使用 hash。
url: 'http://public/',
configure: {
platform: '',
archs: [],
publish: false,
release: true,
notarize: false,
}
}).catch(error => {
console.error(error.message || error);
process.exitCode = 1;
});
} else if (["android-upload"].includes(argv[2])) {
// 上传安卓文件到 R2 draft(GitHub Actions)
(async () => {

@ -1 +1 @@
Subproject commit 1da72c70f95a4489fd3b52d8ea367905d5ef2b5a
Subproject commit 71e81bcfae7ddeed49c819a2970bbdf298715b2a