某天我去看 README 顶部那行「当前版本:x.y.z」,它写的是 1.16.4。
package.json 已经是 1.18.1 了。差了五个版本,而没有任何人、任何工具发现。
这不是第一次。更早的时候,随扩展包分发的那份 README 是根目录那份的手抄副本,悄悄落后到了 v1.13.1——用户拿到的文档缺了整节内容。
两件事的病根是同一个:
任何靠人维持同步的副本,迟早会漂。
先画出真源关系
更新日志要出现在三个地方:
- 安装脚本跑完打印「这次改了什么」——用户唯一会读的地方;
- 博客的产品页展示完整历史;
- 发版时的群通知带一句摘要。
原来的做法是每处各抄一份。现在是单向的:
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 字段区分:
| 字段 | 类型 | 说明 |
|---|---|---|
product | text | 产品 slug,与博客 showcase 的目录名一致 |
version | text | 发布号 |
released | date | 发布时间,前端排序依据 |
entries | json | [{ type, zh, en? }] |
title | text | 可选,本版一句话标题 |
读规则公开匿名只读,写规则全部 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 看起来像一份普通文件——它们不会自己告诉你「我是某个东西的抄本,而且我已经过期了」。
一个很实用的检查问题是:这个值,如果我忘了改它,会有任何东西报错吗? 答案是「不会」的地方,要么消灭这个副本,要么给它装一道闸门。