一个人维护的内部产品,发版这件事的风险不在于难,在于步骤多、间隔长、很容易漏一步。

两周发一次版,每次要做的事:打包、传到分发服务器、更新日志推上云、给所有人发一条顶栏公告、在群里发个通知。五件事,漏任何一件用户都会少看到点什么——而漏了不会有任何东西报错。

所以它现在是一条命令:

终端窗口
npm run release
package build + 两道闸门 + 打 zip
→ deploy 传 zip 与安装脚本到分发服务器
→ changelog:push 本版更新日志推上云(博客展示页读它)
→ announce:release 发全员顶栏公告,并自动顶掉上一条发版公告
→ notify:release 发群通知

每一步都能单独跑,都带 --dry(只打印要做什么,不联网写入)。用 && 串起来,任何一步失败后面就不跑——比如打包没过,就不会出现「公告发了、包没传」这种状态。

闸门一:打包阶段

package 里有两道闸门,都是为了「漏了会静默」的事:

  1. 更新日志里没有当前版本的条目 → 打包直接失败。 漏写更新日志不会报任何错,结果是用户装完看不到这次改了什么;
  2. 构建前的配色结构校验、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"}

原因是两份文档描述的不是同一个端点:

支持的元素文本元素的字段
开放平台「发送消息」APItext / 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 状态;
  • 发公告前在本地按客户端的规则先校验一遍。

自动化解决的是「会不会漏步骤」。而这些检查解决的是另一个更隐蔽的问题:「每一步都显示成功了,但其实有一步没成功」。