麻子哥问鼎了...
在日常的前端工程化实践中,GitHub Actions 自动化发布 NPM 包是提升效率的常用手段,但稍不注意的配置细节就可能引发诡异的问题。本文将分享我遇到的「Filter 组件发布时同一版本重复发布两次,最终因版本冲突报错」的问题,从现象到原理拆解根因,并给出可落地的解决方案。
一、问题现象:发布流程执行两次,版本冲突报错
1. 现象描述
基于 GitHub Actions 编写了自动化发布脚本,用于发布 ui-web-cli、search-chat、filter 等多个前端组件到 NPM 仓库。其中 Filter 组件发布时,日志中出现两次构建(vite build)和两次 npm publish 执行,最终抛出以下错误:
go
npm error You cannot publish over the previously published versions: 0.0.4.
关键日志片段:
shell
> @infinilabs/filter@0.0.4 prepublishOnly
> vite build # 第一次构建
# 第一次发布成功日志...
> @infinilabs/filter@0.0.4 publish
> npm publish # 触发二次发布
> @infinilabs/filter@0.0.4 prepublishOnly
> vite build # 第二次构建
# 第二次发布因版本已存在报错...
2. 问题影响
- 发布流程执行冗余的构建和发布操作,耗时翻倍;
- 第二次发布因版本冲突直接失败,导致 CI 流程报错中断;
- 虽第一次发布已成功,但 CI 失败会误导开发人员认为发布未完成,增加排查成本。
二、根因分析:NPM 发布钩子与自定义脚本的递归触发
1. 核心原理:NPM 发布的生命周期钩子
NPM 提供了一系列发布相关的生命周期脚本(scripts),当执行 npm publish 时,会按以下顺序触发:
prepublishOnly:发布前执行(如构建、校验),不可跳过;publish:发布过程中执行;postpublish:发布完成后执行。
这些钩子的设计初衷是简化发布流程,但如果自定义脚本中包含 npm publish,就会触发递归调用。
2. 问题根源定位
查看 Filter 组件的 package.json 发现了关键配置:
json
{
"name": "@infinilabs/filter",
"version": "0.0.4",
"scripts": {
"prepublishOnly": "vite build",
"publish": "npm publish" // 罪魁祸首
}
}
当 GitHub Actions 脚本中执行 npm publish 时,触发了以下递归流程:
markdown
CI 执行 npm publish
→ 触发 prepublishOnly → 执行 vite build(第一次构建)
→ 触发 publish 脚本 → 执行 npm publish(第二次发布)
→ 再次触发 prepublishOnly → 执行 vite build(第二次构建)
→ 再次触发 publish 脚本 → 执行 npm publish(版本已存在,报错)
简单来说:自定义的 publish 脚本会递归调用 npm publish ,导致发布流程执行两次。
3. 辅助诱因:CI 脚本的幂等性缺失
除了核心的递归触发问题,CI 脚本本身的两个设计缺陷加剧了问题:
- 发布前未校验 NPM 仓库中是否已存在该版本,即使第一次发布成功,仍会无脑执行第二次发布;
- PR 合并时未添加
[skip ci]标记,可能触发 workflow 二次执行(虽本次问题非此原因,但会放大问题影响)。
三、解决方案:切断递归+保证发布幂等性
针对根因,我们采取「移除有害脚本 + 发布前校验 + 跳过冗余钩子」的组合方案,彻底解决重复发布问题。
步骤 1:移除 package.json 中的递归脚本
删除 package.json 中自定义的 publish 脚本,这是最核心的一步:
perl
{
"name": "@infinilabs/filter",
"version": "0.0.4",
"scripts": {
"build": "vite build",
"prepublishOnly": "vite build" // 保留用于本地发布校验,CI 中会跳过
// 移除 "publish": "npm publish"
}
}
步骤 2:优化 CI 脚本,保证发布幂等性
修改 GitHub Actions 脚本,增加版本校验、跳过冗余钩子、防止二次触发:
关键修改点 1:发布前校验版本是否已存在
在发布步骤前添加校验,避免重复发布:
bash
- name: Check if version already published to NPM
working-directory: ${{ env.REPO_NAME }}
run: |
cd ${{ env.DIST_DIR }}
# 获取包名
PACKAGE_NAME=$(jq -r .name package.json)
# 检查 NPM 上是否已存在该版本
if npm view "$PACKAGE_NAME@$VERSION" version >/dev/null 2>&1; then
echo "::warning::Version $VERSION of $PACKAGE_NAME is already published, skipping publish."
echo "SKIP_PUBLISH=true" >> $GITHUB_ENV
else
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
fi
关键修改点 2:发布时跳过生命周期钩子
CI 流程中已提前执行构建,发布时通过 --ignore-scripts 跳过 prepublishOnly 和 publish 钩子,避免重复构建:
bash
- name: Inject Repository & Publish
if: ${{ env.SKIP_PUBLISH == 'false' }}
working-directory: ${{ env.REPO_NAME }}
run: |
cd ${{ env.DIST_DIR }}
# 移除可能残留的 publish 脚本(兜底)
tmp=$(mktemp)
jq 'del(.scripts.publish)' package.json > "$tmp" && mv "$tmp" package.json
# 发布时跳过所有生命周期脚本
npm publish --provenance --access public --ignore-scripts
关键修改点 3:防止 PR 合并触发二次执行
在创建 PR 的 commit message 中添加 [skip ci],避免 PR 合并触发 workflow 重复执行:
yaml
- name: Create pull request for version bump
uses: peter-evans/create-pull-request@v7
with:
commit-message: "chore: release ${{ env.COMPONENT }} v${{ env.VERSION }} [skip ci]"
# 其他配置...
完整优化后的 CI 脚本片段
bash
# 省略其他配置...
- name: Check if version already published to NPM
working-directory: ${{ env.REPO_NAME }}
run: |
cd ${{ env.DIST_DIR }}
PACKAGE_NAME=$(jq -r .name package.json)
if npm view "$PACKAGE_NAME@$VERSION" version >/dev/null 2>&1; then
echo "::warning::Version $VERSION already published, skipping."
echo "SKIP_PUBLISH=true" >> $GITHUB_ENV
else
echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
fi
- name: Inject Repository & Publish
if: ${{ env.SKIP_PUBLISH == 'false' }}
working-directory: ${{ env.REPO_NAME }}
run: |
cd ${{ env.DIST_DIR }}
# 兜底移除 publish 脚本
tmp=$(mktemp)
jq 'del(.scripts.publish)' package.json > "$tmp" && mv "$tmp" package.json
# 仅当 repository 不存在时注入(避免重复修改)
if ! jq '.repository' package.json >/dev/null 2>&1; then
tmp=$(mktemp)
jq --arg url "$REPO_URL" '.repository = { type: "git", url: $url }' package.json > "$tmp" && mv "$tmp" package.json
fi
# 跳过脚本发布
npm publish --provenance --access public --ignore-scripts
四、避坑总结:NPM 发布自动化的核心原则
通过本次问题排查,我们总结出前端自动化发布 NPM 包的 3 个核心避坑原则:
1. 禁用递归的 publish 脚本
永远不要在 package.json 的 scripts.publish 中执行 npm publish,这会触发无限递归(直到版本冲突/超时)。如需自定义发布逻辑,建议使用 release 等自定义脚本名:
json
{
"scripts": {
"release": "npm publish" // 手动执行 npm run release,而非依赖钩子
}
}
2. 保证发布流程的幂等性
发布前必须校验 NPM 仓库中是否已存在该版本,即使流程重复触发,也能自动跳过发布步骤,避免版本冲突。
3. CI 中显式控制生命周期钩子
CI 流程中建议提前执行构建、校验等操作,发布时通过 --ignore-scripts 跳过 NPM 生命周期钩子,避免重复执行构建逻辑。
4. 防止 CI 流程的循环触发
通过 [skip ci] 标记、分支忽略等方式,避免 PR 合并、代码提交等操作触发发布流程的二次执行。
五、最终效果
优化后,Filter 组件的发布流程执行一次构建、一次发布,CI 日志无报错,版本发布成功且无冗余操作:
perl
> @infinilabs/filter@0.0.4 build
> vite build # 仅一次构建
# 发布校验通过,执行一次发布
npm notice 📦 @infinilabs/filter@0.0.4
npm notice Tarball Details
npm notice name: @infinilabs/filter
npm notice version: 0.0.4
# 发布成功日志...
结语
自动化发布的核心是「可预测性」,看似简单的 npm publish 背后,隐藏着 NPM 生命周期钩子的执行逻辑。本次问题的根源是对 NPM 脚本钩子的理解不足,导致递归触发发布流程。在工程化实践中,理解工具的底层原理,再辅以幂等性、防重复触发的设计,才能构建稳定可靠的自动化流程。