踩坑记:NPM 发布脚本导致组件重复发布

麻子哥问鼎了...

在日常的前端工程化实践中,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 时,会按以下顺序触发:

  1. prepublishOnly:发布前执行(如构建、校验),不可跳过;
  2. publish:发布过程中执行;
  3. 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 跳过 prepublishOnlypublish 钩子,避免重复构建:

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.jsonscripts.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 脚本钩子的理解不足,导致递归触发发布流程。在工程化实践中,理解工具的底层原理,再辅以幂等性、防重复触发的设计,才能构建稳定可靠的自动化流程。

相关推荐
Hao_Harrision2 小时前
50天50个小项目 (React19 + Tailwindcss V4) ✨ | AutoTextEffect(自动打字机)
前端·typescript·react·tailwindcss·vite7
IT_陈寒2 小时前
Vite 3.0 实战:5个优化技巧让你的开发效率提升50%
前端·人工智能·后端
玲小珑2 小时前
React 防抖函数中的闭包陷阱与解决方案
前端·react.js
咖啡の猫2 小时前
TypeScript编译选项
前端·javascript·typescript
想学后端的前端工程师2 小时前
【Vue3响应式原理深度解析:从Proxy到依赖收集】
前端·javascript·vue.js
小徐不会敲代码~3 小时前
Vue3 学习 5
前端·学习·vue
_Kayo_3 小时前
vue3 状态管理器 pinia 用法笔记1
前端·javascript·vue.js
How_doyou_do3 小时前
工程级前端智能体FrontAgent
前端
2501_944446003 小时前
Flutter&OpenHarmony日期时间选择器实现
前端·javascript·flutter