semantic-release 与 Changesets 版本发布自动化手册
一次“发布失败”为什么造成了两个版本
周五下午,发布任务在最后一步创建 GitHub Release 时报错。值班同学看到红色退出码,直接点了“重跑所有任务”。第二次运行没有补齐 Release:有的编排器会在 Registry 阶段报 version already exists,semantic-release 则可能因为版本 Tag 已存在而判断没有新版本可发。npm 的 latest 已经指向新版本,公开说明却仍然缺失。一条流水线留下了三种互相冲突的事实:
Registry: @acme/parser@2.4.0 已存在,latest -> 2.4.0
Git: v2.4.0 已指向本次源码提交
Release: v2.4.0 页面和附件不存在根因不是“CI 偶尔不稳定”,而是团队把发布当成了一条原子命令。实际上,版本计算、Changelog、构建、Registry Publish、Git Tag、Release 元数据和通知分属多个系统,任意一步都可能在前面已产生外部副作用后失败。正确的第一反应是冻结后续发布,查询每个远程状态,而不是盲目重跑。
semantic-release 和 Changesets 解决的正是这条链路最前面的“版本真相”问题,但两者的真相源不同:
semantic-release 读取发布分支上自上一个 Tag 以来的提交,由规则推导版本并在同一次运行中执行发布插件。Changesets 读取仓库中已审核的变更意图文件,先计算多包版本和依赖传播,再通过 Version PR 把结果交给人审核。
单包、提交约定成熟、希望主线自动发布时,semantic-release 的状态较少。Monorepo 包各自版本化,或者团队必须在发布前审核影响集时,Changesets 通常更容易治理。同一批包不能同时让两套工具修改版本、Changelog 和 Tag。
先对齐发布模型
安装前先回答“谁声明版本影响”和“何时审核版本结果”:
| 问题 | semantic-release | Changesets |
|---|---|---|
| 谁声明版本影响 | 提交消息,由分析规则推导 | 贡献者写入 .changeset/*.md |
| 何时得到版本号 | 发布流水线运行时 | changeset version 或 Version PR 更新时 |
| 是否默认人工审核版本 | 否 | 是,推荐审核 Version PR |
| 多包内部依赖传播 | 核心默认流程不提供完整独立包图治理 | 原生支持 Monorepo 传播、fixed 与 linked |
| 典型发布节奏 | 合并到发布分支后立即发布 | 变更持续累积,合并 Version PR 后发布 |
接着盘点仓库的发布基线:
受保护的默认分支和可审查的合并流程。锁定的 Node.js、包管理器、发布工具和插件版本。能执行测试、构建、SCA 与 Secret 扫描的 CI。
唯一发布 Job,以及防止重复执行的并发锁。npm Scope、包所有权、访问级别和 Registry 地址。OIDC Trusted Publishing 或最小权限发布 Token。
上一次发布版本、Git Tag、Registry 版本和源码提交之间的可追溯关系。
首次接管已有项目时,先核对最近一次正式版本对应的提交和 Tag。semantic-release 默认按 v${version} 识别历史;Tag 缺失或指错会把旧提交重新纳入分析。Changesets 的仓库版本若落后于 Registry,则可能在 publish 时出现“版本已存在”或跳过逻辑与预期不符。
git tag --sort=-version:refname | head
git show --no-patch --decorate v2.3.1
npm view @acme/parser versions --json
npm view @acme/parser dist-tags --json四条命令对应四个问题:历史 Tag 是否完整,Tag 是否指向正确提交,Registry 是否已有该版本,可变的 dist-tag 当前又指向哪个不可变版本。
把工具锁进项目
semantic-release
项目内安装便于锁定核心与扩展插件:
npm install --save-dev --save-exact \
semantic-release \
conventional-changelog-conventionalcommitssemantic-release 已带有默认的 Commit Analyzer、Release Notes、npm 和 GitHub 插件,不要再直装一套可能与核心版本冲突的默认插件。只有使用文件型 Changelog、回写 Git、执行外部脚本或其他非默认能力时,才把对应扩展锁为项目的直接依赖。配置里出现一个 Publish 插件,就意味着多出一种需要权限、幂等判断和失败恢复的外部副作用。
npx semantic-release --version
npx semantic-release --dry-runCI 中也可通过带版本约束的 npx semantic-release@<major> 执行,但锁文件中的项目依赖更容易审核和复现。升级核心、preset 或任一插件时,都要重跑 Patch、Minor、Major、无发布、预发布和部分失败样例。
Changesets
将 CLI 安装在 Workspace 根目录,再初始化 .changeset/:
npm install --save-dev --save-exact @changesets/cli
npx changeset init
npx changeset --version
npx changeset statusinit 应当只执行一次,并把 .changeset/config.json 提交进仓库。发布 Job 里临时初始化会用默认值覆盖团队的分支、内部依赖和 Changelog 决策。status 在没有待发布 Changeset 时应正常退出,但不会因为某次功能改动忘记添加 Changeset 而自动失败;这个门禁要在 PR 阶段另行建立。
只留一个生产入口
发布命令应封装为仓库脚本,避免每条流水线复制不同参数:
{
"scripts": {
"release:preview": "semantic-release --dry-run",
"release:semantic": "semantic-release",
"release:status": "changeset status",
"release:version": "changeset version",
"release:publish": "changeset publish"
}
}上面是两种路线的命令对照。落地后应删除未选路线的生产脚本,并让受保护的发布 Job 成为唯一调用者。
从提交到制品的状态机
版本自动化不是“计算一个数字”,而是将一组可审计状态依次推进:
候选输入
semantic-release: 上一 Tag..HEAD 的 Git 提交
Changesets: .changeset/*.md + Workspace 依赖图
|
v
版本计划 -> Release Notes -> Git Tag -> Prepare/构建待发制品
| |
| +-> 摘要、SBOM、签名/来源证明
v
Publish 插件按配置顺序运行
|
+------------+-------------+
v v v
Registry Release 元数据 Channel/dist-tag
+------------+-------------+
|
每一步都必须可独立查询semantic-release 的插件生命周期将这条链分为 verifyConditions、analyzeCommits、verifyRelease、generateNotes、prepare、publish、addChannel、success 和 fail;版本 Tag 由核心流程创建,不是某个 Publish 插件创建。同一阶段有多个插件时按配置顺序运行。Tag 已创建、publish 插件 A 成功、插件 B 失败时,整个进程退出失败,但前面发生的远程写入不会被自动撤销。
Changesets 则把状态拆成两个 Git 阶段:普通 PR 累积 Changeset,Version PR 消费它们并写入版本、Changelog 和内部依赖范围。Version PR 合并后,changeset publish 才将仓库中高于 Registry 的版本发布出去。这种拆分让人能在外部写入前审查完整包集。
设计 semantic-release 的版本通道
分支、Channel 与 Tag
最小 release.config.mjs:
export default {
branches: [
'main',
{ name: 'next', channel: 'next' },
{ name: 'beta', prerelease: true },
{ name: '1.x', range: '1.x', channel: '1.x' },
],
tagFormat: 'v${version}',
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
'@semantic-release/npm',
'@semantic-release/github',
],
};四个概念不能混用:
Branch 决定哪个 Git 分支允许产生发布。Version 是本次计算得到的 SemVer。Git Tag 是源码提交的版本引用,默认 v${version}。
Channel 是使用者获得版本的分发通道,npm 中通常体现为 dist-tag,例如 latest、next、beta。
tagFormat 必须包含且只包含一次 ${version}。维护分支、正式分支和预发布分支的版本范围不能相互冲突,否则同一版本可能由不同分支竞争。
提交分析和插件链
export default {
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: [
{ type: 'perf', release: 'patch' },
{ type: 'refactor', release: 'patch' },
{ scope: 'no-release', release: false },
],
},
],
[
'@semantic-release/release-notes-generator',
{ preset: 'conventionalcommits' },
],
'@semantic-release/npm',
'@semantic-release/github',
],
};默认情况下,fix 通常形成 Patch,feat 形成 Minor,Breaking Change 形成 Major;所有提交的最高影响决定本次版本。自定义 Analyzer 后,Release Notes Generator 应采用同一解析口径,避免“版本升级了,说明却缺失”。
插件先按生命周期排序,再在同一生命周期内按数组顺序执行。加入 @semantic-release/changelog 和 @semantic-release/git 时,通常先生成 Changelog,再准备包,最后提交需要回写的文件。文件型 Changelog 会引入发布提交、保护分支写入和循环触发问题;团队若以 GitHub Release 为正式说明,可减少这层复杂度。
设计 Changesets 的 Monorepo 传播
.changeset/config.json 的关键配置示例:
{
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}fixed 让一组包共同提升并发布。linked 让一组包共享版本上界,但不表示每次其中一个发布时其余包都发布。updateInternalDependencies 决定同批发布的依赖方何时刷新内部依赖范围;旧范围已无法容纳新版本时,Changesets 仍会更新依赖。它不是“所有上游包自动跟发”的总开关。
bumpVersionsWithWorkspaceProtocolOnly 默认为 false;启用后,只有使用 Workspace Protocol 的内部依赖范围才按上述策略更新。混用 workspace: 和普通 SemVer 范围时必须做传播矩阵实验。ignore 会阻止指定包发布,但被忽略包与正常包的联合 Changeset、或者依赖升级必须带动被忽略包时,会形成不可安全计算的状态。永久不发布的包应明确设为 private: true。baseBranch 必须指向团队真实合并主线,本地和 CI 的该分支引用必须足够新。
Changesets 的正式闭环是:主线累积 Changeset,Action 创建或更新 Version PR,团队审核版本、内部依赖范围和 Changelog,合并后再由唯一发布 Job 执行 changeset publish。跳过 Version PR 的包集审核,会让它退化成另一个合并即发布工具。
fixed 和 linked 看起来都在“联动版本”,但传播语义不同。设计系统、核心类库与适配器必须每次一起发布时,可以将它们放入同一个 fixed 组。多个框架适配器可以各自发布,但团队希望它们的版本系列保持对齐时,才考虑 linked:
{
"fixed": [["@acme/core", "@acme/runtime"]],
"linked": [["@acme/react", "@acme/vue"]]
}linked 不会因为 @acme/react 有修改就强制 @acme/vue 每次一起发布。如果团队真正需要原子包集,应建模为 fixed,并承担更频繁发布、更大安装面和更多 CI 时间的代价。将几十个包放进同一 fixed 组,往往说明包边界和兼容策略还没有理清。
在临时仓库里验证版本计算
版本规则的第一次运行不应读取生产 Token。下面两组实验均在临时目录建仓,一组只运行 semantic-release 的分析和 Release Notes 插件,另一组只让 Changesets 改写临时 Workspace。它们可以证明版本决策,不能证明 Registry 凭证、网络和远程政策一定成功。
正向实验:feat 将 1.0.0 推导为 1.1.0
这段 Bash 脚本需要 Git、Node.js 和 npm,并需网络下载依赖。它使用本地 bare 仓库模拟可写远程,不需要 GitHub 账号:
set -eu
root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT
git init --bare "$root/remote.git"
git --git-dir="$root/remote.git" symbolic-ref HEAD refs/heads/main
git init -b main "$root/repo"
cd "$root/repo"
git config user.name "Release Fixture"
git config user.email "release-fixture@example.invalid"
npm init -y
npm pkg set name=release-state-machine-fixture version=1.0.0
npm pkg set private=true --json
npm install --save-dev --save-exact \
semantic-release \
@semantic-release/commit-analyzer \
@semantic-release/release-notes-generator \
conventional-changelog-conventionalcommits
cat > release.config.mjs <<'EOF'
export default {
branches: ['main'],
tagFormat: 'v${version}',
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
],
};
EOF
git add package.json package-lock.json release.config.mjs
git commit -m "chore: seed release"
git tag v1.0.0
git remote add origin "file://$root/remote.git"
git push -u origin main --tags
printf 'batch endpoint\n' > feature.txt
git add feature.txt
git commit -m "feat: add batch endpoint"
git push
set +e
npx semantic-release --dry-run --no-ci 2>&1 | tee semantic-release.log
rc=${PIPESTATUS[0]}
set -e
test "$rc" -eq 0
grep -E "next release version is 1\.1\.0|The next release version is 1\.1\.0" \
semantic-release.log末尾 grep 是实验断言,不是用肉眼判断“日志差不多”。预期是 semantic-release 退出 0,找到 v1.0.0,识别 feat 并输出 1.1.0。因为配置中没有 npm 和 GitHub Publish 插件,也因为开启了 Dry Run,实验不会写入公共 Registry。
反向实验:没有合法发布分支时必须拒绝
在上一个临时仓库的清理逻辑执行前,把配置中的 branches 改为不存在的分支:
export default {
branches: ['missing-release-branch'],
plugins: [
['@semantic-release/commit-analyzer', { preset: 'conventionalcommits' }],
['@semantic-release/release-notes-generator', { preset: 'conventionalcommits' }],
],
};再用退出码和错误码同时断言:
set +e
npx semantic-release --dry-run --no-ci > semantic-negative.log 2>&1
rc=$?
set -e
test "$rc" -ne 0
grep "ERELEASEBRANCHES" semantic-negative.log反例的价值是证明“分支配置错误会关闭发布”,而不是当前分支不合法时静默跳过。若流水线包装脚本吞掉这个非零退出码,门禁就会变成假绿。
semantic-release Dry Run
npm run release:preview预期证据包括:
识别到正确发布分支
找到正确的上一个 Git Tag
列出待分析提交
得到预期 major/minor/patch
输出下一版本与 Release Notes
验证仓库 Push 权限--dry-run 会跳过 prepare、publish、addChannel、success 和 fail。因此它不能证明构建脚本、Registry 发布、资产上传和通知一定成功。Dry Run 验证 Push 权限也意味着预览任务不应随意运行在不可信 PR 上。
用最小提交序列验证规则:
fix: correct parser boundary → Patch
feat: add batch endpoint → Minor
feat!: remove legacy endpoint → Major
docs: update guide → 默认不发布正向实验:底层包 Major 如何传播给依赖方
下面的 Workspace 只有 core 和 app 两个包。app 依赖 core@^1.0.0,而 Changeset 将 core 提升到 2.0.0:
set -eu
root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT
cd "$root"
npm init -y
npm pkg set private=true --json
npm pkg set 'workspaces[0]=packages/*'
npm install --save-dev --save-exact @changesets/cli
npx changeset init
mkdir -p packages/core packages/app artifacts
cat > packages/core/package.json <<'EOF'
{"name":"@fixture/core","version":"1.0.0"}
EOF
cat > packages/app/package.json <<'EOF'
{
"name":"@fixture/app",
"version":"1.0.0",
"dependencies":{"@fixture/core":"^1.0.0"}
}
EOF
cat > .changeset/breaking-core.md <<'EOF'
---
'@fixture/core': major
---
Break the core contract.
EOF
git init -b main
git config user.name "Release Fixture"
git config user.email "release-fixture@example.invalid"
git add .
git commit -m "chore: seed changesets fixture"
npx changeset status --output artifacts/status.json
node - <<'EOF'
const status = require('./artifacts/status.json');
const byName = Object.fromEntries(status.releases.map(x => [x.name, x]));
if (byName['@fixture/core']?.newVersion !== '2.0.0') process.exit(1);
if (byName['@fixture/app']?.newVersion !== '1.0.1') process.exit(1);
console.log('core=2.0.0 app=1.0.1');
EOF
npx changeset version
node - <<'EOF'
const core = require('./packages/core/package.json');
const app = require('./packages/app/package.json');
if (core.version !== '2.0.0') process.exit(1);
if (app.version !== '1.0.1') process.exit(1);
if (app.dependencies['@fixture/core'] !== '^2.0.0') process.exit(1);
console.log('core=2.0.0 app=1.0.1 dependency=^2.0.0');
EOF预期第一次输出 core=2.0.0 app=1.0.1,第二次还要证明 app 的内部依赖范围已改为 ^2.0.0。app 没有自己的 Changeset,但旧范围已无法接受 core@2.0.0,因此它被传播一个 Patch。Version PR 中的“意外加版本”通常就应该这样沿依赖边解释。
反向实验:ignore 不能掩盖联合发布
在同一个临时 Workspace 中,先撤销正向实验对版本和 Changelog 的改写,再将 @fixture/core 加入 ignore:
git reset --hard HEAD
node - <<'EOF'
const fs = require('node:fs');
const file = '.changeset/config.json';
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.ignore = ['@fixture/core'];
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
EOF此时的关键配置等价于:
{
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@fixture/core"]
}然后覆盖原有实验 Changeset,让 core 和 app 一起发布:
cat > .changeset/breaking-core.md <<'EOF'
---
'@fixture/core': major
'@fixture/app': patch
---
Change both packages together.
EOF检查命令必须把非零退出当成成功的反向证据:
set +e
npx changeset status > changeset-negative.log 2>&1
rc=$?
set -e
test "$rc" -ne 0
grep -i "ignore\|ignored" changeset-negative.log错误文案可能随 CLI 变化,非零退出码才是门禁。若 core 确实要与 app 一起发布,就取消忽略并审核完整影响;若 core 不应发布,就拆分 Changeset 和包边界,而不是手工删掉 Version PR 的计算结果。
Changesets Version 预演
先查看计划:
npx changeset status
npx changeset status --output artifacts/changeset-status.json然后在临时分支、临时 Worktree 或可丢弃 CI 工作区执行:
npx changeset version
git diff -- package.json package-lock.json pnpm-lock.yaml yarn.lock .changeset packages必须检查:
哪些包被提升,级别是否符合已审核 Changeset。内部依赖方是否按范围和策略传播。Fixed/Linked 组是否出现意外连带发布。
Changelog 是否面向使用者且没有泄露内部信息。锁文件是否同步,构建和测试是否仍通过。
Changesets 没有一个完全等价于 semantic-release --dry-run 的全生命周期命令。changeset version 会改写文件;预演后应丢弃临时工作区,而不是在共享分支手工反向修改。
发布前再检查包内容:
npm pack --dry-run
npm publish --dry-run这些命令仍不能证明真实 Token、远端策略、Tag 和网络阶段一定成功。
semantic-release CI 接入
name: Release
on:
push:
branches: [main, next, beta]
concurrency:
group: npm-release-production
cancel-in-progress: false
permissions:
contents: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
environment: npm-production
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
registry-url: https://registry.npmjs.org
package-manager-cache: false
- run: npm ci
- run: npm audit signatures
- run: npm test
- run: npm run build
- run: npm run release:semantic
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}fetch-depth: 0 用于读取完整提交与 Tag。npm Trusted Publishing 当前要求 Release Job 使用受支持的 Node.js 与 npm 组合、授予 id-token: write,并让 npm 侧的 Trusted Publisher 精确绑定组织、仓库、工作流文件和可选 Environment;工作流启动时应输出并断言 node --version 与 npm --version。registry-url 明确发布目标,私有依赖则使用独立的只读安装身份。需要 GitHub Issue/PR 评论时再增加对应写权限。
静态 npm-release-production 会将同一仓库的发布串行化,但 GitHub 并发组最多保留一个运行中和一个等待中任务,不是按触发时间维护无限队列。多仓库共享同一包名时,仓库内 concurrency 也不够,还需要 Registry 包所有权的单一写入责任和发布前远程版本检查。
Changesets Version PR 接入
name: Release
on:
push:
branches: [main]
concurrency:
group: npm-release-production
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
package-manager-cache: false
- run: npm ci
- run: npm test
- uses: changesets/action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}这个 Job 只创建或更新 Version PR,不持有 npm 发布身份。Changesets Action 当前文档中的内置 Publish 路径会创建指向 NPM_TOKEN 的用户级 .npmrc,适合经过验证的 Token 模式,却不能直接等同于 OIDC。采用 npm Trusted Publishing 时,把 Version PR 与 Publish 拆开:Version PR 合并后,由受保护的独立 Job 配置 registry-url、id-token: write 和 npm-production Environment,再直接执行已审核的 npm run release:publish。这样 PR 机器人无权发包,发布 Job 也不会被 Action 自动写入的 Token 配置改变认证路径。
示例使用主版本 Tag 便于阅读,实际流水线应把第三方 Action 锁定到已审核 Commit SHA,并由自动依赖更新 PR 提供新 SHA 和变更证据。
Version PR 不是普通依赖更新 PR。Reviewer 要核对包集合、版本传播、Changelog、构建产物变化和兼容性;合并它意味着授权随后发生的外部发布。
正式版、预发布与 Snapshot
semantic-release
# 预览下一版本和 Release Notes
npx semantic-release --dry-run
# 输出诊断信息,日志可能包含内部仓库信息
npx semantic-release --dry-run --debug
# 显式指定发布分支配置只应写入配置文件
npx semantic-release维护分支用于旧大版本补丁,预发布分支用于 beta、alpha 等 Channel。不要把“从某分支运行”理解为“可以随意指定任意版本”;semantic-release 的版本仍由历史 Tag、分支范围和提交分析共同决定。
Changesets
# 查看待发布包和计划版本
npx changeset status
# 消费 Changeset,更新版本和 Changelog
npx changeset version
# 发布仓库版本高于 Registry 的包并创建 Tag
npx changeset publish预发布模式:
npx changeset pre enter next
npx changeset version
npx changeset publish
npx changeset pre exit
npx changeset version
npx changeset publishpre enter 会创建 .changeset/pre.json,记录 Tag、已消费 Changeset 和预发布状态;pre exit 只写入退出意图,真正移除预发布后缀发生在下一次 version。这个文件必须与版本变更一起提交,不能在分支之间手工复制。
预发布版本通常不满足原有的普通 SemVer 范围,因此底层包进入 next 后,依赖方可能出现额外的版本传播。更隐蔽的风险是首次发布的新包:它的 dist-tag 行为可能与已存在包不同。首次预发布要在测试 Scope 中执行,发布后立即查询版本和 dist-tag,不能只看 CLI 退出码。
Snapshot 示例:
npx changeset version --snapshot canarySnapshot 是短期验证版本,不是正式 Prerelease Channel。它应使用独立 dist-tag、记录 Commit SHA,并由保留策略清理,禁止长期成为生产依赖。
默认 Snapshot 使用 0.0.0 作为基版本,需要保留计算后版本语义时可以显式配置:
{
"snapshot": {
"useCalculatedVersion": true,
"prereleaseTemplate": "{tag}-{commit}-{datetime}"
}
}Commit 和时间进入版本号能提高可追溯性,也会增加 Registry 元数据、镜像同步和依赖锁文件的数量。保留策略应按“活跃 PR 引用 + 最近时间窗口”判定,而不是不区分是否被使用地删除所有旧 Snapshot。
停用、迁移与清理
更换版本工具时,最危险的做法是同一天启用新流程并删掉旧 Tag 逻辑。迁移应先冻结发布,导出 Git Tag、Registry 版本、dist-tag 和未发布 Changeset,然后在临时分支让新工具计算下一版本。只有新旧结果差异能被解释,才切换唯一 Release Job。
semantic-release 迁出时可以删除它的配置、脚本和依赖,但不删除历史 Tag、Release 和已发布包。Changesets 迁出前必须先消费、转换或明确废弃 .changeset/*.md 中的变更意图;直接删目录会让已合并的兼容性决策消失。
# 迁出 semantic-release 后清理项目依赖
npm uninstall semantic-release \
conventional-changelog-conventionalcommits
# 迁出 Changesets 前先保存待发布状态
npx changeset status --output artifacts/final-changeset-status.json发布凭证的清理独立于代码清理:删除旧 Trusted Publisher 绑定,撤销不再使用的 Token,移除 Environment Secret,并用一次“旧身份必须被拒绝”的反向实验完成验收。
| 现象 | 优先判断 | 排查路径 |
|---|---|---|
| semantic-release 不发布 | 提交没有触发版本,或当前分支不在 branches | 查看 Analyzer 日志、上次 Tag、当前分支和合并后的真实提交消息 |
| 计算出异常大版本 | 历史 Tag 缺失,Breaking 解析规则错误 | 核对 Tag 指向、tagFormat、Parser 与 Release Rules |
| Dry Run 仍报 Push 权限 | 官方会在预览中验证仓库写权限 | 检查 CI 事件、Token、保护分支与远端 URL,不要直接关闭安全控制 |
| Release Notes 与版本不一致 | Analyzer 与 Notes Generator 使用不同 preset | 统一 preset、parser 和规则测试样例 |
| Changelog 提交被保护分支拒绝 | @semantic-release/git 需要回写主线 | 评估是否取消文件型 Changelog,或使用受控发布身份和例外规则 |
| Version PR 连带大量包 | 内部依赖、fixed/linked 或范围策略触发 | 查看依赖图、配置和每个传播边,不能只手工删版本结果 |
| Changesets 漏掉包 | Changeset 意图缺失、包为 private/ignore,或基线分支过旧 | 核对 PR 变更意图、配置、Workspace 识别和 baseBranch |
publish 报版本已存在 | 任务重复运行,或前次已经部分发布 | 先查询 Registry、Tag 和 Action 输出,禁止直接再次全量重跑 |
| 包发布成功但 GitHub Release 失败 | 多个 Publish 插件存在部分成功 | 把外部副作用逐项盘点,补建元数据或发布新修复版本 |
| 新包进入错误 dist-tag | 首次发布与预发布 Tag 规则不同 | 在测试 Scope 验证首次发布,发布后立即查询 dist-tag |
排障第一原则是先判断失败发生在生命周期哪一步,以及该步骤是否已经产生不可逆外部副作用。日志末尾报错不代表前面的 Registry Publish 没有成功。
把发布身份关进受保护环境
发布 Job 是供应链高权限边界。它可以写 Git Tag、创建 Release、发布公开包,并可能读取构建签名或云身份,必须与普通测试 Job 隔离。
优先使用 Registry 支持的 OIDC Trusted Publishing:
GitHub Actions 发布 npm 时,Release Job 需要 id-token: write。npm Trusted Publisher 必须绑定正确组织、仓库、工作流文件和环境。OIDC 只减少长期 Secret,不会自动证明源码、依赖或发布脚本可信。
当前 npm Trusted Publishing 只能在支持的托管 CI 环境中交换短期身份,不能假设自建 Runner 与托管 Runner 能力等价。OIDC 也只授权发布操作,安装私有依赖仍可能需要独立的只读凭证。将“下载依赖”与“发布新版本”分成两种身份,可以避免一个被恶意安装脚本读取的 Token 同时具备写 Registry 的能力。
不能使用 OIDC 时:
使用短期、Granular、仅限指定包和发布操作的 Token。Token 只注入受保护 Environment 的发布 Job。设置轮换、到期、撤销和泄露响应责任人。
不把 Token 放进 .npmrc、命令参数、仓库配置、缓存或 Artifact。自定义 Registry 的代理、CA、认证 Realm 和 Scope 映射必须单独验证。
保护分支与凭证要同时成立。只保护 main,却允许任意分支工作流读取发布 Secret,攻击者仍可修改脚本窃取 Token。反过来,只限制 Secret,而允许发布机器人绕过所有审核,也会把单个凭证变成全仓库管理权限。
保护 Tag 与保护分支同样重要。若任意维护者都能创建符合 v* 的 Tag,一条按 Tag 触发的发布流水线就可以绕过主线审核。发布机器人应只能从受保护分支的已审核 Commit 创建符合 tagFormat 的 Tag,人工移动或删除发布 Tag 必须留审计记录。
发布身份应区分:
读取源码与依赖
创建或更新 Version PR
写 Tag / GitHub Release
发布 npm 包
签名或生成 Provenance
修改 dist-tag / deprecate
撤销发布凭证代理环境中还要验证 Registry 域名白名单、TLS 解密影响、OIDC 端点访问、超时和重试。发布命令超时后,先查询远端状态;网络超时不等于远端事务回滚。
npm 在支持的 Trusted Publishing 链路上可生成 Provenance,将包与源仓库、Commit 和构建环境建立可验证关联。它证明“从哪里构建”,不证明“代码没有恶意逻辑”。发布验收要同时保存 tarball 摘要、SBOM、门禁结果与 Provenance,并用 npm audit signatures 在消费端抽样验证,而不是只看包页面上的标识。
Changelog、Release Notes 和 Debug 日志是另一条敏感数据通道。提交信息或 Changeset 摘要若包含客户名、内部工单 URL、未公开漏洞、仓库路径或凭证片段,发布工具会把它们原样带到公开 Release。发布前的说明生成应设置禁止词与 Secret 扫描,Debug 日志只保留在限权 Artifact 中并设定到期删除。
角色分工
| 角色 | 责任 |
|---|---|
| 贡献者 | 提供合格提交或 Changeset 意图,不把敏感信息写入公开说明 |
| 包 Owner | 审核 SemVer、兼容性、依赖传播和公开说明 |
| 平台团队 | 维护 Release Job、Runner、OIDC、并发锁和审计证据 |
| 安全团队 | 审核发布身份、Secret、Action/插件供应链和 Provenance |
| 发布负责人 | 审批 Version PR 或生产 Environment,处置部分发布失败 |
变更治理
每次升级 semantic-release、Changesets、插件、Preset 或 Action 时,应在测试仓库验证:
Patch、Minor、Major 和不发布样例。正式、Maintenance、Prerelease 与 Snapshot 路径。Monorepo 内部依赖、Fixed/Linked 和 Private 包。
Changelog、Tag、dist-tag 与 Release 结果。Dry Run、失败重试、重复触发和并发执行。Token 不可用、保护分支拒绝和 Registry 超时。
发布证据至少保存:
源码 Commit SHA
Git Tag 与版本
工具、插件、Node 和包管理器版本
测试与门禁结果
Version PR 或提交分析结果
打包文件清单与制品摘要
Registry、包名、版本和 dist-tag
发布身份与审批记录
失败与人工补偿记录不要用“流水线是绿的”替代发布证据。绿色只说明 Job 最终退出成功,不证明使用者下载到的包来自预期 Commit,也不证明所有 Monorepo 包在同一版本集合上。
版本自动化不会替团队理解 SemVer
semantic-release 相信提交元数据,Changesets 相信变更意图文件。两者都无法从代码自动判断某项行为是否真的向后兼容。数据库协议、配置默认值、类型收窄、CSS 选择器、CLI 输出和错误码都可能构成 Breaking Change。版本规则必须由真实消费者契约支撑。
Git 历史是 semantic-release 的数据库
浅克隆、重写历史、删除 Tag、迁移仓库和错误合并策略都会改变分析输入。Squash Merge 场景下,真正进入主线的是 PR 标题形成的最终提交,而不是开发分支上的临时 commit。发布前测试应针对主线最终历史。
Tag 只是引用,不是制品身份。即使 Git 平台允许移动 Tag,也不能据此覆盖 Registry 中已发布的内容。发布证据还需要包摘要、Provenance 或签名。
Monorepo 的难点是依赖图,不是包数量
一个底层包提升 Minor,可能因内部依赖范围不再满足而触发多个上层包 Patch。Peer Dependency、Optional Dependency、Workspace Protocol、Fixed 组和 Linked 组会形成不同传播结果。Version PR 中“为什么这个包也升级”必须能沿依赖边解释。
不要为了缩小发布集合而手工删除被传播的版本更新。若传播不合理,应修改内部依赖策略、包边界或 Changesets 配置,并重新计算。
发布频率是容量与成本参数
版本自动化会降低人工操作成本,却可能同时推高 CI 时长、Registry 存储、安全扫描、镜像同步和下游升级成本。一个底层包的小改动若通过 fixed 或内部依赖传播带动数十个包,发布任务的主要耗时往往不在版本计算,而在每个包的构建、扫描、上传和外部元数据写入。
容量评估要从依赖图的边出发,记录每次 Version PR 的直接变更包数、传播包数、生成 tarball 总字节、发布耗时、Registry 重试次数和失败后人工补偿时间。这些数据应看趋势:若“传播包 / 直接变更包”长期上升,优先检查包边界和内部版本范围,而不是简单增加 Runner。
发布 Job 可以重用经过摘要验证的构建制品,但不应盲目重用包管理器的可写缓存。高权限发布 Job 从低信任 PR 缓存恢复可执行文件,会把节省的几分钟变成供应链执行入口。发布环境的缓存 Key、写入者和校验方式要单独设计。
发布不是原子事务
一次流水线可能依次完成:
修改版本
构建 tarball
发布 npm
创建 Git Tag
创建 GitHub Release
上传附件
更新 dist-tag
发送通知这些外部系统没有统一事务。semantic-release 在 Publish 前已经创建版本 Tag;npm 成功而 GitHub Release 失败时,直接重跑可能因为 Tag 已存在而没有新版本可处理,也可能由外围脚本再次发布并得到“版本已存在”。Tag 已创建而包发布失败时,下次版本分析同样可能认为该提交已完成版本推进。每个步骤必须定义幂等检查、远端查询和人工补偿。
外部制品不可变,回滚不是覆盖
npm 官方明确规定 package@version 一旦使用便不能替换,即使 Unpublish 也不能重新使用同一名称和版本。正确恢复流程是:
冻结后续发布,记录已经成功和失败的步骤。查询 Registry、dist-tag、Git Tag、Release 和制品摘要。必要时把 latest 等可变 Channel 移回已知良好版本。
对错误版本执行 deprecate;只有满足 Registry 政策时才评估 Unpublish。从正确源码生成一个新版本并重新通过完整门禁。通知消费者升级、降级或锁定版本。
保留事故证据,修正发布步骤的幂等和恢复设计。
删除 GitHub Release、移动 Tag 或重跑流水线都不等于撤回已经被下载、缓存或镜像同步的包。
Dry Run 不能覆盖真实发布风险
semantic-release Dry Run 不执行 Prepare 和 Publish;Changesets 的 Version 预演会真实改写工作区;npm publish --dry-run 也不会完成真实远端授权和写入。因此应建立分层验证:规则单测、临时工作区版本计算、测试 Scope 发布、正式包内容检查、受保护环境审批,最后才是生产 Registry。
选择结论
单包、提交约定成熟、希望主线自动发布:优先评估 semantic-release。Monorepo、独立包版本、需要 Version PR 审核:优先评估 Changesets。多语言或多制品仓库:把工具当版本决策组件,不要强迫一个 npm 工具接管所有制品;由上层发布编排统一 Commit、版本和证据。
强监管团队:即使使用 semantic-release,也可以把最终 Publish 放入受保护 Environment,但不能手工覆盖工具算出的版本。
方案与配置
同一批包只有 semantic-release 或 Changesets 一个版本真相源。已明确正式、维护、预发布和 Snapshot 的分支与 Channel。semantic-release 的历史 Tag、tagFormat 和提交解析样例经过验证。
Changesets 的内部依赖、Fixed、Linked、Ignore 和 Private 包策略经过验证。Version PR 的 Reviewer 与审批标准已明确。
semantic-release Dry Run 得到预期版本和 Release Notes。已知 Dry Run 不会执行 Prepare、Publish 和 Add Channel。Changesets Version 在临时工作区预演并审查完整 Diff。
npm pack --dry-run 检查过真实发布文件清单。正式发布前已完成测试 Scope 或等价隔离验证。
权限与供应链
Release Job 只在受保护分支和可信事件上运行。发布使用 OIDC,或使用最小权限且可轮换的短期 Token。Fork PR 和普通分支无法读取发布凭证。
第三方 Action、插件、Preset 与包管理器版本已锁定和审查。Release Job 有并发锁,不会并行发布同一版本。
发布与恢复
每次发布可追溯到 Commit、Tag、版本、构建环境和制品摘要。已区分 Git Tag、Channel、Release 元数据和不可变制品。每个外部步骤都有远端状态查询和幂等判断。
部分发布失败时先盘点副作用,不会直接盲目重跑。团队理解错误版本只能通过 dist-tag、deprecate 和新版本修复,不能覆盖原 package@version。凭证撤销、发布冻结、消费者通知和事故复盘责任人已经确定。
