某天我去看 README 顶部那行「当前版本:x.y.z」,它写的是 1.16.4。

package.json 已经是 1.18.1 了。差了五个版本,而没有任何人、任何工具发现。

这不是第一次。更早的时候,随扩展包分发的那份 README 是根目录那份的手抄副本,悄悄落后到了 v1.13.1——用户拿到的文档缺了整节内容。

两件事的病根是同一个:

任何靠人维持同步的副本,迟早会漂。

先画出真源关系

更新日志要出现在三个地方:

  1. 安装脚本跑完打印「这次改了什么」——用户唯一会读的地方;
  2. 博客的产品页展示完整历史;
  3. 发版时的群通知带一句摘要。

原来的做法是每处各抄一份。现在是单向的:

package.json.version ──┐
├─→ README 版本号 (build 时注入)
changelog.json ────────┼─→ 云端 changelogs 表 (发版时 push)──→ 博客实时读
└─→ 随包分发 (build 时裁成只剩本版一条)

没有任何一处需要人去「保持一致」。 版本号只写在 package.json;「这版改了什么」只写在 changelog.json——这是唯一一件必须人写的事,因为只有人知道。

README 里写占位符,不写版本号

当前版本:{{VERSION}}

构建时由一个同步脚本从 package.json 读版本号,注入到产物里(随包分发的那份),根目录这份保持占位符不变。

为什么不直接改写根目录的:构建改写模板,会让每次 npm run build 都脏一个文件。你会开始习惯性地忽略 git status 里那个 README——然后某天漏掉一个真改动。

代价是在 GitHub 上直接看 README 会看到 {{VERSION}} 字面量。这是有意的取舍:版本号的真正读者是随包那份,以及直接读云端的博客页。

一张表,多个产品

后端是 PocketBase。那个实例是多项目共用的,现有集合都带项目前缀(PROJ_feature_flags 这种)。

changelog 有意不跟这个前缀,而是叫一个通用的 changelogs,用 product 字段区分:

字段类型说明
producttext产品 slug,与博客 showcase 的目录名一致
versiontext发布号
releaseddate发布时间,前端排序依据
entriesjson[{ type, zh, en? }]
titletext可选,本版一句话标题

读规则公开匿名只读,写规则全部 null(只有发版脚本用超管身份写)。唯一索引 (product, version)——同一产品的同一版本只能有一条,重跑 push 不会造出重复行。

为什么不每个项目一张表:

前缀是为「同一项目的多张表」准备的,changelog 是「多项目的同一张表」——方向正好相反。

博客那边的展示组件只认 changelogs + product 过滤。新产品接入零代码,只在它的 showcase frontmatter 里填一行:

changelogApi: "https://pb.example.com/api/collections/changelogs/records?filter=product%3D%22my-app%22&sort=-released,-created&perPage=200"

注意那个 sort=-released,-created:同一天发多个版本时,只按 released 排序顺序是未定义的。加上 -created 当第二排序键。

包内只留本次一条

包里那份 changelog.json 在 build 末尾被裁成只含当前版本那一条(65.4KB → 0.9KB)。

不是为了省体积(压缩后只占 zip 的 1.4%,单看不值得动)。理由是:

  • 包内那份的唯一消费者是安装脚本,而它只按版本号取一条,其余从装上到卸载没人读过;
  • 「看历史」现在有正主了——博客实时读云端。包里再留全量,就又多了一个会漂的副本:用户装的是 1.15 的包,里面的历史就永远停在 1.15;
  • 它天然是过期数据。changelog 的价值恰恰在于「最新」。

安装脚本一行都没改——它本来就是「按版本号取一条,取不到就静默跳过」。

两道闸门

整套机制里最重要的不是同步,是闸门。 因为同步解决的是「抄错」,而闸门解决的是「漏写」——后者不会报任何错。

闸门一:打包时,更新日志里必须有当前版本

npm run package
→ build
→ changelog.json 里有 package.json 当前版本的条目吗?
没有 → 直接失败

漏写更新日志本来不会报任何错:打包照过、安装照跑,只是用户装完看不到这次改了什么,博客上也少一条。而那恰恰是用户最该看见的一句话。

这道闸门上线当天就抓到一个真实漏网:package.json 已经升到了 1.18.1,但更新日志最新条目还停在 1.18.0——如果照旧发出去,用户会一条更新内容都看不到,而且不会有任何报错。

裁剪脚本自己也查一次:裁完留下的那一条,版本号必须和 manifest 一致。否则安装脚本取不到对应条目会静默 return,用户什么都看不到,你也查不出原因。所以这里宁可让 build 失败,也不产出一个「静默失效」的包。

闸门二:README 里找不到占位符就报警

同步脚本在根 README 里找不到 {{VERSION}} 时会吵一声。

触发它的场景只有一个:有人手写回了硬编码的版本号——而那正是这套机制要根治的东西。闸门的作用是防止系统被「好心」地退化回原来的样子。

历史迁移:一个贪婪正则差点吃掉正文

把 41 个版本、290 条历史条目迁上云时,有个转换:历史条目的前缀(【新增】正文 或 新增(重要):正文)要识别成 type,然后从正文里剥掉(前端会按 type 渲染彩色标签,正文里再留一个就重复了)。

初版正则想顺带吃掉可选的「(重要)」:

/^新增[((]?[^))]*[))]?[::]/ // ✗

[^))]* 是贪婪的,贪进了正文——一条「以前按 F12 打开控制台会看到大量扩展的运行日志(填客户……)」被当成「新增(……)」,前半截正文当前缀剥掉了。

修法是拆成两条确定形式,可选后缀只认字面的「(重要)」。

但更重要的是验证方法:迁移前对全部 290 条做了对拍——转换后的正文必须是原文的后缀。 任何一条不满足就说明剥多了。这条断言只要一行,却能兜住所有「正则写宽了」的情况。

批量转换历史数据时,找一个「转换前后必须满足的关系」对全量做断言,比抽几条肉眼检查可靠得多。

另外两个小处理:released 是 date 类型,历史里有一条写的是字面量「更早」,PB 会拒收,映射到一个确定日期并在脚本里遇到新的非法日期就抛错、指明是哪一版,不静默放行;纯日期统一补成 10:00:00Z 而不是 00:00:00——避免东八区用户看到日期倒退一天。


回头看,这件事里没有一行代码是难写的。

难的是识别出「哪些东西是副本」。README 的版本号看起来像一段普通文字,包里的 changelog 看起来像一份普通文件——它们不会自己告诉你「我是某个东西的抄本,而且我已经过期了」。

一个很实用的检查问题是:这个值,如果我忘了改它,会有任何东西报错吗? 答案是「不会」的地方,要么消灭这个副本,要么给它装一道闸门。