自动更新
Spark Work 桌面端自动更新:electron-builder + GitHub Release + 官网版本中心、UpdateService 检查/下载/安装状态、应用内更新入口、stable/beta 通道、Windows / macOS 签名构建。
Spark Work 桌面端的更新链路有三个真实参与方:GitHub Actions 负责构建与发布, 对象存储(MinIO / S3 兼容)承载安装包,官网版本中心(edu-server 的 /api/v1/desktop/releases/*)提供应用内更新所需的元数据;GitHub Releases 是回退源。 应用内更新由主进程的UpdateService实现,不走 electron-updater: 它自己拉元数据、自己下载、自己用 SHA 校验、最后调用系统方式打开安装包。
1. 整体链路一览
| 环节 | 真实实现 | 产物/接口 |
|---|---|---|
| CI 构建与发布 | .github/workflows/publish-desktop-release.yml | GitHub Releasev<version>+ 平台安装包(同时上传为 5 天有效的 workflow artifacts) |
| 安装包归档 | workflow 的publish-to-official-websitejob(需要 official-website-production环境审批) | aws s3 cp到s3://<bucket>/stable/<version>/(dmg/exe/AppImage/zip/deb/rpm/blockmap) |
| 元数据登记 | apps/desktop/scripts/register-release.mjs | POST{RELEASE_API_BASE}/api/v1/ci/desktop/releases/register(头 X-Release-Token) |
| 应用内检查 | apps/desktop/src/main/services/UpdateService.ts | GET /api/v1/desktop/releases/latest?channel=&platform=&arch=,失败回退 GitHub REST API |
| 官网下载按钮 | apps/website/src/lib/releases.ts+ apps/website/scripts/fetch-downloads.mjs | 运行时读同一个/latest接口,构建期烘焙快照到 src/content/downloads.generated.json |
2. 发布流程:workflow 的真实触发与结构
触发条件(比「只有 apps/desktop/package.json 变更才触发」更宽):
- push 到
master,且改动命中这些路径之一:apps/desktop/package.json、 根package.json、.nvmrc、.node-version、.github/workflows/publish-desktop-release.yml自身。 - 手动
workflow_dispatch,输入:platforms(all / mac / win / mac-arm64 / mac-x64 / win-x64)、publish_to_release(默认 false,即只构建+签名验证)、optional_capabilities_only(只构建深度可选能力包)、ref。
prepare job(判断是否真的要发布):
- 版本号取自
apps/desktop/package.json的version,tag 为v<version>。 - push 触发时会比对
github.event.before那个提交里的版本号;版本号没变就直接结束(should_publish=false)。 - 若同名 tag 已存在且指向别的提交,直接报错退出:「请提升版本号,禁止把新产物发布到旧标签」。
- 更新说明由
scripts/release-notes.mjs从CHANGELOG.md生成; 缺少该版本条目时允许为空,流程继续。 - GitHub Release 由这一个 job 预先创建/更新(
--latest);不能让多个 matrix job 并发创建, 否则会撞 422 already_exists。
构建矩阵(push 默认全平台):
| target | runner | 产物 |
|---|---|---|
mac-arm64 | macos-latest | arm64 dmg |
mac-x64 | macos-26-intel | x64 dmg(Intel runner + Xcode 26) |
win-x64 | windows-2022 | x64 exe(NSIS 安装器) |
构建步骤里的硬性要求:
pnpm固定 11.13.0,Node 用.nvmrc,安装用--frozen-lockfile。- 先跑
pnpm run check:codex-protocol校验 Codex App Server 协议兼容性。 - macOS runner 需要 Python 3.11(node-gyp 9.x 依赖
distutils)。 - macOS 先用
apps/desktop/scripts/import-cert-ci.sh把 Developer ID Application 证书导入临时钥匙串, 并立即用security find-identity -v -p codesigning | grep "Developer ID Application"校验, 证书误配会在构建早期直接失败。 - Windows 构建带
REQUIRE_WINDOWS_SIGNING=1与ALLOW_UNSIGNED_WINDOWS_RELEASE=0, 正式发布必须有证书。 - renderer 打包内存:
NODE_OPTIONS=--max-old-space-size=8192。 - 实际打包分别走
apps/desktop/scripts/build-mac-release.sh与apps/desktop/scripts/build-win-release.sh;macOS 构建只对两类瞬时故障重试一次 (dmg 卸载Resource busy、Apple 侧网络错误)。
Release 发布后(或手动触发且勾选publish_to_release)才进入 publish-to-official-websitejob:它挂在official-website-production 环境上,一次人工审批同时控制 MinIO 上传与版本中心登记。该 job 会先检查 6 个 secrets 是否齐备(RELEASE_API_BASE、RELEASE_CI_TOKEN、RELEASE_MINIO_ENDPOINT、RELEASE_MINIO_BUCKET、RELEASE_MINIO_ACCESS_KEY、RELEASE_MINIO_SECRET_KEY),缺一个就失败。
3. 官网版本中心:登记什么、读取什么
登记(CI → 官网):register-release.mjs的行为可以逐条核对:
- 环境变量:
VERSION、PLATFORM、ARCH、RELEASE_API_BASE、RELEASE_CI_TOKEN、可选CHANNEL(默认 stable)、DIST_DIR(默认apps/desktop/dist)、RELEASE_OBJECT_PREFIX(默认<channel>/<version>)、RELEASE_AUTO_PUBLISH(设为false时发布为未自动上线)。 - 安装包匹配规则:文件名同时满足「含
-<platform>-<arch>.」与扩展名属于[.dmg, .exe, .appimage, .zip, .deb, .rpm];匹配不到直接报错并打印 dist 目录内容。 - 每个文件登记
platform、arch、fileName、fileSize、sha512(Base64)、objectKey、blockmapKey。 - 请求:
POST{apiBase}/api/v1/ci/desktop/releases/register,头X-Release-Token,body{version, channel, files, releaseNotes, autoPublish}; 失败最多重试 3 次(2s、4s 退避),响应code !== 0视为失败。 - workflow 会按矩阵逐个平台/架构调用这个脚本,所以一次发布会登记多条文件记录。
读取(官网页面):
- 运行时:
apps/website/src/lib/releases.ts请求/api/v1/desktop/releases/latest?channel=stable;基地址优先取VITE_RELEASES_API_BASE,没有配置时用同源window.location.origin。 返回结构是{code, message, data: LatestRelease | LatestRelease[] | null}, 字段为 version/channel/platform/arch/fileName/fileSize/publicUrl/releaseNotes/publishedAt。 - 构建期:
pnpm --filter @spark/website prebuild会跑scripts/fetch-downloads.mjs把同一个接口的结果写进src/content/downloads.generated.json;页面首屏用这份快照渲染(无闪烁), 后台再拉一次实时数据替换,接口失败则继续用快照。
4. 应用内更新:状态机、节奏与缓存
状态枚举(UpdateStatusState):idle→checking→ available→downloading→downloaded, 以及 not-available与error。UpdateStatus里还会带 currentVersion、updateInfo、progress、lastCheckedAt、updateSource(version-center/ github)与downloadSource。
检查节奏(真实常量):
| 行为 | 取值 | 说明 |
|---|---|---|
| 启动检查延迟 | 5 秒 | 避免影响首屏 |
| 常规检查间隔 | 30 分钟 | 静默执行(自动检查失败不弹提示) |
| 抖动 | ±5 分钟 | 避免客户端集中请求 |
| 失败退避 | 30 分钟 × 2ⁿ,上限 2 小时 | 连续失败时指数退避 |
| 聚焦/唤醒补查 | 距上次检查超过 30 分钟才补 | browser-window-focus与powerMonitor的resume 触发 |
| 手动检查失败 | 仍会显示错误 | 只有自动检查静默 |
下载与安装:
- 默认
autoDownload: false:发现新版本先进入available,由用户点「下载更新」。 打开「自动下载」后才会在检查时自动下载。 - 下载落在
{userData}/spark-agent-updater/<version>/<assetName>; 应用启动时会清理缓存,只保留最近 2 个版本目录(按目录修改时间)。命中缓存则直接进入downloaded,不重复下载。 - 校验:元数据带
digest(版本中心映射成sha512:<base64>, GitHub 资产可能是sha256:<hex>)时端到端校验,不匹配报「更新包 SHA…完整性校验失败」; 旧版本资产没有 digest 时只记录警告并继续(兼容行为)。 - 下载完成 → 系统通知 + 对话框。macOS 按钮是「打开安装镜像」,说明是「打开 dmg 后请将应用拖到 Applications 替换现有版本」, 随后应用延迟 1.2 秒退出;Windows 按钮是「安装更新」,直接启动 exe 安装器。
- Windows 独有「自动安装」:
autoInstall: true且状态为downloaded时, 退出应用会自动启动安装器(其他平台该设置被强制为 false)。 - 没有增量更新:
blockmap会被上传与登记,但更新服务不消费它—— 每次都是下载完整 dmg/exe(资产筛选时还会显式排除*.dmg.blockmap/*.exe.blockmap)。
IPC 与界面:
- 通道:
update:check、update:download、update:install-restart、update:get-status、update:settings(可改 autoCheck / autoDownload / autoInstall / channel)。 - 推送:
stream:update:status为主,另有stream:update:available、stream:update:progress、stream:update:downloaded。 - 设置 → 更新:状态卡(含下载进度条与操作按钮)、更新内容卡(超过 1200 字符可折叠)、 「打开 Release 页」、「更新策略」四项(自动检查更新 / 自动下载 / 自动安装 / 更新来源)、版本卡。 「更新来源」会写明检查顺序「官网版本中心 → GitHub Releases」并显示当前实际来源 (界面文案分别是
官网/GitHub/待检查)。 - 侧边栏头部图标按钮:只在非 idle 状态出现——
available点击即下载、downloading点击跳到设置页、downloaded点击安装、error/checking有对应图标;tooltip 会显示「来源:官网版本中心 / GitHub Releases」。 - 偏好与上次检查时间持久化在
app_settings的updates/data与updates/lastChecked。
5. 更新源选择、回退与错误文案
- 先请求版本中心:
GET{releasesApiBase}/api/v1/desktop/releases/latest?channel=&platform=&arch=。releasesApiBase默认https://www.yiqibyte.com, 可被dev-app-update.yml/app-update.yml覆盖。请求头user-agent: Spark-Agent-Updater。 - 版本中心不可用(网络失败、
code !== 0、非 2xx)时记录警告并回退 GitHub:stable通道读/releases/latest;beta通道读/releases?per_page=20并取第一个非 draft。 - 版本比较用 tag 规范化后的版本号:不大于当前版本 →
not-available。 平台资产筛选规则:macOS 只接受.dmg,按当前架构优先(arm64 优先含 arm64 的包, x64 优先含 x64 的包,fallback universal);Windows 只接受.exe,含setup加权、含portable降权。找不到合适资产时报「当前 Release 缺少适用于 macOS/Windows 的安装包」。 - GitHub 未认证请求会撞 rate limit:服务会解析
retry-after/x-ratelimit-reset并在提示里给出重置时间;频繁点「检查更新」时请等待或改用带 token 的配置。
配置来源与优先级(用于排查「为什么读到的是另一份配置」):
- 打包版读
{resourcesPath}/app-update.yml(由 electron-builder 依据electron-builder.yml的publish段生成:github / owneralexanderizh/ repospark-agent/ releaseType release)。 - 开发版按顺序找:当前工作目录的
dev-app-update.yml、apps/desktop/dev-app-update.yml等候选路径;仓库里的这份内容是provider: github、updaterCacheDirName: spark-agent-dev-updater、releasesApiBase: https://www.yiqibyte.com。 - 可识别字段:
owner、repo、updaterCacheDirName、token(用于提高 GitHub 限额,仅本地开发用)、releasesApiBase。都找不到时用内置默认值。
6. 签名、公证与原生模块要求
electron-builder.yml:appId: com.spark-agent.desktop、productName: Spark Agent、artifactName: ${productName}-${version}-${os}-${arch}.${ext}、electronVersion: 43.2.0、mac.target: dmg、hardenedRuntime: true、identity: 'Developer ID Application'、公证交给afterSign: scripts/notarize.js。 协议 scheme 注册为spark-agent。- macOS 还需要
APPLE_ID、APPLE_APP_SPECIFIC_PASSWORD、APPLE_TEAM_ID;build-mac-release.sh会在启动前检查这三个变量、 keychain 里的 Developer ID 证书以及xcrun notarytool可用性。 - Windows:
signtoolOptions里是 SHA-256 + RFC3161 时间戳 (http://timestamp.digicert.com),signExts: [.exe]。 CI 传WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD;build-win-release.sh支持本地.pfx路径、http(s)、data URL 或 base64 内容, 并把证书写到临时目录、构建结束自动清理。 - 签名校验:有证书时脚本要求最终
.exe的 Authenticode 状态是Valid; 正式发布路径(REQUIRE_WINDOWS_SIGNING=1)缺少证书会直接失败; 只传了一半凭据也会失败(除非显式允许未签名)。 - 原生模块:
better-sqlite3、keytar、node-pty必须按目标架构重建到 Electron ABI。 打包脚本调用pnpm run rebuild:native -- <arch>,宿主架构与目标一致时会继续跑native:verify(真实用 Electron 加载一次);不一致时跳过并提示「在目标架构 runner 上构建」。 electron-builder.yml关闭了自带 native rebuild(npmRebuild: false), 原因写在配置注释里:node-pty@1.1.0的 install 脚本会跑 tsc 编译测试文件, 类型检查失败会拖垮整个 rebuild。
7. 本地构建与调试
常用命令(都在apps/desktop下,或直接用脚本路径):
# 开发模式(更新检查/下载链路同样可用)
pnpm --filter @spark/desktop dev
# macOS 单架构发布构建(内部会先 rebuild:native 再 electron-builder)
pnpm --filter @spark/desktop build:mac:arm64
pnpm --filter @spark/desktop build:mac:x64
# Windows 发布构建(不发布到 Release)
pnpm --filter @spark/desktop build:win:release -- --publish never
# 或者从仓库根目录:
bash apps/desktop/scripts/build-win-release.sh x64 --publish never
# 带本地证书的 Windows 构建
WIN_CSC_LINK=/path/to/cert.pfx \
WIN_CSC_KEY_PASSWORD=your-pfx-password \
pnpm --filter @spark/desktop build:win:release -- --publish never
调试要点:
dev-app-update.yml会覆盖仓库/缓存目录/版本中心地址;如果要绕过 GitHub 限额, 可以在里面加一个仅供开发用的token。- 本地想验证「版本中心 → 官网下载按钮」链路时,设置
VITE_RELEASES_API_BASE(运行时) 与RELEASES_API_BASE(prebuild拉快照)指向你的 edu-server 实例。 - 可观测点:设置 → 更新 的状态卡与「更新来源」、会话/主窗口里的
stream:update:status事件、侧边栏更新按钮 tooltip、以及{userData}/spark-agent-updater/下的缓存目录。 - 远端 Release 缺少对应平台安装包时,本地也会收到同一错误文案(例如「当前 Release 缺少适用于 macOS 的安装包」)。
8. 通道、设置与常见坑
通道类型是UpdateChannel = 'stable' | 'beta',默认stable,可通过 update:settings修改并持久化在updates/data。需要说清楚的是:当前设置 → 更新 页面没有通道选择器,只有「自动检查更新 / 自动下载 / 自动安装 / 更新来源」四项, 以及检查、下载、安装三个动作按钮;也就是说 beta 在协议与检查逻辑上可用,但普通用户界面上切不了。
常见坑:
- 「自动安装」在 macOS 上打开又自己关掉:这是设计——保存偏好时会强制把非 Windows 平台的
autoInstall置回 false(界面提示「当前平台不支持自动安装,下载后需手动打开安装包」)。 - 以为发的是增量包:blockmap 只被登记,不会被消费;用户每次都下完整安装包。
- 改了
CHANGELOG.md却没触发发布:触发路径里没有 CHANGELOG, 它只影响 release notes 内容;另外版本号不变时 workflow 会判定不发布。 - tag 已存在但指向旧提交:workflow 会直接失败并要求提升版本号,这是防止「新产物挂到旧标签」的保护。
- Release 里能看到安装包,但应用内检查不到:应用内优先读版本中心; 如果版本中心登记失败(缺 secrets 或审批未通过),要么回退 GitHub 成功、要么提示缺少平台资产。 先看 GitHub Release 里资产名是否包含
-<platform>-<arch>.(例如-mac-arm64.dmg),这是登记与筛选共用的命名约定。 - 担心缓存越滚越大:更新器启动时只保留最近 2 个版本目录,其余自动回收; 想手动清理可以删掉
{userData}/spark-agent-updater/。 - Windows 未签名包:本地构建允许产出未签名 exe(会有 SmartScreen 警告), 但 CI 正式发布路径要求签名;判据就是脚本对 Authenticode 状态是否为
Valid的校验。
速查表
| 项目 | 内容 |
|---|---|
| 发布源 | GitHub Release + MinIO 对象存储 + 官网版本中心(GitHub 为回退源) |
| 登记接口 | POST /api/v1/ci/desktop/releases/register(头 X-Release-Token) |
| 检查接口 | GET /api/v1/desktop/releases/latest?channel=&platform=&arch= |
| 版本中心默认地址 | https://www.yiqibyte.com |
| 对象存储路径 | s3://<bucket>/stable/<version>/(dmg/exe/AppImage/zip/deb/rpm/blockmap) |
| 构建矩阵 | mac-arm64(macos-latest)· mac-x64(macos-26-intel)· win-x64(windows-2022) |
| 检查节奏 | 启动延迟 5 秒 · 常规 30 分钟 ± 5 分钟抖动 · 失败退避上限 2 小时 |
| 更新缓存 | {userData}/spark-agent-updater/<version>/,保留最近 2 个版本 |
| IPC | update:check / download / install-restart / get-status / settings |
| 推送 | stream:update:status(另有 available / progress / downloaded) |
| 原生模块 | better-sqlite3 · keytar · node-pty(pnpm run rebuild:native -- <arch> + native:verify) |
| macOS 签名 | Developer ID Application + hardenedRuntime + afterSign 公证(notarize.js) |
| Windows 签名 | WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD,SHA-256 + RFC3161 时间戳 |
上手清单:发布一次桌面端新版本
从改 version 到用户收到更新提示
- 在 apps/desktop/package.json 把 version 改成新号,并在 CHANGELOG.md 里补一条对应版本的说明(缺条目也能发布,但更新说明会为空)
- 合并到 master;只要改动命中 workflow 的 paths(含 apps/desktop/package.json),桌面端发布 workflow 就会触发
- prepare job 比对版本号、创建 v<version> tag 与 GitHub Release(版本号没变会直接跳过)
- 三个 matrix job 分别在 macOS(arm64 / Intel)与 Windows runner 上 rebuild 原生模块、打包、签名(macOS 还会公证),并把安装包上传为 workflow artifacts
- 通过 official-website-production 环境审批后,安装包被复制到对象存储的 stable/<version>/,并按平台逐个登记到官网版本中心
- 打开桌面端 设置 → 更新 点「检查更新」,确认状态进入「可更新」且「更新来源」显示官网;点下载后确认进度与完成提示
- 回到官网下载页确认按钮已指向新版本(运行时接口或构建期快照二选一都应有新数据)
常见问题
GitHub Release 是唯一发布源吗?
不是。CI 会同时把安装包上传到对象存储(MinIO/S3 兼容桶的 stable/<version>/)并把元数据登记到官网版本中心;应用内更新优先读版本中心,失败才回退 GitHub Releases API。
为什么我只改了版本号却要先人工审批?
electron-builder 推完 GitHub Release 后,publish-to-official-website job 挂在 official-website-production 环境上,required reviewer 会拦一次;这次审批同时控制 MinIO 上传与版本中心登记,所以正式分发路径需要人工放行。
应用内更新是增量下载吗?
不是。blockmap 会被上传与登记,但 UpdateService 不消费它,资产筛选中还会显式排除 *.blockmap,因此每次都是完整安装包。
自动检查更新会打扰我吗?
不会弹提示。启动后延迟 5 秒检查一次,之后约每 30 分钟静默检查(±5 分钟抖动),失败按 30 分钟 × 2ⁿ 退避、上限 2 小时;只有手动检查失败才会显示错误。
能切到 beta 通道吗?
协议与检查逻辑支持 stable/beta(beta 会遍历 /releases?per_page=20 取第一个非 draft),也能通过 update:settings 修改并持久化;但当前设置 → 更新 页面没有通道选择器,界面上切不了。
Windows 一定要证书吗?
本地构建可以不带证书(产出未签名 exe,会有 SmartScreen 警告);CI 正式发布路径设置了 REQUIRE_WINDOWS_SIGNING=1 且不允许未签名,脚本还会要求最终 exe 的 Authenticode 状态为 Valid。