Skip to content

feat(auth): 审核账号旁路支持多个标识——Apple 用邮箱、微信用手机号并存(dev-board#347) #770

feat(auth): 审核账号旁路支持多个标识——Apple 用邮箱、微信用手机号并存(dev-board#347)

feat(auth): 审核账号旁路支持多个标识——Apple 用邮箱、微信用手机号并存(dev-board#347) #770

Workflow file for this run

name: Desktop Build
# Builds desktop installers with the backend bundled in (Epic #18 T2):
# Spring Boot jar (per-platform javacpp natives) + jlink-trimmed JRE, so the
# installer works without a local Java/PostgreSQL environment.
on:
workflow_dispatch:
push:
tags: ['v*']
pull_request:
paths:
- '.github/workflows/desktop-build.yml'
- 'desktop/**'
- 'backend/**'
# Installers bundle the built frontend (frontend/dist), so frontend-only
# changes affect the installer too and must run the build + smoke test.
# Without this, the first-run wizard (PR #25, frontend-only) shipped to
# v0.2.0 having never been validated inside an installer. See issue #27.
- 'frontend/**'
# The "Attach to GitHub Release" step (softprops/action-gh-release) creates the
# release for a v* tag, which needs write access to repository contents. The
# default GITHUB_TOKEN is read-only, so without this the tag build fails with
# "403 Resource not accessible by integration".
permissions:
contents: write
jobs:
# 小版本发布纪律守门(增量更新设计 §2.3):tag v0.X.Y(Y>0)时校验变更没有
# 越出补丁边界(Electron 壳 / pom 依赖 / LOWA 引擎 / requirements.lock 都只能
# 随大版本走)。违规直接失败——删 tag 升大版本号重打即可。
patch-gate:
name: Patch gate (minor tag)
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Enforce minor-release boundaries
run: bash desktop/scripts/patch-gate.sh "$GITHUB_REF_NAME"
build:
name: Build (${{ matrix.os }})
needs: [patch-gate]
# patch-gate 只在 tag 上运行;非 tag(PR / 手动触发)时它被 skip,
# build 必须照常跑,所以放行 success 与 skipped 两种结果
if: always() && (needs.patch-gate.result == 'success' || needs.patch-gate.result == 'skipped')
strategy:
fail-fast: false
matrix:
# macOS runners take 1h+ per build (jlink + dmg + notarization), so PRs
# only build Windows; macOS builds run on v* release tags and manual
# workflow_dispatch runs.
os: ${{ fromJSON((startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') && '["macos-latest", "windows-latest"]' || '["windows-latest"]') }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
# 全量历史:Detect drawio changes 需要拿 PR base / push before 做 diff
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
- uses: actions/setup-java@v4
id: jdk
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Build frontend
working-directory: frontend
run: |
npm ci || npm install
npm run build:h5
# Epic #43: build the embedded LibreOffice editor bundle (dist/zetaoffice),
# shipped via extraResources for the experimental "LibreOffice 验证" window.
npm run build:zetaoffice
- name: Bake LOWA runtime + OFL CJK font into the editor bundle (offline)
shell: bash
env:
# Self-built zh-CN LOWA engine (issue #66): native Chinese UI incl. Qt
# tooltips + FS/callMain exports. Hosted on the project site (versioned,
# immutable; wasm/data served brotli so the installer stays small).
# Unset -> upstream CDN (English UI). Build recipe: desktop/lowa-build/.
LOWA_BASE_URL: https://www.aiworkdeck.com/lowa-engine/24.2.8-zhcn-r4/
run: |
# Track A (Epic #43): download the LibreOffice WASM runtime (soffice.*)
# into dist/zetaoffice/lowa and an OFL-licensed CJK font (Noto Sans SC)
# as cjk.ttc, so the packaged app renders Chinese OFFLINE without
# reaching cdn.zetaoffice.net. Replaces the old best-effort copy of a
# system font (non-deterministic, absent offline). Adds ~60 MB to the
# installer. The local HTTP server (zetaoffice-server.js) serves these
# first and falls back to the CDN only for anything not bundled.
node desktop/scripts/fetch-lowa-assets.js
ls -la frontend/dist/zetaoffice/lowa frontend/dist/zetaoffice/cjk.ttc
- name: Install desktop dependencies
working-directory: desktop
run: npm ci
- name: Detect drawio changes (skip drawio tests when untouched)
shell: bash
run: |
# drawio-server 测试要真起 HTTP 服务 + 真读写 Temp 目录,是 Windows
# runner 上 Defender 锁文件 EPERM(hookFailed)与步骤卡死的主要来源
#(dev-board#146;v0.25.1/#609/v0.26.0 三个发版 run 都被咬过)。
# 本区间没碰 drawio 服务/测试/资源脚本就整套跳过(测试文件内识别
# SKIP_DRAWIO_TESTS=1);tag 发版与手动触发永远全量跑。
SKIP=0
if [[ "$GITHUB_REF" == refs/tags/* || "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
echo "tag/manual trigger: full suite"
else
BASE=""
[ "$GITHUB_EVENT_NAME" = "pull_request" ] && BASE="${{ github.event.pull_request.base.sha }}"
[ "$GITHUB_EVENT_NAME" = "push" ] && BASE="${{ github.event.before }}"
if [ -n "$BASE" ] && git cat-file -e "$BASE^{commit}" 2>/dev/null; then
CHANGED=$(git diff --name-only "$BASE...$GITHUB_SHA" -- \
desktop/main/drawio-server.js \
desktop/tests/drawio-server.test.js \
desktop/scripts/fetch-drawio-assets.js)
if [ -z "$CHANGED" ]; then
echo "drawio untouched since $BASE — skipping drawio-server tests"
SKIP=1
else
echo "drawio files changed:"; echo "$CHANGED"
fi
else
echo "no usable base commit ($BASE) — full suite"
fi
fi
[ "$SKIP" = "1" ] && echo "SKIP_DRAWIO_TESTS=1" >> "$GITHUB_ENV"
echo "drawio tests: $([ "$SKIP" = "1" ] && echo SKIPPED || echo FULL)"
# Windows runner 上 Defender 实时扫描会给刚落盘的临时文件加排他句柄,
# drawio-server 测试的 t.after 删临时目录时报 EPERM(hookFailed),
# 断言全过、只有清理失败,却把整个发版拦下——v0.25.1/#609/v0.26.0/v0.27.5/v0.28.0
# 都被咬过(dev-board#146)。PR 上靠「没碰 drawio 就跳过」绕开了,
# 而 tag 发版永远全量跑,于是这个坑专挑发版咬。
#
# 这一步直接治病根:把 runner 的 Temp 目录加进 Defender 排除项。
# **失败不许中断构建**(|| true):部分 runner 镜像本来就禁用了 Defender,
# 或者不给加排除项——那种情况下退回测试侧的 EPERM 兜底(drawio-server.test.js
# 的 rmrf),不该因为"加排除项没成功"就把发版拦了。
- name: Exclude runner Temp from Defender (Windows EPERM guard)
if: runner.os == 'Windows'
shell: pwsh
continue-on-error: true
run: |
try {
Add-MpPreference -ExclusionPath $env:RUNNER_TEMP -ErrorAction Stop
Add-MpPreference -ExclusionPath $env:TEMP -ErrorAction Stop
Write-Host "Defender exclusions added: $env:RUNNER_TEMP ; $env:TEMP"
} catch {
Write-Host "Defender exclusion not applied (harmless): $($_.Exception.Message)"
}
- name: Desktop unit tests
working-directory: desktop
# drawio 测试曾在 Windows 上锁死 110 分钟不退出(v0.26.0 首发 run):
# 正常全量 ≤5 分钟,给 3 倍预算快挂,别再拖到 job 默认 6 小时上限。
timeout-minutes: 15
run: npm test
- name: Bundle backend (macOS arm64)
if: runner.os == 'macOS'
env:
JAVA_HOME: ${{ steps.jdk.outputs.path }}
run: |
# 插件宿主 SPI 是本仓独立工程,不在远端仓库:先 install 再打 backend
mvn -B -q -f backend/plugin-api/pom.xml install
# Apple Silicon only(2026-07-03 决策放弃 Intel Mac):单 arm64 jar + arm64 JRE
mvn -B -q -DskipTests -Djavacpp.platform=macosx-arm64 -f backend/pom.xml package
node desktop/scripts/prepare-backend.js \
--jar backend/target/backend-0.0.1-SNAPSHOT.jar \
--out desktop/bundled/mac-arm64
# ──────────────────────────────────────────────────────────────
# Python 运行时 / 各 pysvc / graphviz 的缓存。
#
# 这几样只由 requirements.lock、各服务源码与打包脚本决定,
# 但过去每个 PR 都从零重装一遍(实测 Windows 上 mineru 111s + kokoro 98s +
# pptx 60s + asr 22s + graphviz 43s ≈ 5.6 分钟纯浪费)。
#
# **必须放在「Bundle backend」之后**:prepare-backend.js 会
# `rmSync(outDir, {recursive:true})` 清空整个 bundled/<plat>,
# 放在它之前恢复的缓存会被当场抹掉。
#
# key 里带打包脚本自身的哈希:脚本改了(比如换 Python 版本)就该重装。
# **也必须带各服务的源码哈希**(dev-board#74):prepare-python-service.js 的
# --src 会把源码 cpSync 进这里被缓存的 pysvc/。少了它,只改
# pptx-service/backend、kokoro-service、asr-service 而不动 lock 时 key 不变,
# 缓存命中就把下面的打包步骤整步跳过,装机包里带的还是旧源码——构建全绿、
# 冒烟也过(跑的是旧代码),tag 构建会把缺了这笔改动的安装包直接发出去。
# 开头的 v1 是人工盐——graphviz 来自 choco/brew,上游版本不体现在任何文件哈希里,
# 想强制取新版就把它 +1。
# ──────────────────────────────────────────────────────────────
- name: Cache python runtime / pysvc / graphviz (mac-arm64)
id: cache-pybundle-mac
if: runner.os == 'macOS'
uses: actions/cache@v4
with:
path: |
desktop/bundled/mac-arm64/python
desktop/bundled/mac-arm64/pysvc
desktop/bundled/mac-arm64/graphviz
key: v1-mac-arm64-pybundle-${{ hashFiles('*/requirements.lock', 'desktop/scripts/prepare-python-service.js', 'desktop/scripts/prepare-graphviz.js', 'pptx-service/backend/**', 'kokoro-service/**', 'asr-service/**') }}
- name: Bundle pptx-service (macOS arm64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'macOS' && steps.cache-pybundle-mac.outputs.cache-hit != 'true'
run: |
node desktop/scripts/prepare-python-service.js \
--service pptx-service \
--src pptx-service/backend \
--requirements pptx-service/requirements.lock \
--out desktop/bundled/mac-arm64
- name: Bundle mineru-service (macOS arm64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'macOS' && steps.cache-pybundle-mac.outputs.cache-hit != 'true'
run: |
# 纯 pip 包服务(无 --src);模型不进包,首启在「组件管理」下载
node desktop/scripts/prepare-python-service.js \
--service mineru-service \
--requirements mineru-service/requirements.lock \
--out desktop/bundled/mac-arm64
du -sh desktop/bundled/mac-arm64/pysvc/mineru-service/lib
- name: Bundle kokoro-service (macOS arm64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'macOS' && steps.cache-pybundle-mac.outputs.cache-hit != 'true'
run: |
# 本地 TTS 包装层(Phase 3):模型不进包,首启在「组件管理」下载
node desktop/scripts/prepare-python-service.js \
--service kokoro-service \
--src kokoro-service \
--requirements kokoro-service/requirements.lock \
--out desktop/bundled/mac-arm64
du -sh desktop/bundled/mac-arm64/pysvc/kokoro-service/lib
- name: Bundle asr-service (macOS arm64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'macOS' && steps.cache-pybundle-mac.outputs.cache-hit != 'true'
run: |
# 本地 ASR 包装层(P3):模型不进包(1.5GB),首启在「组件管理」下载
node desktop/scripts/prepare-python-service.js \
--service asr-service \
--src asr-service \
--requirements asr-service/requirements.lock \
--out desktop/bundled/mac-arm64
du -sh desktop/bundled/mac-arm64/pysvc/asr-service/lib
- name: Bundle backend (Windows x64)
if: runner.os == 'Windows'
shell: bash
env:
JAVA_HOME: ${{ steps.jdk.outputs.path }}
run: |
mvn -B -q -f backend/plugin-api/pom.xml install
mvn -B -q -DskipTests -Djavacpp.platform=windows-x86_64 -f backend/pom.xml package
node desktop/scripts/prepare-backend.js \
--jar backend/target/backend-0.0.1-SNAPSHOT.jar \
--out desktop/bundled/win-x64
# ──────────────────────────────────────────────────────────────
# Python 运行时 / 各 pysvc / graphviz 的缓存。
#
# 这几样只由 requirements.lock、各服务源码与打包脚本决定,
# 但过去每个 PR 都从零重装一遍(实测 Windows 上 mineru 111s + kokoro 98s +
# pptx 60s + asr 22s + graphviz 43s ≈ 5.6 分钟纯浪费)。
#
# **必须放在「Bundle backend」之后**:prepare-backend.js 会
# `rmSync(outDir, {recursive:true})` 清空整个 bundled/<plat>,
# 放在它之前恢复的缓存会被当场抹掉。
#
# key 里带打包脚本自身的哈希:脚本改了(比如换 Python 版本)就该重装。
# **也必须带各服务的源码哈希**(dev-board#74):prepare-python-service.js 的
# --src 会把源码 cpSync 进这里被缓存的 pysvc/。少了它,只改
# pptx-service/backend、kokoro-service、asr-service 而不动 lock 时 key 不变,
# 缓存命中就把下面的打包步骤整步跳过,装机包里带的还是旧源码——构建全绿、
# 冒烟也过(跑的是旧代码),tag 构建会把缺了这笔改动的安装包直接发出去。
# 开头的 v1 是人工盐——graphviz 来自 choco/brew,上游版本不体现在任何文件哈希里,
# 想强制取新版就把它 +1。
# ──────────────────────────────────────────────────────────────
- name: Cache python runtime / pysvc / graphviz (win-x64)
id: cache-pybundle-win
if: runner.os == 'Windows'
uses: actions/cache@v4
with:
path: |
desktop/bundled/win-x64/python
desktop/bundled/win-x64/pysvc
desktop/bundled/win-x64/graphviz
key: v1-win-x64-pybundle-${{ hashFiles('*/requirements.lock', 'desktop/scripts/prepare-python-service.js', 'desktop/scripts/prepare-graphviz.js', 'pptx-service/backend/**', 'kokoro-service/**', 'asr-service/**') }}
- name: Bundle pptx-service (Windows x64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'Windows' && steps.cache-pybundle-win.outputs.cache-hit != 'true'
shell: bash
run: |
node desktop/scripts/prepare-python-service.js \
--service pptx-service \
--src pptx-service/backend \
--requirements pptx-service/requirements.lock \
--out desktop/bundled/win-x64
- name: Bundle mineru-service (Windows x64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'Windows' && steps.cache-pybundle-win.outputs.cache-hit != 'true'
shell: bash
run: |
node desktop/scripts/prepare-python-service.js \
--service mineru-service \
--requirements mineru-service/requirements.lock \
--out desktop/bundled/win-x64
du -sh desktop/bundled/win-x64/pysvc/mineru-service/lib
- name: Bundle kokoro-service (Windows x64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'Windows' && steps.cache-pybundle-win.outputs.cache-hit != 'true'
shell: bash
run: |
node desktop/scripts/prepare-python-service.js \
--service kokoro-service \
--src kokoro-service \
--requirements kokoro-service/requirements.lock \
--out desktop/bundled/win-x64
du -sh desktop/bundled/win-x64/pysvc/kokoro-service/lib
- name: Bundle asr-service (Windows x64)
# 缓存命中就整步跳过——产物只由 requirements.lock / 服务源码 / 打包脚本决定,见上面那条 cache
if: runner.os == 'Windows' && steps.cache-pybundle-win.outputs.cache-hit != 'true'
shell: bash
run: |
node desktop/scripts/prepare-python-service.js \
--service asr-service \
--src asr-service \
--requirements asr-service/requirements.lock \
--out desktop/bundled/win-x64
du -sh desktop/bundled/win-x64/pysvc/asr-service/lib
- name: Sign bundled backend natives (macOS)
if: runner.os == 'macOS'
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: |
# Notarization rejects the adhoc-signed bytedeco dylibs inside the
# fat jar (and any unsigned JRE binaries), so they must carry our
# Developer ID signature before electron-builder seals the app.
if [ -z "$CSC_LINK" ]; then
echo "No signing cert (fork PR) — skipping native signing"
exit 0
fi
KC="$RUNNER_TEMP/awd-sign.keychain-db"
KCPASS="awd-tmp-keychain"
echo "$CSC_LINK" | base64 --decode > "$RUNNER_TEMP/awd-cert.p12"
security create-keychain -p "$KCPASS" "$KC"
security unlock-keychain -p "$KCPASS" "$KC"
security import "$RUNNER_TEMP/awd-cert.p12" -k "$KC" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KCPASS" "$KC" > /dev/null
security list-keychains -d user -s "$KC" login.keychain-db
IDENTITY=$(security find-identity -v -p codesigning "$KC" | awk 'NR==1{print $2}')
echo "Signing with identity: $IDENTITY"
bash desktop/scripts/sign-mac-natives.sh "$IDENTITY" desktop/bundled/mac-arm64 desktop/build/entitlements.mac.plist
- name: Smoke test bundled backend (macOS)
if: runner.os == 'macOS'
env:
# The backend's WebTools pre-warm downloads chromium/firefox/webkit
# from playwright.azureedge.net on every boot; the 2026-07-03 CDN
# outage (400 GatewayExceptionResponse) pushed startup past 120s and
# failed this step on both platforms. The smoke test only curls the
# wizard endpoint — no browser needed — so skip the download to
# remove the live network dependency (playwright-java's Driver
# honors this env var).
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
run: |
# Boot the signed backend on the trimmed JRE exactly as the packaged
# app will (split layout: java -cp "app.jar:lib/*",见增量更新设计
# §4.1 与 backend-service.js javaLaunchArgs) — catches runtime/JRE/
# signing breakage that packaging alone cannot. (Cannot be tested on
# SIP-disabled dev machines: JDK-8326663.)
mkdir -p "$RUNNER_TEMP/awd-home"
cd desktop/bundled/mac-arm64/backend
../jre/bin/java \
-Duser.home="$RUNNER_TEMP/awd-home" \
-cp "app.jar:lib/*" com.checkba.CheckbaApplication \
--spring.profiles.active=desktop --server.port=9696 \
> "$RUNNER_TEMP/backend-smoke.log" 2>&1 &
BACK_PID=$!
for i in $(seq 1 60); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:9696/api/admin/wizard; then
echo "backend up after ~$((i*2))s"
curl -s http://127.0.0.1:9696/api/admin/wizard; echo
kill $BACK_PID
exit 0
fi
done
echo "backend failed to start within 120s"
tail -80 "$RUNNER_TEMP/backend-smoke.log"
kill $BACK_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled pptx-service (macOS)
if: runner.os == 'macOS'
run: |
# 用烙好的运行时按打包态同款方式拉起:先 alembic 迁移,后起服务,验 /health
export PPTX_DATA_DIR="$RUNNER_TEMP/pptx-data"
mkdir -p "$PPTX_DATA_DIR"
APP="$PWD/desktop/bundled/mac-arm64/pysvc/pptx-service/app"
PY="$PWD/desktop/bundled/mac-arm64/python/bin/python3.11"
export PYTHONPATH="$PWD/desktop/bundled/mac-arm64/pysvc/pptx-service/lib"
(cd "$APP" && "$PY" -m alembic -c alembic.ini upgrade head)
PORT=5099 BACKEND_PORT=5099 FLASK_ENV=production "$PY" "$APP/app.py" > "$RUNNER_TEMP/pptx-smoke.log" 2>&1 &
PPTX_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf http://127.0.0.1:5099/health; then
echo; echo "pptx-service up after ~$((i*2))s"
kill $PPTX_PID
exit 0
fi
done
echo "pptx-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/pptx-smoke.log"
kill $PPTX_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled mineru-service (macOS)
if: runner.os == 'macOS'
run: |
# 无模型拉起(模型解析时懒加载,/docs 不依赖模型——本地实测约 4s 就绪)
export MDIR="$RUNNER_TEMP/mineru-data"
mkdir -p "$MDIR"
PY="$PWD/desktop/bundled/mac-arm64/python/bin/python3.11"
export PYTHONPATH="$PWD/desktop/bundled/mac-arm64/pysvc/mineru-service/lib"
MINERU_DEVICE_MODE=cpu MINERU_MODEL_SOURCE=modelscope \
MODELSCOPE_CACHE="$MDIR" HF_HOME="$MDIR/hf" MINERU_TOOLS_CONFIG_JSON="$MDIR/mineru.json" \
"$PY" -m mineru.cli.fast_api --host 127.0.0.1 --port 8098 > "$RUNNER_TEMP/mineru-smoke.log" 2>&1 &
MINERU_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:8098/docs; then
echo "mineru-service up after ~$((i*2))s"
kill $MINERU_PID
exit 0
fi
done
echo "mineru-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/mineru-smoke.log"
kill $MINERU_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled kokoro-service (macOS)
if: runner.os == 'macOS'
run: |
# 无模型拉起(模型懒加载,/health 与 voices 不依赖模型)
PY="$PWD/desktop/bundled/mac-arm64/python/bin/python3.11"
APP="$PWD/desktop/bundled/mac-arm64/pysvc/kokoro-service/app"
export PYTHONPATH="$PWD/desktop/bundled/mac-arm64/pysvc/kokoro-service/lib"
PORT=8881 "$PY" "$APP/app.py" > "$RUNNER_TEMP/kokoro-smoke.log" 2>&1 &
KOKORO_PID=$!
for i in $(seq 1 20); do
sleep 2
if curl -sf http://127.0.0.1:8881/health && curl -sf http://127.0.0.1:8881/v1/audio/voices | grep -q zf_001; then
echo; echo "kokoro-service up after ~$((i*2))s"
kill $KOKORO_PID
exit 0
fi
done
echo "kokoro-service failed to start within 40s"
tail -60 "$RUNNER_TEMP/kokoro-smoke.log"
kill $KOKORO_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled asr-service (macOS)
if: runner.os == 'macOS'
run: |
# 无模型拉起。/health 断言 modelReady=false 是这条冒烟的重点:
# 「服务起着但模型没下」必须是一个能被观测到的状态,界面的下载引导全靠它
PY="$PWD/desktop/bundled/mac-arm64/python/bin/python3.11"
APP="$PWD/desktop/bundled/mac-arm64/pysvc/asr-service/app"
export PYTHONPATH="$PWD/desktop/bundled/mac-arm64/pysvc/asr-service/lib"
export HF_HOME="$RUNNER_TEMP/asr-empty-home"
PORT=8891 "$PY" "$APP/app.py" > "$RUNNER_TEMP/asr-smoke.log" 2>&1 &
ASR_PID=$!
for i in $(seq 1 20); do
sleep 2
if curl -sf http://127.0.0.1:8891/health | grep -q '"modelReady":false'; then
echo; echo "asr-service up after ~$((i*2))s"
kill $ASR_PID
exit 0
fi
done
echo "asr-service failed to start within 40s"
tail -60 "$RUNNER_TEMP/asr-smoke.log"
kill $ASR_PID 2>/dev/null || true
exit 1
- name: Pack pysvc archive (macOS)
if: runner.os == 'macOS'
run: |
# 上万个 Python 小文件不再直接进 .app:签名(sign-mac-natives.sh 已给
# 每个 Mach-O 打 Developer ID + 时间戳——公证会扫嵌套压缩包,未签名照拒)
# 与冒烟测试之后整体打成单个 pysvc.tar.gz,electron-builder 的签名/公证
# 阶段从上万文件降到一个文件,消除时间戳服务抖动。首启由主进程解压。
node desktop/scripts/pack-pysvc.js --bundle desktop/bundled/mac-arm64
- name: Package installers (macOS, signed & notarized)
if: runner.os == 'macOS'
working-directory: desktop
env:
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# GitHub never exposes secrets to fork PRs, so CSC_LINK is empty
# there — and force-signing with an empty CSC_LINK makes
# electron-builder fail ("not a file"). Only force signing when the
# cert secret is actually present; fork PRs fall back to
# electron-builder's default unsigned PR build.
CSC_FOR_PULL_REQUEST: ${{ secrets.CSC_LINK != '' }}
run: |
# mineru lib(transformers 等)有数万个小文件,macOS 默认 fd 上限 256 会让
# electron-builder 在打包/装订阶段 EMFILE。65536 实测仍复现——内核级
# kern.maxfilesperproc(默认 61440)同样要抬,三层一起调并打印生效值
sudo sysctl -w kern.maxfiles=524288 kern.maxfilesperproc=524288 || true
sudo launchctl limit maxfiles 524288 524288 || true
ulimit -n 524288 || ulimit -n "$(ulimit -Hn)" || true
echo "fd soft=$(ulimit -n) hard=$(ulimit -Hn)"
npx electron-builder --publish never
- name: Notarization failure log (macOS)
if: failure() && runner.os == 'macOS'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
# NOT the APPLE_TEAM_ID secret: notarytool history rejects it with
# 403 (it works for submission but not for the history API); the
# team id is public anyway (package.json mac.notarize.teamId)
TEAM_ID: X9B97KVA84
run: |
# Surface Apple's per-file rejection reasons; without this the build
# log only says "Invalid"
HISTORY=$(xcrun notarytool history --apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$TEAM_ID" \
--output-format json || true)
echo "$HISTORY" | head -c 2000; echo
for SUB_ID in $(echo "$HISTORY" | jq -r '.history[0:2][].id // empty'); do
echo "=== notarization log for $SUB_ID"
xcrun notarytool log "$SUB_ID" --apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$TEAM_ID" \
"$RUNNER_TEMP/notary-log-$SUB_ID.json" || true
cat "$RUNNER_TEMP/notary-log-$SUB_ID.json" || true
echo
done
- name: Smoke test bundled backend (Windows)
if: runner.os == 'Windows'
shell: bash
env:
# Skip the live Playwright browser download — see the macOS smoke
# test step for the full rationale.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
run: |
# split 布局直启(相对路径 + 引号防 MSYS 路径转换与 bash glob 展开;
# 通配符由 JVM 自己展开,分隔符 Windows 用分号)
mkdir -p "$RUNNER_TEMP/awd-home"
cd desktop/bundled/win-x64/backend
../jre/bin/java.exe \
-Duser.home="$RUNNER_TEMP/awd-home" \
-cp "app.jar;lib/*" com.checkba.CheckbaApplication \
--spring.profiles.active=desktop --server.port=9696 \
> "$RUNNER_TEMP/backend-smoke.log" 2>&1 &
BACK_PID=$!
for i in $(seq 1 60); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:9696/api/admin/wizard; then
echo "backend up after ~$((i*2))s"
curl -s http://127.0.0.1:9696/api/admin/wizard; echo
kill $BACK_PID
exit 0
fi
done
echo "backend failed to start within 120s"
tail -80 "$RUNNER_TEMP/backend-smoke.log"
kill $BACK_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled pptx-service (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
export PPTX_DATA_DIR="$RUNNER_TEMP/pptx-data"
mkdir -p "$PPTX_DATA_DIR"
APP="$PWD/desktop/bundled/win-x64/pysvc/pptx-service/app"
PY="$PWD/desktop/bundled/win-x64/python/python.exe"
export PYTHONPATH="$PWD/desktop/bundled/win-x64/pysvc/pptx-service/lib"
(cd "$APP" && "$PY" -m alembic -c alembic.ini upgrade head)
PORT=5099 BACKEND_PORT=5099 FLASK_ENV=production "$PY" "$APP/app.py" > "$RUNNER_TEMP/pptx-smoke.log" 2>&1 &
PPTX_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf http://127.0.0.1:5099/health; then
echo; echo "pptx-service up after ~$((i*2))s"
kill $PPTX_PID
exit 0
fi
done
echo "pptx-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/pptx-smoke.log"
kill $PPTX_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled mineru-service (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
export MDIR="$RUNNER_TEMP/mineru-data"
mkdir -p "$MDIR"
PY="$PWD/desktop/bundled/win-x64/python/python.exe"
export PYTHONPATH="$PWD/desktop/bundled/win-x64/pysvc/mineru-service/lib"
MINERU_DEVICE_MODE=cpu MINERU_MODEL_SOURCE=modelscope \
MODELSCOPE_CACHE="$MDIR" HF_HOME="$MDIR/hf" MINERU_TOOLS_CONFIG_JSON="$MDIR/mineru.json" \
"$PY" -m mineru.cli.fast_api --host 127.0.0.1 --port 8098 > "$RUNNER_TEMP/mineru-smoke.log" 2>&1 &
MINERU_PID=$!
for i in $(seq 1 30); do
sleep 2
if curl -sf -o /dev/null http://127.0.0.1:8098/docs; then
echo "mineru-service up after ~$((i*2))s"
kill $MINERU_PID
exit 0
fi
done
echo "mineru-service failed to start within 60s"
tail -80 "$RUNNER_TEMP/mineru-smoke.log"
kill $MINERU_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled kokoro-service (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
PY="$PWD/desktop/bundled/win-x64/python/python.exe"
APP="$PWD/desktop/bundled/win-x64/pysvc/kokoro-service/app"
export PYTHONPATH="$PWD/desktop/bundled/win-x64/pysvc/kokoro-service/lib"
PORT=8881 "$PY" "$APP/app.py" > "$RUNNER_TEMP/kokoro-smoke.log" 2>&1 &
KOKORO_PID=$!
for i in $(seq 1 20); do
sleep 2
if curl -sf http://127.0.0.1:8881/health && curl -sf http://127.0.0.1:8881/v1/audio/voices | grep -q zf_001; then
echo; echo "kokoro-service up after ~$((i*2))s"
kill $KOKORO_PID
exit 0
fi
done
echo "kokoro-service failed to start within 40s"
tail -60 "$RUNNER_TEMP/kokoro-smoke.log"
kill $KOKORO_PID 2>/dev/null || true
exit 1
- name: Smoke test bundled asr-service (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
PY="$PWD/desktop/bundled/win-x64/python/python.exe"
APP="$PWD/desktop/bundled/win-x64/pysvc/asr-service/app"
export PYTHONPATH="$PWD/desktop/bundled/win-x64/pysvc/asr-service/lib"
export HF_HOME="$RUNNER_TEMP/asr-empty-home"
PORT=8891 "$PY" "$APP/app.py" > "$RUNNER_TEMP/asr-smoke.log" 2>&1 &
ASR_PID=$!
for i in $(seq 1 20); do
sleep 2
if curl -sf http://127.0.0.1:8891/health | grep -q '"modelReady":false'; then
echo; echo "asr-service up after ~$((i*2))s"
kill $ASR_PID
exit 0
fi
done
echo "asr-service failed to start within 40s"
tail -60 "$RUNNER_TEMP/asr-smoke.log"
kill $ASR_PID 2>/dev/null || true
exit 1
- name: Pack pysvc archive (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
# 与 macOS 同构:安装包只带一个 pysvc.tar.gz(文件数大降、安装更快),首启解压
node desktop/scripts/pack-pysvc.js --bundle desktop/bundled/win-x64
- name: Build arm64 Electron shell overlay (Windows)
# Windows 单包双架构(dev-board#341):产出纯 arm64 壳目录供 NSIS
# customInstall 覆盖用。app.asar 无原生依赖双架构通用,剪掉 resources/
#(几 GB 的 x64 重资源)后只剩 Electron 运行时约 120MB。
if: runner.os == 'Windows'
working-directory: desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
shell: pwsh
run: |
npx electron-builder --dir --arm64
if (-not (Test-Path 'release\win-arm64-unpacked\AI WorkDeck.exe')) { throw 'arm64 unpacked dir missing' }
Remove-Item -Recurse -Force 'release\win-arm64-unpacked\resources'
New-Item -ItemType Directory -Force 'build\win\arm64-shell' | Out-Null
Move-Item 'release\win-arm64-unpacked\*' 'build\win\arm64-shell\'
Remove-Item -Recurse -Force 'release\win-arm64-unpacked'
"arm64 shell files: $((Get-ChildItem -Recurse 'build\win\arm64-shell' | Measure-Object Length -Sum).Sum / 1MB) MB"
- name: Render one-click installer art (Windows)
# 一键安装 UI(awd-oneclick-ui.nsh,dev-board#339)的位图构建现场渲染、
# 不入库;缺了这步 NSIS 编译会因 File 找不到 generated/*.bmp 直接失败
if: runner.os == 'Windows'
shell: pwsh
run: |
$v = node -p "require('./desktop/package.json').version"
node desktop/scripts/render-oneclick-art.mjs --product desktop --out desktop/build/win/generated --version $v
- name: Package installers (Windows, unsigned)
if: runner.os == 'Windows'
working-directory: desktop
env:
# Windows code signing not set up yet (issue #12)
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
run: npx electron-builder --publish never
- name: Build patch assets + signed manifest (tag only)
# 补丁产物平台无关(业务 jar / h5 / 壳层 js 均无原生二进制,mac/win 共
# 用),只在 windows runner 生成一次(快、且 tag 构建两平台都在矩阵里)。
# 与上一版 manifest 内容级去重:未变组件沿用旧版本号与旧 asset URL。
if: runner.os == 'Windows' && startsWith(github.ref, 'refs/tags/v')
shell: bash
env:
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
run: |
node desktop/scripts/build-patch-assets.js \
--version "${GITHUB_REF_NAME#v}" \
--backend desktop/bundled/win-x64/backend/app.jar \
--h5 frontend/dist/build/h5 \
--zeta frontend/dist/zetaoffice \
--pysvc desktop/bundled/win-x64/pysvc \
--prev https://www.aiworkdeck.com/update/desktop/manifest.json \
--out desktop/release/patch
ls -la desktop/release/patch
- uses: actions/upload-artifact@v4
with:
name: aiworkdeck-${{ matrix.os }}
path: |
desktop/release/*.dmg
desktop/release/*.exe
desktop/release/patch/*
if-no-files-found: error
# 发布收口:GitHub Release 在这一个单点 job 里创建,不放在上面的 matrix job
# 里(dev-board#74 稳定性审计)。build 用 fail-fast:false,mac 腿在公证抖动
# 处失败时 windows 腿仍会跑完;发布步骤若留在 matrix 里,windows 腿会独自把
# release 发出去——用户看到的是只有 .exe、缺 .dmg 的半成品版本。
# needs:[build] 不加 if:always(),默认语义就是「build 的所有矩阵腿都成功才
# 跑」,天然挡住这种半成品发布;同构于 pack-release.yml 的 release job
# (needs:[mac,win] 单点发布)。
# patch/* 现在随 aiworkdeck-windows-latest 这个 artifact 一起上传(见上面
# upload-artifact 的 path,仅 windows 腿会真的产出这些文件,mac 腿这条 glob
# 落空不影响该 step——if-no-files-found 看的是全部 path 的汇总匹配)。
# 下面用 merge-multiple 把 mac/windows 两份 artifact 拍平进同一目录再一起发布。
release:
name: Create GitHub Release
needs: [build]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
pattern: aiworkdeck-*
merge-multiple: true
- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/*.dmg
artifacts/*.exe
artifacts/patch/*
# Auto-generate a "What's Changed" changelog from merged PRs since the
# last tag, appended below the bilingual body template. Without this a
# tag build creates a Release with EMPTY notes (had to `gh release edit`
# by hand every time). The template is version-agnostic so it stays
# correct on every release; per-release highlights can still be edited in.
generate_release_notes: true
body: |
## AI WorkDeck — 律师的 AI 原生工作台 / The lawyer's AI-native workstation
**中文** · 桌面版「双击即用」:安装包已捆绑后端与精简 JRE,无需预装 PostgreSQL / Java。首次启动有向导,配置一项 AI 提供商即可解锁核心功能;可选「本地 Ollama」实现数据不出本机。
**English** · Desktop, double-click to run: the installer bundles the backend and a trimmed JRE — no PostgreSQL or Java setup. A first-run wizard gets you started; configure one AI provider to unlock core features. Pick local Ollama to keep all data on-device.
### 下载 / Downloads
- **macOS · Apple Silicon (M 系列)** — `AI WorkDeck-<ver>-arm64.dmg`(已签名公证 / signed & notarized)
- **Windows · x64** — `AI WorkDeck Setup <ver>.exe`(暂未签名,首次运行 SmartScreen 提示属正常 / not yet code-signed; a SmartScreen prompt on first run is expected)
> 不提供 Intel Mac 安装包(依赖生态已停发 x86_64 预编译组件,2026-07-03 起)。/ No Intel Mac build (upstream dependencies no longer ship x86_64 binaries; since 2026-07-03).
官网 / Website: https://www.aiworkdeck.com
---
# 发版收尾:把安装包从 runner 直接推到官网镜像。
#
# 为什么不靠服务器上的 cron(deploy/update-mirror-sync.sh 反向拉 GitHub):
# 境内 ECS 从 GitHub 拉 1.4GB 安装包实测只有 12 KB/s(同一时刻本机 7.1 MB/s),
# 一个包要 32 小时;而卡住的那次持有 flock,之后每小时的 cron 全被「已有实例
# 在跑」跳过——官网下载区一直停在上一版且不会自愈(v0.16.0 实测,最后靠人工
# 下载再 rsync 上去才补上)。runner 在境外,上行不受这条限制。
#
# cron 那条链路保留不动:它仍负责补丁包与 manifest,也是这条推送失败时的兜底。
# needs 必须是 release 而不是 build:这一步会在服务器上跑 update-mirror-sync.sh,
# 而那个脚本是**从 GitHub Release 拉资产**的(上面文件名要换成 Release 资产名
# 就是为了对上它的 skip 判据),最后还要校验 latest.json 已指向本次 tag。
# 发布收口成独立 job 之后,若这里仍写 needs:[build],它会与 release 并行——
# Release 还没建出来,脚本拉不到资产、latest.json 停在上一版,校验步骤必挂。
sync-mirror:
name: Sync installers to mirror
needs: [release]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Download built installers
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: aiworkdeck-*
merge-multiple: true
- name: Push to mirror
env:
MIRROR_HOST: ${{ secrets.MIRROR_HOST }}
MIRROR_SSH_KEY: ${{ secrets.MIRROR_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "${MIRROR_SSH_KEY:-}" ] || [ -z "${MIRROR_HOST:-}" ]; then
echo "::warning::未配置 MIRROR_SSH_KEY / MIRROR_HOST,跳过镜像推送(服务器 cron 会兜底,但境内拉包极慢)"
exit 0
fi
install -m 700 -d ~/.ssh
printf '%s\n' "$MIRROR_SSH_KEY" > ~/.ssh/mirror_key
chmod 600 ~/.ssh/mirror_key
ssh-keyscan -H "$MIRROR_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ls -la artifacts
DEST=/www/wwwroot/update/desktop/installers
SSH_OPTS="-i $HOME/.ssh/mirror_key -o IdentitiesOnly=yes -o ServerAliveInterval=20"
# **推送之前先落 marker**:服务器上的 cron 每小时一发,看到新鲜 marker 就
# 直接让路(deploy/update-mirror-sync.sh 顶部的判断)。不落这个的话,推送
# 途中起来的那一发 cron 会先抢到 flock、再花一小时从 GitHub 重拉同一批包,
# 把下面的收尾步骤卡到超时——latest.json 停在上一版、本 job 转红、官网
# 下载页继续发旧版(v0.17.0 与 v0.23.0 各踩一次,后者靠人工介入才补上)。
ssh $SSH_OPTS "root@$MIRROR_HOST" 'touch /var/lock/awd-release-push-in-progress'
# --partial --append-verify:这条链路断过(scp 传到 694MB/1437MB 掉线),
# 断点续传比重传整包可靠。
for f in artifacts/*.dmg artifacts/*.exe; do
[ -e "$f" ] || continue
want=$(stat -c%s "$f")
# **文件名必须换成 GitHub Release 的资产名**(空格 → 点号)。
# update-mirror-sync.sh 的「skip (exists)」判据是资产名,构建产物
# 带空格(AI WorkDeck Setup 0.17.0.exe),名字对不上它就当没传过,
# 照样从 GitHub 重拉一遍 1.4GB——这条推送等于白做。
# v0.17.0 实测:包都推到了,cron 仍在重下,latest.json 卡在上一版
# 导致本 job 的校验步骤失败。
name=$(basename "$f" | tr ' ' '.')
echo "→ $(basename "$f") → $name ($want bytes)"
for try in 1 2 3 4 5; do
rsync -e "ssh $SSH_OPTS" --partial --append-verify --timeout=120 \
"$f" "root@$MIRROR_HOST:$DEST/$name" && break
echo "第 $try 次未传完,续传重试"; sleep 10
done
got=$(ssh $SSH_OPTS "root@$MIRROR_HOST" "stat -c%s '$DEST/$name' 2>/dev/null || echo 0")
[ "$got" = "$want" ] || { echo "::error::$name 传输不完整($got/$want)"; exit 1; }
done
# 安装包已就位,跑一次同步脚本让它补 latest.json 与补丁包(会 skip exists)。
# **先等锁再跑**:脚本自带 `flock -n`,撞上 cron 那一发就静默 exit 0,
# latest.json 停在上一版、下面的校验必挂(v0.17.0 实测)。
# `flock -w 3600 ... true` 只是「等到锁空出来就立刻放手」,随后由脚本
# 自己去加锁。两步之间的窗口极小,真撞上了下面的校验也会报出来。
# 上面的 marker 已经挡住「推送途中新起的 cron」;这里的等待是为了另一种
# 情形——推送开始之前就已经在跑的那一发,它拉完 GitHub 才会放手,
# 1200s 不够(v0.23.0 实测超时),放宽到一小时。
# FORCE=1:让脚本无视自己刚落下的 marker。
ssh $SSH_OPTS "root@$MIRROR_HOST" \
'flock -w 3600 /var/lock/awd-update-mirror-sync.lock true \
&& cd /www/wwwroot/update/desktop && FORCE=1 bash update-mirror-sync.sh' | tail -8
# marker 的清理在下面独立的 always() 步骤里做——这里失败也要清掉,
# 否则残留 marker 会让 cron 白白让路(有 90 分钟过期兜底,但那是最后一道)。
echo "校验 latest.json 指向 ${GITHUB_REF_NAME}"
curl -fsS --max-time 20 https://www.aiworkdeck.com/update/desktop/installers/latest.json | tee /tmp/latest.json
grep -q "\"${GITHUB_REF_NAME}\"" /tmp/latest.json || { echo "::error::latest.json 未指向 ${GITHUB_REF_NAME}"; exit 1; }
- name: Clear release-push marker
# 必须 always():推送失败/校验失败时同样要清,否则 marker 留在服务器上,
# 接下来 90 分钟的 cron 全部让路,镜像反而更新不了。
if: always()
env:
MIRROR_HOST: ${{ secrets.MIRROR_HOST }}
run: |
set -uo pipefail
[ -n "${MIRROR_HOST:-}" ] || exit 0
[ -f ~/.ssh/mirror_key ] || exit 0
ssh -i ~/.ssh/mirror_key -o IdentitiesOnly=yes "root@$MIRROR_HOST" \
'rm -f /var/lock/awd-release-push-in-progress' || \
echo "::warning::marker 清理失败,90 分钟后自动过期"