返回博客

发版日志全绿、用户装完却是旧版本——readdir 字典序吃掉新产物

发布流水线跑完,日志全是绿的:bump 看起来对、build 成功、scp/rsync 传输完成、updater 签名校验也通过。然后用户反馈:"装完版本号没变。"

这种 bug 最难查,因为没有任何报错可以追踪,甚至 .sig 校验也验通了——只是验的是错误版本的签名。

现象

  • tauri build / electron-builder 构建新版本(如 0.1.3)并成功退出
  • scp 把 build 产物拉到本地中转目录,日志显示两个文件都传输了
  • rsync 推到部署服务器,服务端文件 mtime 是新的
  • OTA updater 拉 update.json + .sig,签名校验通过,提示有新版本
  • 用户安装后打开 APP,版本号还是 0.1.2

根因

问题由三个独立缺陷叠加触发,单独修任何一个都不够:

第一层:build 工具不清旧产物。 tauri buildelectron-buildercargo build 等工具把产物写入 target/release/bundle/ 后就不管历史文件。上次 0.1.2 的产物还在,这次 0.1.3 新增进去,两个并存:

bundle/nsis/
  app_0.1.2_x64-setup.exe       ← 上次留的
  app_0.1.2_x64-setup.exe.sig
  app_0.1.3_x64-setup.exe       ← 本次新 build
  app_0.1.3_x64-setup.exe.sig

第二层:wildcard scp 把全部拉回来。 scp host:bundle/*.exe local/staging/ 两个都拉,staging 同样变成累积目录。

第三层:readdirSync().find() 按字典序选第一个。 release 脚本里一行看似无害的代码:

const exe = fs.readdirSync(staging).find(f => f.endsWith('-setup.exe'))

readdirSync 在 macOS/Linux 上返回字典序,"0.1.2" < "0.1.3",老版本永远排在前面,find() 命中的始终是 0.1.2 的 exe。.sig 也一起被挑中,配对推上去。

坑:稳定文件名 + 累积产物 = 完美隐形 bug

部署服务器上用稳定名(app-stable.exe / app.dmg / latest.tar.gz)接收发版是合理设计——这样 OTA URL 不需要随版本变更。但正因为是稳定名,rsync 覆盖后 mtime 会更新,从外面看"文件刚被推过",没有任何异常信号。

唯一能识破真相的是 .sig 文件里的 trusted comment

curl -s deploy/.../stable.exe.sig | base64 -d
# trusted comment: timestamp:1779253622  file:App_0.1.2_x64-setup.exe
#                                                 ^^^^^  这就是真实版本

timestamp 是签名时间,file 字段嵌的是原始文件名,Tauri/minisign 签名工具都会写入,是 forensic 第一工具。

正解

三层防御,缺一不可。

1. build 后在 builder 端清除非当前版本文件:

# macOS/Linux
find target/release/bundle -type f ! -name "*_${VERSION}_*" -delete
# Windows PowerShell
$bundleDir = "target/.../bundle/nsis"
Get-ChildItem $bundleDir -File |
  Where-Object { $_.Name -notlike "*_${version}_*" } |
  ForEach-Object { Remove-Item -Force $_.FullName }

2. 每次 release 前清空中转 staging 目录:

fs.rmSync(localStage, { recursive: true, force: true })
fs.mkdirSync(localStage, { recursive: true })

3. find() 改为版本号精确匹配 + fail-fast:

const files = fs.readdirSync(localStage)
const localExe =
  files.find(f => f.includes(`_${version}_`) && f.endsWith('-setup.exe')) ||
  files.find(f => f.endsWith('-setup.exe')) // 兜底:build 工具不带版本号时
 
if (!localExe?.includes(`_${version}_`)) {
  throw new Error(`staging 目录没有 v${version} 的 exe,实际: ${files.join(', ')}`)
}
正解:三层清理 + 精确匹配 + fail-fast

完成后用三个独立证据验证:

  1. 本地 build 产物 size == 服务端文件 size(curl -sIcontent-length
  2. sig 解码 trusted comment 里的 file 字段是当前版本文件名
  3. update.jsonsig 字段 base64 解码后 file 字段同上

三个全对才算真修好,少一个都可能还差一步。

rsync hotfix 时需要加 -c

紧急修复时直接 rsync 正确版本覆盖稳定名文件,.sig 必须加 -c 强制 checksum 校验:

rsync -avc artifacts/.../0.1.3_x64-setup.exe.sig deploy@host:.../stable.exe.sig

不加 -c 时,rsync 默认用 size + mtime 判断是否跳过,.sig 体积极小且通常一样大——rsync 会认为"没变化"直接跳过,旧 sig 留在服务端。

一句话外卖

build 工具不清旧产物 + readdir().find() 不锁版本 = 字典序永远让老版本胜出;任何"挑第一个匹配"的逻辑,在累积产物目录面前都是不确定的。