一个人维护的内部产品,发版这件事的风险不在于难,在于步骤多、间隔长、很容易漏一步。
两周发一次版,每次要做的事:打包、传到分发服务器、更新日志推上云、给所有人发一条顶栏公告、在群里发个通知。五件事,漏任何一件用户都会少看到点什么——而漏了不会有任何东西报错。
所以它现在是一条命令:
npm run releasepackage build + 两道闸门 + 打 zip → deploy 传 zip 与安装脚本到分发服务器 → changelog:push 本版更新日志推上云(博客展示页读它) → announce:release 发全员顶栏公告,并自动顶掉上一条发版公告 → notify:release 发群通知每一步都能单独跑,都带 --dry(只打印要做什么,不联网写入)。用 && 串起来,任何一步失败后面就不跑——比如打包没过,就不会出现「公告发了、包没传」这种状态。
闸门一:打包阶段
package 里有两道闸门,都是为了「漏了会静默」的事:
- 更新日志里没有当前版本的条目 → 打包直接失败。 漏写更新日志不会报任何错,结果是用户装完看不到这次改了什么;
- 构建前的配色结构校验、README 占位符检查,任何一个不过都中止。
细节在一张 changelogs 表服务多个产品。这里只强调一个原则:
闸门要放在最前面。 越早失败,越不会留下半完成的状态。
凭据:存账号密码,不存 token
后三步要写云端(PocketBase),要超管身份。一个容易想岔的地方:
PocketBase 的超管 token 是短期的(默认半小时左右)。把 token 存进 .env,等于每次发版都要先去后台拷一个新的——然后某次你就会懒得拷,改成手工去后台操作,自动化就这么退化了。
所以 .env 里存的是账号密码,每次运行现场换一个短期 token,用完即弃。
cp .env.example .env # 填真值;.env 在 .gitignore 里,入库的只有占位符优先级是 真实环境变量 > .env 文件,临时覆盖可以直接写在命令前面。
专用账号,但别以为权限收窄了
发版脚本用的是一个独立的超管账号,不是我的个人账号。
但要诚实地说清楚它的收益:PocketBase 的超管权限是全有或全无,没法按集合细分。 「只给它写更新日志表和公告表的权限」在 PB 里做不到——它实质上仍然是个全权超管。
(要真收窄,得改成普通用户账号 + 逐集合改写规则。但公告所在的那张表被所有客户端读取,动它的规则有误伤全员的风险,不值得。)
独立账号的真实收益是两条:
- 可以单独吊销:怀疑泄露时删掉它,不影响我本人登录;
- 审计分得清:日志里能看出是脚本改的还是人手工改的。
做安全设计时,把「它实际防住了什么」写下来,而不是写「它是专用账号所以更安全」。后者会让人以为权限已经收窄了。
公告:发版公告要顶掉上一条
发版公告的 id 统一用 release-* 前缀,announce:release 会顺带移除上一条发版公告——否则用户会同时看到「v1.17 已发布」和「v1.18 已发布」。
另外还维护着一份历史遗留 id 名单(早期手工发的,没按前缀命名)。这份名单看着很多余,但去掉它就会复现「两条发版公告同时挂着」——在 dry-run 里实测复现过。
公告的 id 为什么发出去就不能改,是另一篇的内容:云端公告的四个决策。
群通知:HTTP 200 也可能是失败
这是整条流水线里最值得单独说的一条。
群机器人(这里是飞书的自定义机器人)发送失败时照样返回 HTTP 200,错误码在响应体的 code 字段里:
HTTP 200{"code":19021,"msg":"sign match fail or timestamp is not within one hour from current time"}只判 res.ok 的话,「签名错误」「机器人已经被移出群」都会被当成发送成功——脚本告诉你发了,群里什么都没有。
const res = await fetch(url, { method: 'POST', headers, body });const body = await res.json().catch(() => ({}));// ★ 两个都要判:HTTP 层和业务层if (!res.ok || (body.code !== undefined && body.code !== 0)) { throw new Error(`发送失败:HTTP ${res.status} / code ${body.code} / ${body.msg}`);}「成功」有两层:传输成功,和业务成功。 很多第三方接口把业务失败包在 HTTP 200 里——对接任何一个新服务,第一件事就是去确认它失败时返回什么。
两份文档,两个端点
还有一次代价更高的:给通知里的标题加了个加粗。
照着飞书开放平台「发送消息」API 的文档,给文本元素加了 style: ['bold']。真发出去,被打回:
HTTP 200 + {"code":19002,"msg":"params error, unknown content value"}原因是两份文档描述的不是同一个端点:
| 支持的元素 | 文本元素的字段 | |
|---|---|---|
| 开放平台「发送消息」API | text / a / at / img / media / code_block / hr / md … | text + style(加粗、斜体……) |
| 自定义机器人(我们用的这条) | 只有 text / a / at / img | 只有 text |
照前者的文档写、发到后者,整条被拒。
这次之所以没演变成事故,是因为上面那道「200 也判 code」的检查把它拦住了。否则就是「显示发送成功、群里其实什么都没有」。
结论:这条通道上没有富文本加粗,强调只能靠文案本身(【新增】、【修复】 这类前缀)。
而且 md 标签也别去试——每试一次都是一条真发到全群的消息。这类接口的试错成本不在你这边,在所有群成员那边。
加签的 HMAC 顺序
如果机器人开了「加签」,签名是拿 ${timestamp}\n${secret} 当 key,对空串做 HMAC——不是拿 secret 当 key、拿那个字符串当 data。
顺序反了会一直报签名错误。文档里这一点很容易看漏。
另外:没开加签就别填 secret,填了反而报签名错误。
两个连脚本都拦不住的
@所有人在群权限没放开时,返回也是code: 0,但不会真的提醒任何人。 这个只能人去群里看。- 通知正文里写了「请运行下方命令」,却忘了真的附上命令——用户反馈「下方是空白的」。模板化的文案要和实际拼进去的内容一起检查。
@所有人 的正确写法是结构化的 at 元素({tag:'at', user_id:'all'});纯文字「@所有人」只显示字,不触发提醒。
别把消息体格式散到调用方
将来要加钉钉、企微,做法是在通知脚本里加一个 buildXxxPayload(),在 send() 里按目标地址的域名分派。
各家的 payload 互不兼容,集中在一处才好维护。散到调用方的话,每加一家就要改所有发通知的地方。
回头看这条流水线,真正起作用的不是「自动化」本身,而是每一步都有人(或脚本)在检查它真的成功了:
- 打包前检查更新日志写了没有;
- 发通知后检查 body 里的 code 而不只是 HTTP 状态;
- 发公告前在本地按客户端的规则先校验一遍。
自动化解决的是「会不会漏步骤」。而这些检查解决的是另一个更隐蔽的问题:「每一步都显示成功了,但其实有一步没成功」。