从零搭建 Monorepo 自动发布工作流(GitHub Actions + pnpm + Lerna)
🚀 省流助手 (速通结论)
如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面最后的 GitHub Actions 配置即可开箱即用:
三个核心要点:
- 只监听 PR 合并事件,避免手动推送误触发。
-
发布前先将
master同步到release,确保基于最新主干代码发版。 -
发布后使用
--ff-only快进master,保持历史线性且零冲突。
如果你想知道为什么这么设计、如何避坑,请继续阅读全文。
1. 引言:为什么要折腾这套流程?
在 Monorepo 项目中,包的版本管理和发布往往是最繁琐的环节。手动执行 lerna publish 不仅容易忘记切换 Node 版本、打错标签,还可能在多人协作时出现版本冲突或漏发包的情况。
本文将手把手带你用 GitHub Actions 搭建一套完全自动化的发布流水线,实现以下效果:
- ✅ 开发者只需将 PR 合并到
release分支,剩下的全部交给机器人。 - ✅ 自动计算版本号,自动生成 CHANGELOG,自动推送 Git 标签。
- ✅ 发布完成后自动将
master分支同步到最新状态,保持双分支一致。
2. 触发时机:如何精确捕获“PR 合并”事件?
很多同学一开始会写成这样:
on:
push:
branches:
- release
问题:任何向 release 分支的推送都会触发(包括手动 git push 或 git commit),不符合“只有 PR 合并才发布”的规范。
正确姿势是监听 pull_request 事件的 closed 类型:
on:
pull_request:
types:
- closed
branches:
- release
但 closed 事件包含两种情形:合并后关闭 和 直接关闭(未合并)。因此我们还需要在 Job 级别加一个条件过滤:
jobs:
publish:
if: github.event.pull_request.merged == true
这样就能精准命中“PR 已合并”的场景,完美避开直接关闭的空跑。
3. 环境配置:锁定 Node 与 pnpm 版本
为了避免因环境差异导致的构建失败,强烈建议将 Node.js 和 pnpm 的版本写死在环境变量中:
env:
NODE_VERSION: "22"
PNPM_VERSION: "10.33.0"
后续步骤通过 ${{ env.NODE_VERSION }} 和 ${{ env.PNPM_VERSION }} 引用,日后升级只需改一处即可。
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: "https://registry.npmjs.org"
4. Git 身份配置:为什么必须用 [bot] 邮箱?
在 CI 中生成的提交需要有一个明确的作者身份。如果随意填写 ci@localhost,GitHub 会将其显示为灰色头像的“幽灵提交”,无法关联到任何账户,也不利于审计追溯。
正确做法是使用 GitHub Actions 官方的 Bot 身份:
- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
其中
41898282是 GitHub Actions App 的唯一数字 ID,加上这串数字后提交会明确归属给机器人。
5. 分支同步策略:为什么发布前要合并 master?
很多团队允许紧急 Hotfix 直接合并到 master 上线。如果 release 分支长期未更新,就可能基于过时代码发布,导致线上问题复现。
因此我们在发布前增加一步:
- name: Sync master into release
run: |
git fetch origin master
git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"
-
--no-ff保留合并历史,清晰记录本次同步动作。 - 提交信息中带上
[skip ci]是一个防御性习惯:即使未来因某种原因推送了这个合并提交,也不会触发额外的工作流。
6. Lerna 发布:本地生成提交,不着急推送
核心发布命令如下:
- name: Publish packages
run: |
npx lerna publish --yes \
--conventional-graduate \
--no-push \
--message "chore(release): publish [skip ci]"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}
参数解释:
-
--yes:跳过所有交互式确认,全自动执行。 -
--conventional-graduate:自动将当前为alpha/beta的预发布包“毕业”为正式版本(例如1.0.0-alpha.0→1.0.0)。 -
--no-push:禁止 Lerna 自动推送,改为后续手动推送。这样可以在 npm 发布成功后再推送 Git 标签,保证原子性。 -
--message:自定义提交信息,包含[skip ci]防止推送后再次触发本工作流。
7. 推送与主干快进:如何让 master 历史保持一条直线?
发布完成后,我们分两步推送:
第一步:推送 release 分支及标签
- name: Push release and tags
run: git push --follow-tags origin release
第二步:将 master 快进到 release
- name: Fast-forward master
run: |
git fetch origin master
git checkout master
git merge --ff-only origin/release
git push origin master
由于发布前我们已经将 master 合并到了 release,加上发布提交,release 必然比 master 多一个新提交。此时使用 --ff-only(仅快进)可以将 master 指针直接移动到 release 的位置,不会产生额外的合并提交,历史图谱干净如线。
8. 并发控制与安全兜底
concurrency:
group: release-publish
cancel-in-progress: false
这一配置确保同一时刻只有一个发布任务运行,新触发的任务会排队等待,避免多人同时合并 PR 造成 Git 推送冲突。
同时,工作流顶部声明权限:
permissions:
contents: write
配合 Personal Access Token(需具备 Contents 读写权限),保证 Git 推送操作万无一失。
9. 结语
通过以上配置,我们实现了一套高内聚、低心智负担的 Monorepo 自动发布流水线。开发者只需专注于代码本身,合并 PR 后喝杯咖啡,机器人会自动完成剩下的所有脏活累活。
附
完整配置文件,欢迎直接复制使用。
如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面这份 GitHub Actions 配置即可开箱即用:
name: Publish from Release
env:
NODE_VERSION: "22"
PNPM_VERSION: "10.33.0"
on:
pull_request:
types: [closed]
branches: [release]
concurrency:
group: release-publish
cancel-in-progress: false
jobs:
publish:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: "https://registry.npmjs.org"
- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Sync master into release
run: |
git fetch origin master
git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"
- name: Install dependencies
run: pnpm install
- name: Publish packages
run: |
npx lerna publish --yes --conventional-graduate --no-push --message "chore(release): publish [skip ci]"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}
- name: Push release and tags
run: git push --follow-tags origin release
- name: Fast-forward master
run: |
git fetch origin master
git checkout master
git merge --ff-only origin/release
git push origin master
下一篇我们将深入探讨 Lerna 版本计算的底层逻辑,以及如何解决令人头疼的 bad revision 'undefined' 错误——敬请期待。