返回博客

发版脚本只写部署清单、忘写 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.jsonrelease-notes.json | 安装页、更新 feed | ✅ 每次更新 | | 框架元数据 | desktop-electron/package.json | electron-buildernext.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_VERSIONnext build 时被 bake 进静态 chunk,之后就是字面字符串,永远不变。而 desktop-electron/package.jsonversion 字段从 0.1.4(初始值)起就再没人动过。

坑:extraMetadata.version 让运行时对,但前端构建常量还是读 on-disk 文件

electron-builder --config.extraMetadata.version=X 只在打包阶段覆盖进 asar,让 app.getVersion() 返回正确值。它不会回写磁盘上的 package.json

前端的 NEXT_PUBLIC_APP_VERSION / VITE_APP_VERSIONnext build / vite build 时读的是磁盘文件——这个文件没人碰,所以版本号永远是初始值。

CI 的冒烟测试通常只检查 app.getVersion(),结果正确,CI 全绿,但前端 Chip 的 bug 会安静地存活数月。

三个排查陷阱让根因难以定位:

  1. app.getVersion() 是对的——第一个怀疑方向就跑偏了,以为「版本系统正常,是 UI bug」。
  2. 最新 commit 里 IPC handler 存在——看最新源码一切正常,却没意识到用户装的是更早的构建。
  3. 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——否则运行时正确会永远遮蔽构建时常量的腐烂。