发版脚本只写部署清单、忘写 package.json,版本号在前端冻结半年
用户反馈桌面 App 里「检查更新」页面一直转圈,始终显示「读取中…」。第一反应是 IPC 没注册,或者 preload 没注入。翻遍最新代码,IPC handler 赫然在目,preload bridge 的日志也确实有其他模块在用。整整折腾了 45 分钟,最后是一个 jq .version desktop-electron/package.json 的输出把一切讲清楚了。
现象
- 用户安装最新版后打开「关于」/ 「检查更新」页面,版本 Chip 显示
v0.1.4,而当前已发布到v0.1.12。 - 遥测 sink 里该用户设备的日志
app_version字段也是"0.1.4"。 - 在 App 内调用
app.getVersion()返回的是正确的0.1.12——OS 层面「关于本应用」也对。 - 最新 commit 的
main.ts里 IPC handler 存在;但用户这台机器上的二进制根本没有这个 handler,因为那是几周前旧构建安装的。
根因
桌面应用版本号有三个层次,发版脚本只动了第一层:
| 层次 | 文件 | 谁读它 | 发版脚本是否更新 |
|------|------|--------|----------------|
| 部署清单 | lan-versions.json、release-notes.json | 安装页、更新 feed | ✅ 每次更新 |
| 框架元数据 | desktop-electron/package.json | electron-builder、next.config.mjs 构建时注入 | ❌ 从未更新 |
| 运行时覆盖 | electron-builder --config.extraMetadata.version=X | 打包后的 app.getVersion() | ✅ 覆盖正确 |
extraMetadata.version 在打包时直接覆盖运行时元数据,所以 app.getVersion() 始终正确。但 next.config.mjs 里有这样一段:
// next.config.mjs
const pkg = JSON.parse(fs.readFileSync('desktop-electron/package.json', 'utf8'))
const nextConfig = {
env: {
NEXT_PUBLIC_APP_VERSION: pkg.version, // ← 构建时冻结
},
}NEXT_PUBLIC_APP_VERSION 在 next build 时被 bake 进静态 chunk,之后就是字面字符串,永远不变。而 desktop-electron/package.json 的 version 字段从 0.1.4(初始值)起就再没人动过。
electron-builder --config.extraMetadata.version=X 只在打包阶段覆盖进 asar,让 app.getVersion() 返回正确值。它不会回写磁盘上的 package.json。
前端的 NEXT_PUBLIC_APP_VERSION / VITE_APP_VERSION 在 next build / vite build 时读的是磁盘文件——这个文件没人碰,所以版本号永远是初始值。
CI 的冒烟测试通常只检查 app.getVersion(),结果正确,CI 全绿,但前端 Chip 的 bug 会安静地存活数月。
三个排查陷阱让根因难以定位:
app.getVersion()是对的——第一个怀疑方向就跑偏了,以为「版本系统正常,是 UI bug」。- 最新 commit 里 IPC handler 存在——看最新源码一切正常,却没意识到用户装的是更早的构建。
git log -- desktop-electron/package.json看起来正常——偶尔有 dep 更新的 commit,不像「从来没人动」。
真正的信号只在两个地方:jq .version desktop-electron/package.json,以及 sink 遥测里的 app_version 字段。
正解
在发版脚本里,让框架元数据与部署清单同步更新:
// scripts/release-electron-lan.mjs
const DESKTOP_PKG = path.join(ROOT, 'desktop-electron/package.json')
// 原有:只写部署清单
export function writeManifestVersion(version) {
const data = JSON.parse(fs.readFileSync(LAN_VERSIONS, 'utf8'))
data['mac-arm64'] = version
fs.writeFileSync(LAN_VERSIONS, JSON.stringify(data, null, 2) + '\n')
}
// 新增:同步写框架元数据,保证 next build 时 NEXT_PUBLIC_APP_VERSION 正确
export function writeDesktopPkgVersion(version) {
const pkg = JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf8'))
pkg.version = version
fs.writeFileSync(DESKTOP_PKG, JSON.stringify(pkg, null, 2) + '\n')
}
// 调用处:两个一起写
writeManifestVersion(version)
writeDesktopPkgVersion(version) // ← 关键增补
// git add 也要把 package.json 纳入 bump commit
run('git', ['add',
'desktop-electron/lan-versions.json',
'desktop-electron/package.json', // ← 关键增补
'docs/release-notes/lan-mac.json',
])验证方法:
# 跑完发版脚本后
git show --stat HEAD~1
# 期望:lan-versions.json + package.json + release-notes 都出现在 bump commit 里
jq .version desktop-electron/package.json
# 期望:与刚发布的版本号一致,不再是 0.1.4在 bump 函数里同时写 package.json,并在 git add 里纳入该文件,保证每次发版 commit 的快照里构建时常量的来源已经是新版本号。
extraMetadata.version 保留——它让多平台并行构建时运行时版本始终精确;package.json.version 同步——它让前端 NEXT_PUBLIC_* 常量在下次 next build 时能读到正确值。两者互补,不是非此即彼。
额外的观测保底
这个 bug 能藏半年,根本原因是没有遥测。事发后给自动更新模块的每个状态转换都接入了 sink:
// 每个状态转换都打到 sink,而不只写 electron-log
autoUpdater.on('checking-for-update', () => {
sink.emit('auto-updater', { event: 'checking', version: app.getVersion() })
})
autoUpdater.on('error', (err) => {
sink.emit('auto-updater', { event: 'error', msg: err.message })
})下次再出版本号相关的 bug,一个 curl 查 sink 即可定位,不需要再反推用户装了哪个构建。
一句话外卖
发版脚本里加
extraMetadata.version的那一刻,同时把package.json纳入 bump commit——否则运行时正确会永远遮蔽构建时常量的腐烂。