普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月15日首页

从零搭建 Monorepo 自动发布工作流(GitHub Actions + pnpm + Lerna)

作者 donecoding
2026年4月14日 23:09

🚀 省流助手 (速通结论)

如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面最后的 GitHub Actions 配置即可开箱即用:

三个核心要点

  1. 只监听 PR 合并事件,避免手动推送误触发。
  2. 发布前先将 master 同步到 release,确保基于最新主干代码发版。
  3. 发布后使用 --ff-only 快进 master,保持历史线性且零冲突。

如果你想知道为什么这么设计、如何避坑,请继续阅读全文。

1. 引言:为什么要折腾这套流程?

在 Monorepo 项目中,包的版本管理和发布往往是最繁琐的环节。手动执行 lerna publish 不仅容易忘记切换 Node 版本、打错标签,还可能在多人协作时出现版本冲突或漏发包的情况。

本文将手把手带你用 GitHub Actions 搭建一套完全自动化的发布流水线,实现以下效果:

  • ✅ 开发者只需将 PR 合并到 release 分支,剩下的全部交给机器人。
  • ✅ 自动计算版本号,自动生成 CHANGELOG,自动推送 Git 标签。
  • ✅ 发布完成后自动将 master 分支同步到最新状态,保持双分支一致。

2. 触发时机:如何精确捕获“PR 合并”事件?

很多同学一开始会写成这样:

on:
  push:
    branches:
      - release

问题:任何向 release 分支的推送都会触发(包括手动 git pushgit 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.01.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' 错误——敬请期待。

❌
❌