semantic-release:从提交历史到可恢复发布
semantic-release 不是替开发者把 package.json 中的版本号加一。它读取上一个版本标签之后的提交,判断消费者能够观察到的变化,算出下一个 SemVer,再按插件生命周期更新 Registry、代码托管平台和分发 Channel。真正需要设计的,是输入能否表达契约变化、每个外部写入由谁负责,以及写到一半失败后怎样查清事实。
先确认它是不是这个仓库的版本真相
semantic-release 的输入是 Git 历史。团队如果已经稳定使用 Conventional Commits,能让合并后的提交保留 fix、feat 和 breaking change 信息,并且希望受保护主线自动形成版本,它的模型很直接。版本判断发生在发布任务运行时,不需要额外维护版本提案文件。
这也意味着提交历史必须足够可信。Squash Merge 若把十几个提交压成一句“merge feature”,分析器看到的就只有这句话;Rebase 后丢失 breaking footer,也会把本应是 Major 的变化降成 Minor 或不发布。提交规范在这里不是格式美化,而是版本协议。
Monorepo 中每个包需要独立声明影响、审核版本传播并形成 Version PR 时,应使用家族 17 的 Changesets 权威手册。两套工具可以分别服务不同仓库,但同一个包不能同时由它们修改版本、Changelog、Tag 或 Registry 状态。否则一次提交可能得到两个版本计划,失败恢复时也无法判断谁拥有最终事实。
实验基线使用 semantic-release 25.0.9,Node 运行时要求 ^22.14.0 || >=24.10.0。这两个约束应一起锁定:只固定 semantic-release 而让 Runner 漂移 Node 主版本,升级结果仍然不可复现。
node --version
npm install --save-dev --save-exact semantic-release@25.0.9
npx semantic-release --versionsemantic-release 默认已经包含 Commit Analyzer、Release Notes Generator、npm 和 GitHub 插件。只有确实需要额外生命周期能力时才增加直接依赖。重复安装默认插件或随意混合主版本,常见结果不是“更完整”,而是核心与插件的 peer dependency、配置语义和运行时要求互相错位。
版本不是由提交条数决定的
默认分析逻辑关注提交表达的消费者影响。fix 通常得到 Patch,feat 得到 Minor,带有 breaking change 的提交得到 Major;文档或内部维护类提交默认不形成版本。一个发布区间内有多条提交时,最高影响决定下一版本,不是把每个 bump 逐次相加。
| 提交 | 消费者含义 | 常见结果 |
|---|---|---|
fix: preserve empty query values | 修复既有契约 | Patch |
feat: add cursor pagination | 新增向后兼容能力 | Minor |
feat!: remove legacy pagination | 破坏既有调用方式 | Major |
docs: explain retry policy | 不改变运行时契约 | 默认不发布 |
解析规则必须和团队真实的合并历史对齐。下面的配置使用 Conventional Commits preset,并把性能优化与可观察重构纳入 Patch,同时明确一个不触发发布的 scope。Release Notes Generator 使用同一 preset,避免版本判断识别了提交,说明生成却按另一套语法漏掉内容。
// release.config.mjs
export default {
branches: ['main'],
tagFormat: 'v${version}',
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: [
{ type: 'perf', release: 'patch' },
{ type: 'refactor', scope: 'public-api', release: 'patch' },
{ scope: 'no-release', release: false },
],
},
],
[
'@semantic-release/release-notes-generator',
{ preset: 'conventionalcommits' },
],
'@semantic-release/npm',
'@semantic-release/github',
],
};自定义规则不是给团队提供绕过审查的暗号。no-release 若能遮住真实 API 变化,版本历史很快就会失真。例外需要限制适用路径和审批人,并用真实提交样本做回归,而不是只验证配置文件能被加载。
发布分支同时定义版本范围和分发 Channel
branches 不只是允许名单。它决定哪个 Git 分支可以发布、允许发布什么版本范围,以及发布后进入哪个 Channel。Channel 在 npm 中通常表现为 dist-tag;它是可移动的分发指针,不是不可变版本本身。
export default {
branches: [
'main',
{ name: 'next', channel: 'next' },
{ name: 'beta', prerelease: true },
{ name: '1.x', range: '1.x', channel: '1.x' },
],
tagFormat: 'v${version}',
};main 承担默认正式 Channel,next 承担下一条稳定线,beta 形成预发布版本,1.x 只维护 Major 1 的兼容范围。维护分支与正式分支的版本范围不能重叠竞争。新增分支前应在临时仓库演练从旧 Tag 到候选提交的计算,确认它不会跳过已有版本,也不会试图发布另一个分支已经占用的版本号。
tagFormat 默认是 v${version},其中 ${version} 必须恰好出现一次,并生成合法 Git 引用。接管已有仓库时,先查询最近标签是否遵循同一格式、是否指向 Registry 对应源码。历史标签缺失或指错提交,会把已发布变化重新纳入 analyzeCommits;临时修改 tagFormat 不能修复这段历史,只会制造第二套标签空间。
git tag --sort=-version:refname
git show --no-patch --decorate v3.4.2
npm view @acme/parser versions --json
npm view @acme/parser dist-tags --json第一条查看版本线,第二条确认标签对象,第三条确认不可变版本集合,第四条确认可移动 Channel。四种状态一致,才说明“上一次发布”有可靠起点。
如果配置中不存在任何有效发布分支,semantic-release 会以 ERELEASEBRANCHES 失败。这个错误应该让发布任务关闭,而不是被包装脚本吞掉后显示绿色。分支保护也不能只限制直接 Push;能修改发布工作流、semantic-release 配置、依赖锁文件或 Action 引用的人,实际上都能改变发布控制面。
插件生命周期就是副作用顺序
semantic-release 的插件按生命周期运行。verifyConditions 先检查继续执行所需条件,analyzeCommits 计算发布类型,verifyRelease 校验候选版本,generateNotes 生成说明,prepare 准备待发布内容,publish 写入外部系统,addChannel 将既有版本加入新的 Channel,success 与 fail 处理结果通知。
同一个生命周期内,插件按照配置数组顺序执行。这个顺序会直接决定部分失败时已经发生了什么。例如 npm Publish 已成功,GitHub Release 随后失败,进程虽然返回非零,Registry 中的 package@version 却不会回滚。反过来,如果凭证验证在 verifyConditions 就失败,通常还没有产生版本制品和远端写入,修复凭证后重跑的风险较低。
verifyConditions
↓
analyzeCommits → verifyRelease → generateNotes
↓
创建 Git Tag
↓
prepare → publish → addChannel
↓
success / fail版本 Tag 由核心流程创建,不应误写成某个发布插件的职责。外部编排如果又自行创建相同 Tag、手改版本或上传同名资产,就会和核心生命周期争夺状态。更稳妥的做法是让 semantic-release 拥有版本决定与 Tag,让构建系统在发布前产出一次候选制品,插件只上传经过摘要确认的同一份内容。
文件型 Changelog 需要额外判断。@semantic-release/changelog 会在 prepare 更新文件,通常还要配合 @semantic-release/git 把变化提交回仓库。这样会引入发布提交、受保护分支写权限和循环触发。若 GitHub Release 已经是正式说明载体,仓库未必还需要一份机器回写的 Changelog。增加插件之前,要说明它新增的事实来源、权限和恢复成本。
Dry Run 验证规则,不证明发布成功
生产前最有价值的第一道验证是 --dry-run。它会读取标签和提交,执行条件校验、提交分析与说明生成,输出下一版本,同时跳过 prepare、publish、addChannel、success 和 fail。Dry Run 仍会检查仓库 Push 权限,因此不能把带高权限凭证的预览任务开放给不可信 PR。
{
"scripts": {
"release:preview": "semantic-release --dry-run",
"release": "semantic-release"
}
}npm run release:preview一次可信预览应能解释当前发布分支、识别到的上一个 Tag、纳入分析的提交区间、计算出的版本类型、下一版本和 Release Notes。只看到退出码为零并不够;若因为分支不匹配而跳过发布,日志同样可能没有产生期望结果。
真正的回归需要放在临时 Git 仓库中。以 v1.0.0 为起点提交 feat: add batch endpoint,Dry Run 应得到 1.1.0;将分支配置改为不存在的名称,应非零退出并包含 ERELEASEBRANCHES。再加入 Patch、Major 与无发布样本,才能证明团队规则,而不是证明 CLI 可以启动。
set +e
npx semantic-release --dry-run --no-ci > semantic-release.log 2>&1
rc=$?
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本地实验的 --no-ci 只用于受控临时仓库。正式发布应该在 CI 中运行,并等待测试、类型检查、构建、安全扫描和制品验证全部成功。把 --no-ci 固化到生产脚本,会绕开 semantic-release 对 CI 环境的保护判断。
Dry Run 不执行 Prepare 和 Publish,因此不能证明包内容正确、Registry 授权有效、Provenance 能生成、GitHub 资产能上传或通知可达。npm 包还要运行 npm pack --dry-run 检查实际文件清单,并在隔离 Scope 或测试 Registry 验证授权与远端政策。
发布任务只接触必要权限
版本分析可以在低权限环境进行,真正写入 Registry、Tag 和 Release 的任务必须只接受可信主线事件。Fork PR、普通分支和未经审查的工作流变化不能读取发布凭证。即使使用 OIDC Trusted Publishing,也要限制可信仓库、工作流文件、环境和分支;OIDC 消除了长期 Token,不会自动消除错误工作流获得身份的问题。
Git 仓库权限与 Registry 权限应分开核对。semantic-release 需要读取完整标签和足够深的提交历史,也需要为版本 Tag 与托管平台 Release 执行相应写入。npm 插件只需要目标包的发布权限。一个覆盖整个组织的长期 Token 虽然配置省事,却会把单仓库工作流泄露扩大成组织级发布事故。
发布依赖也属于高权限代码。Action 应锁定可审查引用,semantic-release、preset 和插件应进入锁文件,升级时比较 Node 要求、插件生命周期、默认依赖和生成内容。发布任务不要恢复由不可信 PR 可写的可执行缓存;否则攻击者不必窃取凭证,只需让高权限 Job 执行被污染的缓存。
并发锁需要按版本事实设计。多个提交快速进入主线时,只允许一个发布任务分析同一段历史并写入外部系统。取消旧任务也不是总安全:旧任务可能已经发布 Registry,只是尚未创建 Release。编排器在取消前无法撤销已经发生的外部写入,因此新的任务仍要先查远端状态。
Build Once 必须绑定不可变摘要
理想链路不是在每个插件里重新构建。测试与扫描通过后生成候选 tarball,记录文件清单和摘要;发布插件消费这份候选物,Registry、Release 附件、SBOM 与签名都指向同一内容。若 npm 插件在 prepare 重新执行构建,而外部 Release 上传的是前一个 Job 的产物,两处即使版本号相同也可能不是同一字节。
npm ci
npm test
npm run build
npm pack --dry-run
npm pack --json > artifacts/npm-pack.json
sha256sum *.tgz > artifacts/SHA256SUMS生成目录、包入口、Source Map、许可证和不应发布的配置都要从 pack 清单核对。摘要应和 Commit、版本计划、Runner 镜像及依赖锁文件一起保存。版本号方便人交流,摘要才是判断制品是否相同的证据。
semantic-release 可以发布非 npm 制品,但这依赖插件或自定义脚本。每增加一种外部目标,就增加一套授权、幂等键和部分失败状态。多语言仓库更适合让 semantic-release 提供版本决定,再由上层发布编排消费版本与候选制品;不要假设 npm 工具天然理解容器、系统包和移动应用商店的全部事务语义。
发布失败先查事实,不先点重跑
发布不是跨 Git、Registry 和代码托管平台的原子事务。任务红了,只能证明至少一个步骤失败,不能证明前面的写入都没有发生。恢复时应从不可变事实向可变指针查询:Commit、Git Tag、Registry 版本和摘要、Release 及附件、dist-tag 或 Channel,最后才看通知。
| 观察到的状态 | 含义 | 恢复方向 |
|---|---|---|
| 无 Tag、无 Registry 版本 | 尚未跨越主要外部写入 | 修复条件后重新完整验证 |
| 有 Tag、无 Registry 版本 | 核心已推进版本事实,Publish 未完成 | 冻结发布,确认失败插件与 Tag 处置策略 |
| Registry 已有版本、Release 缺失 | 不可变制品已经公开 | 校验摘要后补齐元数据,不重复发布版本 |
| 版本存在、Channel 指向旧版 | 制品成功,分发指针未推进 | 审核后修复 dist-tag 或执行 addChannel |
| 版本内容错误 | 已发布字节不可安全覆盖 | deprecate、移动 Channel,并发布修复新版本 |
npm 的 package@version 是不可变发布坐标,不能用删除后重发同版本当作回滚。错误版本通常要标记 deprecate,将 latest 等 Channel 移回已知良好版本,再从正确源码生成一个新版本。删除 GitHub Release 或移动 Git Tag,也不会收回已经被下载、缓存或镜像同步的包。
git ls-remote --tags origin 'v3.4.2'
npm view @acme/parser@3.4.2 dist.integrity dist.shasum
npm view @acme/parser dist-tags --json
gh release view v3.4.2 --json tagName,targetCommitish,assets查询输出需要和发布时保存的摘要对比。Registry 已有版本且摘要一致时,补齐 Release 元数据比重新执行整条流水线更安全;摘要不一致时应立即冻结 Channel 和下游推广,不能把“版本号一样”当成内容一致。
外围脚本必须保留 semantic-release 的非零退出码。semantic-release || true 会把权限错误、分支冲突和插件失败伪装成成功,使下游部署拿到半完成状态。通知任务可以设置为不阻断主发布,但发布写入本身不能被同样处理。
运营视角看的是传播半径
自动发布降低了手工操作,却会放大一次错误提交的传播速度。评估发布频率时,应记录从主线合并到版本可用的时长、无发布运行比例、版本计算失败、Registry 重试、Channel 修复、部分失败补偿时间和下游升级失败。指标的用途是发现契约与流程问题,不是逼团队制造更多版本。
频繁出现“无版本”但每次都启动高成本构建,说明版本预判和流水线分层仍可优化。频繁需要移动 Channel,通常说明候选验证不足或正式推广过早。Release 元数据经常晚于 Registry,则应把两个外部步骤的状态和补偿路径明确建模,而不是只增加重试次数。
发布控制面还需要定期恢复演练。选一个隔离包验证错误 Channel 如何回退、已发布版本如何 deprecate、Release 资产如何补齐、凭证如何撤销,以及新版本怎样重新走完整门禁。没有演练过的 Runbook 往往只覆盖“任务还没写入任何东西”的简单失败。
升级时比较行为,不只比较版本号
升级 semantic-release 核心或插件前,先固定一组 Git 历史样本,记录旧版本对 Patch、Minor、Major、预发布、维护分支和无发布提交的输出。新版本在相同临时仓库运行后,比较下一版本、Release Notes、生命周期日志和外部请求计划。Node 主版本也要进入同一矩阵。
默认插件集合和运行时要求可能随主版本变化。升级核心后又保留旧的直接插件依赖,很容易产生表面安装成功、运行时才暴露的冲突。先从锁文件确认最终解析版本,再检查每个自定义插件支持的 semantic-release 范围和生命周期。
回退工具版本只能改变下一次运行,不能撤销上一版已经创建的 Tag 或 Registry 版本。若新版本已产生外部副作用,先按远端事实恢复,再调整锁文件。把 package-lock.json 回滚后直接重跑,会让工具面对已经改变的世界。
一条可验收的生产链路
生产可用的 semantic-release 入口,应能从提交样本稳定推导版本,分支、范围与 Channel 不冲突,历史 Tag 和 Registry 版本可互相追溯。Dry Run 的日志证明分析结果,pack 清单和摘要证明待发字节,受保护 Job 证明权限只在可信事件中出现。
插件顺序需要对应清晰的外部状态,每个写入都能单独查询。任务失败时,操作者能区分“未发布”“Tag 已创建”“Registry 已发布”“Release 缺失”和“Channel 未推进”,并选择补齐、移动指针、废弃或发布新版本。做到这些,semantic-release 才是可恢复的版本控制面,而不只是主线合并后自动运行的一条命令。
