一个 fix: commit 推上 main 就发版:semantic-release 配置与三个必踩的坑
项目与文档地址
- semantic-release:
- release-please-action:
- Changesets:
- semantic-release-action:
- npm trusted publishing:
- Conventional Commits:
「自动化发版」指什么
代码合并进 release 分支后,由 CI 自动完成整条链路:算版本号 → 生成 changelog → 打 tag → 建 GitHub Release → 发布到 registry,全程不改人工版本号。
三种流派:
- semantic-release:全自动、commit 驱动、零人工。默认使用 Angular Commit Message Conventions,也可以换成别的 preset(比如
conventionalcommits)。 - release-please(googleapis/release-please-action,Google 出品):半自动。分析 Conventional Commits 后生成一个 Release PR,人工审批合并才真正发布。
- Changesets(
@changesets/cli):半自动。开发者手写 changeset 文件声明本次改动类型,原生支持 monorepo,按依赖拓扑排序发布,包 A 升级时会自动 bump 依赖 A 的包 B。
三者对照
| 维度 | Changesets | semantic-release | release-please |
|---|---|---|---|
| 自动化程度 | 半自动(手写 changeset) | 全自动 | 半自动(需审批 PR) |
| Monorepo | 原生支持 | 需插件 | 原生支持 |
| 版本控制 | 手动声明 | Commit 驱动 | Commit 驱动 |
| Changelog | 从 changeset 生成 | 从 commit 生成 | 从 commit 生成 |
| 学习成本 | 低 | 中(需严格 commit 规范) | 低 |
| 适合场景 | 多包项目、团队协作 | 单包、持续交付 | 单包、需审批流程 |
Commit 类型 → 版本号
semantic-release 默认的映射关系:
fix(pencil): ...→ Patch(1.0.x)feat(pencil): ...→ Minor(1.x.0)feat!: ...或 footer 里的BREAKING CHANGE:→ Major(x.0.0)BREAKING CHANGE:必须写在 commit footer(body),写进第一行标题不生效- 不发布版本:
chore:/docs:/style:/test:/build:/ci:
不支持 0.x。 master/main 上第一个 feat 会直接把版本推到 1.0.0。如果确实想从 0.x 起步,得先手动打个 tag,之后第一个 feat 才会挂到这个 tag 上:
git tag -a v0.1.0 -m "Version v0.1.0"
git push origin v0.1.0
预发布走分支:在 beta 分支上提交会发成 1.0.0-beta.1 这种形式。
semantic-release 的执行顺序是:Verify Conditions → Get last release(分析 Git tags)→ Analyze commits → Verify release → Generate notes → Create Git tag → Prepare → Publish → Notify。注意第二步依赖完整 tag 历史,这决定了后面 workflow 里 fetch-depth 怎么配。
.releaserc.json
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}],
"@semantic-release/github"
]
}
GitHub Actions 最小集成
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
id-token: write # npm OIDC trusted publishing + provenance
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0 # semantic-release 要靠完整 tag 历史算版本
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
坑 1:回写 commit 和 tag 过不了保护分支
semantic-release 需要把 CHANGELOG.md、package.json 的版本 bump 提交回仓库并打 tag。默认的 GITHUB_TOKEN 绕不过 main 分支上的 ruleset(要求所有改动走 PR),@semantic-release/git 直推会被 GitHub 拒掉,报 GH013。
两种解法:
- 建一个 fine-grained PAT(
contents: write),存成RELEASE_PAT; - 建一个 GitHub App(Contents / Issues / Pull requests 均为 read & write),把这个 App 加为该 ruleset 的唯一 bypass actor,再用
actions/create-github-app-token现场取一个短时 token。
很多仓库在 secret 缺失时会 no-op 保持绿灯(用 if gate 控制)。另外 fetch-depth: 0 必须给,否则算不出上一个 tag。
坑 2:[skip ci] 和 tag push 触发打架
release commit 上带了 [skip ci],而 tag push 事件会触发 GitHub 扫描整条 commit message 里的 [skip ci],于是「靠 tag push 触发 publish workflow」这套写法会被静默跳过——不报错,就是不发。
解法是别依赖 tag push 事件:
- publish job 用
needs: release+if: needs.release.outputs.published == 'true'; - 直接
uses: ./.github/workflows/release.yml(把发布流程写成workflow_call)来调用; - 同时 release job 加
if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }}防止自触发。
坑 3:npm 发布凭据
用 npm 的 OIDC trusted publishing 可以完全不要 NPM_TOKEN:npm 拿 workflow 的 OIDC token 换一个短时、限定范围的发布凭据,所以 permissions 里的 id-token: write 就是授权本身,并且会自动生成 provenance。
前提是在 npmjs.com 上配置的 trusted publisher 必须写对 workflow 文件名(例如 release.yml),名字对不上就换不到凭据。旧方案是配 NPM_TOKEN secret。
release-please 的接法
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: node
后续 publish job 用 needs + if: ${{ needs.release-please.outputs.release_created }} 决定要不要执行 npm publish --provenance。
浮动 major tag
发布流程通常会维护 v1、v2 这类浮动 major tag,指向该 major 下最新的 release。这样使用者用 v1 引用时,能自动拿到最新的 minor / patch,而不用改引用版本。