发布 & 发版指南
一句话版本
按 type(包名): message 提交 conventional commits,发版一条命令:pnpm release。
| 环节 | 谁负责 |
|---|---|
| 版本号怎么定 | pnpm 原生 release management(pnpm change 写意图 → pnpm version -r 消费) |
| CHANGELOG 怎么出 | git-cliff 逐包渲染,由 @walnut/release 的 src/release/changelog.ts 写入(唯一写入者) |
| GitHub Release 正文 | pnpm release 生成的根 changelog-latest.md(随 release commit 提交) |
| 创建 GitHub Release | .github/workflows/release.yml(tag 推送触发,本地不调 API) |
| 发了什么 | 15 个包的版本号(判据:versioning.fixed 组成员 = 有 version 的 workspace 包)+ 各包 CHANGELOG.md + changelog-latest.md + .changeset/ledger.yaml + git tag vX.Y.Z + push(分支 + tag) |
⚠️ 本仓不用
@changesets/cli。意图文件仍是 changesets 格式(pnpm change沿用),但版本策略的 唯一真源是根pnpm-workspace.yaml的versioning段,.changeset/config.json已删除。详见 ADR 0011。
版本策略:单一 fixed 组
versioning:
changelog:
storage: registry # changelog 由 git-cliff 写,pnpm 不落文件(避免同一版本两段)
fixed:
- # 单一组:全部有 version 的 workspace 包永远同版本
- '@walnut/admin'
# … 其余全部全部有 version 的 workspace 包(apps + platform-any + platform-web + generated + tooling)永远同一个版本号, 发布 tag vX.Y.Z 因此永远有唯一来源(取组内版本,基准是 apps/admin)。
tooling那 6 个是@walnut/tsconfig/@walnut/eslint-config/@walnut/commitlint-config/@walnut/vitest-config/@walnut/scripts/@walnut/release(前五个 2026-09-23 由@walnut/tooling单包拆分而来、见 ADR 0019,vitest-config同日新增)。
为什么是「一组」而不是历史上的「Apps 组 + Packages 组」
pnpm 的 fixed 组各自独立。两个组的写法会留下一条结构性陷阱:一次只动共享包的发版 (如 feat(utils): …)只会 bump Packages 组、apps/admin 的版本不变 ⇒ 编排会命中 「版本号未变更,跳过发版」而退出 0,留下一个已被 bump 却永远不会打 tag 的脏工作区。
一个组从结构上消灭这条路径,也与 ADR 0008 的标题(Unified Versioning)一致。
新增 workspace 包时必须把它加进
versioning.fixed,否则:pnpm change check(CI 与 pre-push 会跑)报锁步失败,且pnpm release第 1 步的 fixed 组审计会直接拒绝发版(exit 1)。
提交纪律(发版的前提)
commitlint 强制(见 @walnut/commitlint-config):
type(包名): message —— scope 必填,且必须是包名或基础设施 scope:
feat(admin): 添加登录页
fix(server): 修复事务回滚
feat(utils)!: 破坏性 API 变更 ← breaking 用括号后感叹号
chore(release): v0.1.0 ← 发版记账提交专用 scope(不是包 scope)
feat(deploy): 部署后验证 ← 基础设施 scope| 层 | scope |
|---|---|
| apps | admin server docs |
| platform-any | utils contract types |
| platform-web | client http |
| generated | (没有 scope)api-types 的改动按路径归属 —— 它几乎总是随 server DTO 一起重生成 |
| tooling | eslint-config commitlint-config tooling |
| 基础设施 | docker deploy pnpm release |
白名单真源是
packages/tooling/commitlint-config/index.ts的SCOPES。tooling这个 scope 保留给 工具链包的改动(@walnut/scripts/@walnut/release/@walnut/tsconfig都在packages/tooling/下, 归属规则按路径命中这些包);release是发版记账提交专用的 infra scope(chore(release): vX.Y.Z), 不产生变更意图。
归属规则(决定「这条提交要不要发版」)
因为只有一个 fixed 组,归属不影响版本号(任何一条意图都会让整组一起升),它只决定 这条提交是否产生意图:
- 路径优先:提交改动的文件落在哪个包目录下(最长前缀);一个提交可命中多个包。
- scope 兜底:路径一个包都没命中时,用 commit scope 查表。
- 基建提交挂到载体包:改了文件、但不在任何包目录下(根配置、
.github/、deploy/、docker-bake.hcl这类)—— 意图挂到@walnut/scripts(拥有 CI / 门禁 / 构建脚本的那个包),按 type 正常参与版本升级 (fix→ patch),于是它会进 changelog 并带动发版。
第 3 条在 2026-09-30 改过一次(原口径是"基础设施改动不带动产品版本号")。改的原因是一次实测: 一个
fix(release)只改了.github/workflows/release.yml+docker-bake.hcl+deploy/README.md(修的正是发版构建失败本身),按旧口径它既不进 changelog 也不升级版本 ⇒ 修复合不进去, "下次发版自动带上"成了死循环。基建也是交付物,静默丢弃比"多一个 patch"贵得多。载体包是必须的:意图文件由
pnpm change写成 changesets 格式,包名只能是本 workspace 的真实包 —— 自造一个infra会被直接拒(ERR_PNPM_VERSIONING_UNKNOWN_PACKAGE,实测踩到过)。本仓是单一 fixed 组, 挂给谁不影响版本号,只决定这条意图记在哪个包的账上。真正不产生意图的只剩两种:一个文件都没改(空提交),以及 type 本身是 skip (
docs/chore/style/test/build/ci)—— 后者在commit-intent.ts的BUMP_MAP里。
bump 映射
| 前缀 | bump | 是否触发发版 |
|---|---|---|
feat | minor | ✅ |
fix / perf / refactor / revert | patch | ✅ |
破坏性变更(type(包名)!:) | major | ✅ |
| 无约定式前缀 | patch | ✅ |
docs / chore / style / test / build / ci | skip | ❌ |
被过滤的噪声:wip:、fixup!、squash!、tmp、draft、纯数字,以及长度 < 4 的消息。
依赖升级(
chore(admin): upgrade vue)默认不触发发版——刻意设计;想记录就手写一个意图。
发版一次的全过程
在 main 分支、工作区干净、已 git pull 的前提下:
pnpm release六步(分节顺序即执行顺序):
0 前置 分支=main / 未落后上游 / fixed 组已对齐 / 钩子已装 / GITHUB_TOKEN(可选)
1 生成意图 扫上次 tag 以来的 commit → 归属到包 → pnpm change --bump --summary <pkg…>
2 确认 bump 列出意图摘要 + 预期版本 → 交互确认,或 --bump 覆盖
3 消费意图 pnpm version -r --no-git-checks(版本 + ledger)
3.4 收走残留 删已记账的 .changeset/*.md;清 .changeset/changelogs/
3.5 changelog 逐包 git-cliff 渲染 → 写 <包>/CHANGELOG.md(幂等、空段跳过、渲染失败即中止)
3.6 notes 整仓本次发版段落 → 写根 changelog-latest.md(CI 的 Release 正文源)
4 总览确认 包归属 / bump / 版本 / 条目 / 将提交文件数 / 将推 refs / 无关改动 → Y/n
5 提交发布 git add -A → git commit -m "chore(release): vX.Y.Z"
→ 发版前全量电池 → git tag -a → git push --atomic origin main vX.Y.Z
6 完成 GitHub Release 由 release.yml 在 tag 推送后创建常见退出路径(不是错误):
| 输出 | 含义 |
|---|---|
没有可生成的变更记录,无需发版 | 有提交但全部是噪声 / 无归属 / 不触发发版 |
无需发版:标签 vX.Y.Z 已在远端、且没有新提交 | 已经在目标状态 |
全部 N 个意图都声明 none:本次不发版 | 意图集明确声明不发版 |
为什么「门禁在打 tag 之前」
pnpm release 在打 tag 之前跑一遍发版前全量电池(release/steps.ts 的 RELEASE_BATTERY):
| id | 内容 |
|---|---|
hooks | 第 0 步:pnpm hooks:check —— 断言本机 git 钩子仍由 lefthook 托管。prepush 本身就是钩子调的(钩子没装时它根本不会跑),所以只有 pnpm release 这种不经钩子的入口才检查得到「本机门禁是否已静默失效」 |
boundaries | turbo boundaries |
circular | pnpm madge:check(apps/server 的 apps + libs 循环依赖;madge 跑 1053 文件约 14–20s。它此前只躺在 package.json 里、不在任何门禁中,批次 2 · R2 接入) |
lint | turbo run lint(所有包,不排任何包) |
lint-root | turbo run lint:root(根级文件的 lint:eslint.config.ts / commitlint.config.ts / knip.config.ts) |
types | turbo run types:check |
types-root | pnpm types:check:root(根 tsconfig 覆盖的 eslint.config.ts / commitlint.config.ts / knip.config.ts) |
test | turbo run test |
syncpack | syncpack lint |
versioning | pnpm change check(fixed 组锁步) |
workflows | pnpm lint:workflows(actionlint)。探测不到 actionlint ⇒ 抛 SkippedError ⇒ 退出码 3 —— 本机未验证,既不是通过也不是违规 |
workflows-shell | pnpm lint:workflows-shell(把每个 run: / with.script: 抽出来逐块 bash -n —— actionlint 看不见字符串里的 shell 语法)。没有 bash 时同样 退出码 3(设 BASH_PATH 可启用) |
dynamic-imports | pnpm lint:dynamic-imports(apps/** 里不许出现拼出来的动态 import —— 模板插值 / 非字面量说明符;tsc 与 vite build 都看不见那一类) |
docs-refs | pnpm lint:docs-refs(活文档正文里引用的 workspace 包名 / 仓库路径必须真实存在) |
adr | pnpm lint:adr(ADR 形态:编号连续 / Status 在枚举内 / 四个必需小节 / index 双向对齐) |
doc-ts | pnpm lint:doc-ts(标成 ts 的代码块必须能按 TypeScript 解析) |
doc-budget | pnpm lint:doc-budget(常驻上下文文件不许膨胀,用不到一半也算失败) |
emoji | pnpm lint:emoji(注释与常驻文档里不许有 emoji:跨所有被跟踪文本文件) |
seed | pnpm lint:seed(初始化数据形态:必需/禁入集合、体积、凭据形状、引用完整性) |
openapi | pnpm openapi:check(= 两段:段① openapi:check:export 重导出 openapi.json(要引导 AppModule,连 PG/Redis)→ 段② openapi:check:schema 重生成 @walnut/api-types 的 schema.d.ts(只读文件、不要数据库),各自 git diff --exit-code。CI 的 openapi job 在没有 ENV_KEYS 的分支里只跑段② —— fork PR 也能硬失败;有 secret 时跑全量) |
dts | pnpm lint:dts(手写 .d.ts 关掉 skipLibCheck 查一遍,只报仓库内文件) |
turbo-cache | pnpm lint:turbo-cache(turbo 配置不变量:产物 / outputs / env / 依赖边 + tags) |
lockfile | pnpm lint:lockfile(pnpm-workspace.yaml 的 catalog ↔ pnpm-lock.yaml 锁步;改声明忘了 pnpm install 时本地全绿、CI 死在 --frozen-lockfile) |
nginx-headers | pnpm lint:nginx-headers(deploy/nginx/conf.d/ 各 server/location 块上的 4 个安全响应头;add_header 是整段替换而不是合并) |
secrets | pnpm lint:secrets(源码密钥形态:PEM 私钥 / 带真口令的连接串 / JWT / 云厂商 AK)。⚠️ 发版面必须有它 —— GitHub 的 push protection 在服务端且先于 CI,被它拒掉时 tag 就推不上去 |
exports | pnpm lint:exports(包 exports 形态:目标存在 / 通配命中 / types 条件在前 / 与顶层 main·types 不矛盾) |
pre-hooks | pnpm lint:pre-hooks(有生成物的包,其跑 vite / vue-tsc 的脚本必须接 pre<script> 生成步骤;vite preview 除外)。⚠️ 发版面必须有它 —— 发版要构建,而这条守的正是「脚本没接生成 ⇒ 构建期报一串找不到全局名」 |
build | ⏭️ 默认暂缓:镜像由 release.yml 的 images job 真正构建;要跑就删掉表里那行的 skip |
dist-secrets | pnpm lint:dist(产物去密体检:机密形态 + 后端 env 真值 + 凭据文件)⏭️ 默认暂缓,与 build 是一对 —— 它扫的就是 apps/admin/dist,本地没构建时只会报「前置条件未满足」(退出码 2)。解冻时两条一起解冻 |
这张表与
packages/tooling/release/src/release/steps.ts的RELEASE_BATTERY必须一致 ——steps.test.ts逐行钉住了它。加一段门禁时两边都要改:RELEASE_BATTERY与本页表格 (注意:这里说的「逐行钉住」指的是那个测试文件钉RELEASE_BATTERY,不是本页;本页的表格 眼下仍靠人工同步,见 与参考仓 Z 的基建交叉对比 的 A1)。「跳过」是第四种结论,既不是通过也不是失败(退出码 3,来自
SkippedError;判据在packages/tooling/scripts/src/lib/errors.ts)。上表这两条遇「本机缺工具」时走的就是它:
- prepush:该段打
-、尾巴标[SKIP 未验证],汇总单列「跳过(未验证)」而不是「全部通过」;PREPUSH_STRICT=1时才把「跳过」判红,默认只让它可见、不挡推送。- 本页的电池:子进程以 3 收场时该条记进
unverifiedIds、打一行「跳过(未验证)」, 并计入收尾汇总(releaseBatterySummary():有未验证项时分开报数并附警告,不再说全绿)。 所以没装 actionlint / bash 的发版机既不会因「跳过」而发版失败,也不会把「没跑过」说成「验过了」。权威校验仍在 CI:
.github/workflows/workflow-lint.yml强制装 actionlint 并直接跑二进制。
⚠️
types-root必须是独立一行、且经根脚本跑(pnpm types:check:root)—— 它只是根package.json的脚本、没有对应的 turbo 任务,turbo run types:check:root会直接以Could not find task 'types:check:root' in project非 0 退出。
lint-root曾经也是这个形态,2026-09-23 起不是了:根turbo.json新增了//#lint:root根任务(带精确 inputs —— 就是根脚本那三个 glob),于是它改成走turbo run lint:root从而 可缓存(实测冷跑 3.8s → 热跑 0.11s)。别按旧结论把它改回pnpm lint:root—— 那样会丢掉 缓存;也别把types-root改成 turbo,它到今天仍没有任务定义。判据:turbo run lint:root与turbo run types:check:root各跑一次,前者 1 个任务、后者报Could not find task。
为什么是刻意的:不跑门禁时,release 完全依赖 git push 顺带触发的 pre-push 钩子 —— 而 push 发生在打 tag 之后。于是「本地全绿、发版成功、CI 的 verify 却红」是可达的, 那时远端已经有一个指向坏提交的 tag。放在打标之前 ⇒ 失败不留本地 tag,改完直接重跑。
失败时退出码非 0,且不会留下需要手工清理的本地 tag。 确实要带病发版:--skip-gates(门禁与电池一起跳)或 --skip-gates=<id>[,<id>](只跳这几项)。 两者都会写进 tag annotation(第二段 -m),事后可审计。
只读面与 AI / 非交互用法
| 入口 | 说明 |
|---|---|
pnpm release --status | 现在停在哪一步、下一步是什么、为什么(含工作区与远端事实) |
pnpm release --plan | 同上,但按「计划」措辞 |
pnpm release --intent-only | 只生成变更意图 |
pnpm release --dry-run | 演练:打印将生成的意图 + 目标版本/tag + 后续命令链 |
pnpm release --json | 人类日志全走 stderr,stdout 只放一个 JSON(含 nextStep / version / tag) |
交互只在「是 TTY 且没给对应 flag」时发生;非交互环境缺 flag 一律 exit 2 并说清缺什么, 不猜、不挂,且此时尚未改动任何文件:
| 交互点 | 人在终端 | AI / CI(非交互) |
|---|---|---|
| ① 确认版本升级类型 | 回车取自动检测值,可输 major/minor/patch 覆盖 | --bump major|minor|patch |
| ② 变更总览确认 | 打印总览后 Y/n | --yes / -y |
--dry-run 的边界
不写盘 ⇒ 待消费意图恒为 0 ⇒ 它只演练到第 1 步,输出的是「将要生成的意图 + 目标版本 / tag + 后续命令链」(--json 里是 wouldGenerate / toVersion / bump)。 会写盘或联网的步(pnpm version -r / commit / tag / push / changelog 落盘)不演练 —— 它们要么改工作区要么打远端。
它也不受「非交互必须给 flag」约束:缺 --bump 时采用自动检测档位并打印预览。
退出码
| 码 | 含义 |
|---|---|
| 0 | 完成(含「无需发版」「已在目标状态」) |
| 1 | 检出不一致 / 子步骤失败(fixed 组不一致、HEAD 版本复核失败、提交后工作区仍脏、门禁或电池未过、changelog 渲染失败、push 失败) |
| 2 | 前置条件未满足(不在 main、落后上游、fixed 组半升级、非交互缺 flag、未知 --skip-gates id) |
| 128+N | 信号中断(POSIX 130/143;Windows 上表现为 1) |
断点续跑
判定只看可观测事实(git / 工作区 / ledger / 远端 Release),不靠本机记忆:
| 停在哪 | 重跑会做什么 |
|---|---|
| 意图已生成、未消费 | 直接进 bump 确认(意图正文里的 hash 是幂等键,不会重复生成) |
| 版本已 bump、changelog 没写完 | 幂等补写缺的那些包 |
| 版本已 bump、未提交 | 跳到总览确认 —— 不会再 bump 一次 |
| 已提交、未打 tag / 未 push | 只补打 tag、只补 push |
| 一切都已发布 | 打印 done 与原因,退出 0 |
.changeset/.release-state.json(gitignored)是写前日志:只让原因显示得更准,丢了也能正确接上。- Ctrl+C / SIGTERM 有信号钩子:先确认子进程树真的死了,再按事实重算下一步、打印「停在哪、重跑怎么接」, 然后按 shell 约定(128+N)退出。
- 「还有没有未消费的意图」以
.changeset/ledger.yaml为准,不看文件是否残留。 - 补推只推不提交:只有「版本改动还没提交」时才
git add -A+ commit。
需要人工介入的状态
| 现象 | 含义与处置 |
|---|---|
工作区不是可发版状态:fixed 组没对齐 | 上次消费被中断留下半升级状态。git checkout -- . 还原后重跑 |
已 bump 但还剩 N 个未消费意图 | 刻意不自动选「删」还是「重来」:在已 bump 的版本上再 bump 一档会打出错版本号的 tag。看一眼再决定 |
标签 vX.Y.Z 已存在,但它指向 …,而 HEAD 是 … | 推它等于把别人的 commit 发布成这个版本。先查清是谁打的;确认重打:git tag -d vX.Y.Z |
拒绝打标:HEAD 上的 apps/admin 版本是 … | release commit 没落到 HEAD(提交被钩子拒绝 / 跳过)。先查清再重跑 |
changelog 渲染失败 | 在打标之前中止(放过去那一版的 changelog 就永远补不回来)。常见原因:pnpm install 没装全、cliff.toml 被改坏、GitHub API 不可达 |
GitHub 集成
Release 由 CI 创建,本地不调 API
release.yml 的 release job 在 verify + images 之后运行,用 body_path: changelog-latest.md 作为正文。那份文件由 pnpm release 生成并随 release commit 提交, 所以 CI checkout 到该 tag 时读到的就是本次的正文;旧 tag 重跑也能复现同一份正文。
发版机因此不需要任何能改远端内容的凭据。
GITHUB_TOKEN 是可选的(只影响 changelog 的丰富度)
git-cliff 走 GitHub 原生 provider 补 PR 号与作者:
export GITHUB_TOKEN=<token> # 可选;公开仓匿名也能查,但限流 60 次/小时
pnpm release- 有 token:条目形如
- [#12](…/pull/12) [1a82770](…/commit/1a82770…) by @user **admin** 支持记住登录状态 - 无 token:Step 0 打一次明确警告,条目降级为 commit 链接 + 主题
- 要「缺 token 就硬失败」:
--require-github-meta - token 不落盘(没有
~/.config凭证文件),且不进任何子进程(git push会触发本仓脚本)
想确认 Release 建好了
pnpm release --status # 公开仓无需 token 即可查询远端 Release关键文件
| 文件 | 作用 |
|---|---|
pnpm-workspace.yaml | versioning 段 = 版本策略唯一真源 |
.changeset/ | 意图(*.md)、消费台账(ledger.yaml)、写前日志(gitignored) |
cliff.toml | changelog 渲染规则 |
lefthook.yml | git 钩子唯一真源 |
packages/tooling/release/src/release/ | 发版编排(模块地图见该包的 README) |
.github/workflows/release.yml | tag 推送 → 镜像 → GitHub Release → 部署 |
⚠️ 这一表原本写的是相对路径(
../../../../../cliff.toml之类)——层级数错了,而且这些是 仓库文件、不是站点页面,站点上也渲染不出来。2026-09-23 打开 VitePress 内置死链校验时被抓出来, 已统一改成 GitHub 链接。