用Node.js脚本自动化版本发布:Git Tag与Semver实践 Git Tag、Semver、CI/CD这三个词单独拎出来任何一个都有一堆文章在讲原理、讲最佳实践。但等你真正站在“要发一个新版本”这个节骨眼上还是会发现得从收藏夹里翻出三五篇文档对着一步步手动操作。这个项目就是干这个的把散落在文章里的发布规则收敛成一个release.mjs一条命令完成版本号计算、代码提交、标签推送剩下的交给 CI/CD 自动跑完。不需要再记“先改 package.json再 commit再打 tag再 push”这个顺序脚本替你把这些全包了。release.mjs本质上是一个用 Node.js 写的命令行脚本核心逻辑可以概括成四步读取当前最新的 Git Tag 得到当前版本号按 Semver 规则算出下一个版本号更新 package.json 里的 version 字段并提交代码最后打一个带版本号的附注标签推送到远程。如果项目已经接入了 CI/CD那么推送 Tag 这个动作会直接触发流水线里的构建、测试和部署整个发布流程就从“人肉操作”变成了“一条命令 自动跑完”。这篇文章适合三类人一是还在手动改版本号、手动打 Tag想把手动流程收敛成脚本的前端和 Node.js 开发者二是想把发布从本地搬到 CI/CD 上但不太清楚脚本和流水线怎么分工的团队三是想看看别人怎么把流程工具化的朋友。不需要你有多深的前端功底写过 npm 脚本、用过 Git 基本就能跟着走下来。我会把 Semver 的边界情况、Git Tag 的操作细节、CI/CD 的触发机制全部拆开讲最后附上完整可用的脚本。1. 为什么要把发布流程收敛成一个脚本1.1 文章里的规则和脚本里的规则差的是执行力网上讲 Semver、Git Tag 的文章很多规则本身也不复杂主版本号不兼容就加 1加了新功能但兼容就加次版本号只修 bug 就加修订号发布前打 TagTag 和版本号一一对应。这些你读的时候都觉得懂了但真操作起来问题就来了。比如当前版本是1.2.3你修完一个 bug 准备发1.2.4然后你改了 package.json、提交了代码、打了v1.2.4的 Tag、推了代码、又推了 Tag这一套流程看着简单但中间每一步都可能出错。最容易出的问题是“流程顺序被记混”。我有段时间就经常在发布时忘记推 Tag代码推上去了CI 跑的是主分支的流水线结果构建产物是没带版本号的“裸构建”。等部署的时候才发现版本对不上又要回头补一个 Tag 触发流水线。这种事情发生过两三次之后你就会意识到规则写在文章里是没有约束力的只有把规则写进脚本里才能保证每次执行都是同一个顺序、同一个标准。把发布流程收敛成脚本本质上是把“我知道该怎么发版”变成“脚本知道该怎么发版”。人的记忆是会出错的脚本不会。而且脚本可以被 CI/CD 调用形成自动化闭环这是手动流程完全做不到的。1.2 release.mjs 解决的是哪三个具体痛点第一个痛点是版本号不一致。手动发布的时候很容易出现 package.json 里的版本号和 Git Tag 对不上的情况。你说我改完 package.json 打成v1.2.4结果提交的时候手抖改成了v1.2.3这种事看着低级但人就是会在重复劳动中失误。脚本从 Git Tag 读当前版本再统一计算出新版本号写入 package.json 和 Git Tag 用的是同一个变量从根源上杜绝了这个问题。第二个痛点是发布动作分散在多个工具里。你要打开终端敲git add、git commit、git tag、git push还要用编辑器改 package.json中间可能还要跑测试。这些动作分散在不同地方任何一步中断下次就得从头来。脚本把这些动作串成一条链路任何一步失败就停住不会出现“代码提交了但 Tag 没打”这种半截状态。第三个痛点是团队协作时没有统一标准。每个人发布习惯不一样有人打附注标签有人打轻量标签有人版本号前加v有人不加。这些差异在单人项目里无所谓但在团队项目里会造成很大的混乱。把发布逻辑收敛成脚本所有人都是同一个发布入口Tag 格式、版本号规则、提交信息风格全部统一新成员不需要问“我们发布是怎么发的”直接跑脚本就行。1.3 为什么选 Node.js而不是 Shell 或 Makefile做版本管理和发布脚本未必一定要用 Node.jsShell 脚本也能做Makefile 也行。但我在实际项目中综合比较下来Node.js 有几个不可替代的优势。第一个优势是跨平台。Shell 脚本在 macOS 和 Linux 上没问题但项目里只要有一个人用 Windowssed、grep这些命令的行为就可能不一样。Node.js 脚本只要装了 Node 就能跑文件读写、命令执行都有统一的 API避免了平台差异带来的坑。第二个优势是 Node.js 的生态。如果一个项目本身是前端或 Node.js 后端项目里一定已经有package.json了脚本可以直接用npm的依赖来处理版本比较、命令行交互这些事不需要额外引入工具链。第三个优势是解析能力。版本号这种东西要解析、比较、递增用纯 Shell 处理字符串会非常痛苦。Node.js 里一个正则就能搞定而且可以用node:child_process里的execSync直接调用 Git 命令写起来思路非常顺。当然Shell 和 Makefile 也有它们的场景。如果你在一个纯 Python 项目里那写个.sh脚本可能更符合项目习惯如果只是想在npm run里串几个命令Makefile 也够用。但要做成“一个带参数、带逻辑、能处理边界情况的发布工具”Node.js 是性价比最高的选择。这也是我把release.mjs写成 Node.js 脚本的原因后面所有代码都基于 Node 18 的 ESM 语法。2. Semver 版本号规则先把“三位数”背后的逻辑吃透2.1 主版本号、次版本号、修订号分别代表什么Semver语义化版本的规则看着就三组数字但很多人对“什么时候该加哪一位”理解得不够清晰。先说简单的主版本号.次版本号.修订号对应英文是major.minor.patch。主版本号major做了不兼容的 API 修改。用户升级到新版本后原有代码可能会跑不起来这种变化必须升主版本号。次版本号minor加了新功能但保持向后兼容。原有功能不破坏只是多了新能力。修订号patch修复 bug不新增功能也不改变现有 API 的行为。举个例子你的项目当前版本是2.3.1如果你修了一个让页面崩溃的 bug发2.3.2如果你给系统加了一个导出报表的新功能发2.4.0如果你把接口的返回结构改了让依赖这个接口的程序没法用了那就得发3.0.0。2.2 预发布版本和构建元数据怎么处理1.2.3-beta.1这种带后缀的版本号是很多人在 Semver 上最容易忽略的部分。正式版本号之外Semver 规范允许加两类后缀预发布版本pre-release和构建元数据build metadata。预发布版本用连字符-连接比如1.2.3-alpha.1、1.2.3-beta.2、1.2.3-rc.1。它表示的是一段“快到了但还没正式发”的版本适合在测试环境、灰度环境里验证。预发布版本号的排序规则是同一组数字下1.2.3-alpha 1.2.3-beta 1.2.3-rc 1.2.3也就是说正式版永远排在预发布版后面。构建元数据用加号连接比如1.2.3build.20240115它主要用来记录一些构建信息不参与版本号的优先级比较。在release.mjs里我用一个正则就能把这三段拆开const SEMVER_RE /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-](?:\.[0-9A-Za-z-])*))?(?:\([0-9A-Za-z-](?:\.[0-9A-Za-z-])*))?$/这个正则看起来复杂用起来很稳。它能把1.2.3-beta.1build.5拆成四组主版本号、次版本号、修订号、预发布标识构建元数据单独一组。我不会让构建元数据参与版本号比较因为它只是附加信息没有优先级的意义。2.3 手动算版本号 vs 脚本算版本号差距在哪里手动算版本号的时候你面对的是“当前是1.2.3我要发 minor新版本应该是多少”这种计算题。数字小的时候还好一旦到了2.14.0这种两位数的次版本号或者项目存在多个维护分支、每个分支版本号不一致的情况手动算就容易出错甚至会出现“当前版本读到的是缓存的旧值”这种事。脚本算版本号的核心逻辑是对“当前版本号”做一次确定性转换输入是当前版本和 bump 类型输出是目标版本。我用一个bumpVersion函数来管这件事function bumpVersion(current, bump, preId beta) { const { major, minor, patch, prerelease } parseSemver(current) if (bump major) return ${major 1}.0.0 if (bump minor) return ${major}.${minor 1}.0 if (bump patch) { // 当前是预发布版本时patch 应该先转正式版而不是继续加数字 if (prerelease) return ${major}.${minor}.${patch} return ${major}.${minor}.${patch 1} } if (bump pre) { // 继续当前预发布序列比如 1.2.3-beta.1 - 1.2.3-beta.2 if (prerelease) { const lastDash prerelease.lastIndexOf(-) const base lastDash 0 ? prerelease.slice(0, lastDash) : prerelease const parts base.split(.) const id parts[0] const num Number(parts[1] ?? 0) 1 return ${major}.${minor}.${patch}-${id}.${num} } return ${major}.${minor}.${patch 1}-${preId}.1 } if (bump promote) return ${major}.${minor}.${patch} throw new Error(Unknown bump type: ${bump}) }这里有几个细节要说明一下。第一bump: patch且当前版本是预发布版本的时候我选择直接返回主.次.修订而不是修订 1。原因是当你在1.2.3-beta.1上修了几个 bug 要发正式版版本号应该是1.2.3还是1.2.4按 Semver 的语义1.2.3-beta.1本身还在1.2.3的范围内所以从预发布转正式应该是1.2.3不需要再加 1。这是很多实现容易搞错的地方。第二bump: pre的逻辑里如果当前已经是预发布版本比如1.2.3-beta.1再执行 pre 就会变成1.2.3-beta.2而不是重新从1开始。只有在当前是正式版本号时才会生成${当前修订号 1}-${preId}.1。这样你想连续发多个测试版本的时候版本号是连续的不会乱。第三bump: promote表示把预发布版本提为正式版直接丢弃预发布后缀。比如1.2.3-rc.2经过 promote 变成1.2.3。可能有人会问为什么不直接用semver这个 npm 包来算版本号其实完全可以semver包提供了inc()方法而且对边界情况的处理比我这里的简化实现更完善。我选择手写这套逻辑是为了让脚本不依赖外部包复制到任何项目里都能直接跑同时借这篇文章把 Semver 的细节讲清楚。如果你的项目已经装了semver那在bumpVersion里改成semver.inc(current, bump, preId)也是一行的事。2.4 边界情况0.x 版本和 pre-release 排序Semver 里有几个容易被忽略的边界情况在设计脚本时必须考虑。第一个是0.x版本。按照 Semver 规范0.1.0和0.2.0之间并不保证兼容性所以在0.x阶段加不兼容的功能不需要升到1.0.0。我见过很多团队从0.9.0直接跳到1.0.0以为这是规定必须的其实语义上1.0.0更多是一个“对外承诺 API 稳定”的信号。脚本不需要特别处理这个但你心里得有数。第二个是 pre-release 的排序规则。1.0.0-alpha 1.0.0-alpha.1 1.0.0-beta.1 1.0.0这个顺序在手动操作时经常被搞混尤其是alpha和alpha.1谁大谁小。alpha这种没有数字后缀的标识符按规范它排在alpha.1前面还是后面答案是alpha等价于alpha.0所以alpha alpha.1。脚本里如果没有处理这种没有数字后缀的情况比较结果就可能出错。如果你的发布流程会用alpha不带数字的版本号建议在脚本里做一次规范化给缺少数字后缀的标识符补上.0。第三个是版本号递增的“单调性”。理论上每次发布的新版本号都必须大于当前版本号。但因为 Semver 允许1.0.0-alpha.1和1.0.0这样的版本存在单纯比较字符串是不够的要做分段比较。脚本里如果只是用String.prototype.localeCompare来比较版本号会得到完全错误的结果。我在写脚本时特意写了computeNextVersion这个函数就是为了保证版本号在语义上单调递增而不是字符串上递增。3. Git Tag 才是发布流程里真正的主角3.1 Tag 是发布锚点不用分支跑偏很多人做发布时习惯用分支来管理比如release/1.2.0分支合完再打 Tag。这种做法本身没错但有一个问题分支是经常变动的你可以在release/1.2.0上继续加提交它的位置会不断前移。Tag 则不一样它是指向某个具体提交的“固定锚点”一旦打上位置就定死了。在release.mjs里我把 Git Tag 作为当前版本号的唯一来源脚本会先跑git describe --tags --abbrev0git describe --tags --abbrev0这条命令的语义是“从当前 HEAD 往前找离得最近的、带注释的 Tag 的完整名称”。也就是说不管你的项目有多少个 Tag、多少个分支脚本只会认当前提交能回溯到的那个 Tag。这样设计有一个好处如果你在特性分支上跑了发布脚本它拿到的当前版本号是“从当前代码往上最近的一个版本”而不是“仓库里最新打的 Tag”。这两者在多分支并行开发时差异很大用git describe可以保证脚本在任意分支上拿到的是和当前代码相关的版本信息。3.2 附注标签和轻量标签选哪个Git 有两类标签轻量标签lightweight tag和附注标签annotated tag。区别也很简单附注标签会额外存一条完整的 tag 对象信息包括打标签的人、邮箱、时间、一条 tag message可以签名验证轻量标签就只是一个指向某个提交的引用没有这些元信息。在发布场景里我强烈建议用附注标签。原因有三点。第一附注标签携带了“谁在什么时候发布了这个版本”的信息。这个信息在审计、排查问题时非常有用你能清楚地看到这次发布是哪一个同事在哪个时间点打的。第二git describe的行为默认偏向附注标签。如果你用轻量标签某些 Git 操作比如git describe不带--tags参数时可能不会识别到它。为了避免这些坑脚本里统一使用-a参数打附注标签。第三附注标签可以支持后续的签名和验证流程轻量标签做不到。虽然大多数小团队用不上签名但既然这一步没有额外成本没理由不用更规范的方案。在脚本里对应的是这一行run(git tag -a ${nextTag} -m Release ${nextTag})-a就是 annotated 的意思-m指定 tag message。执行完之后git tag -n就能看到这个 Tag 的说明信息。3.3 Tag 命名规范和推送策略Tag 的命名规范看起来是个小事但会影响后续一切解析逻辑。我推荐的格式是v加上 Semver 版本号比如v1.2.3、v2.0.0-rc.1理由是大多数工具包括 Go modules、很多 CI 系统默认这个格式而且v前缀能避免 Tag 和分支或 commit hash 混淆。在release.mjs里我统一用一个versionToTag函数来转换function versionToTag(version) { return v${version} }这个函数看着多余但它把“版本号”和“Git Tag 的字符串表示”这两个概念干净地分开了。后面如果团队决定换 tag 格式比如不加v只需要改这一个地方不需要在脚本里到处找字符串拼接。Tag 的推送策略同样值得注意。很多人在发布时图省事直接跑git push --tags把所有本地 Tag 全推上去。这个做法风险很大因为你本地可能有一些分支上打的临时 Tag、旧 Tag全推上去会导致远程 Tag 列表混乱。更稳妥的做法是只推送当前要发布的这一个 Taggit push origin tag_name在脚本里对应的是run(git push origin HEAD) run(git push origin ${nextTag})第一条推的是主分支的提交第二条推的是新打的 Tag。这样的好处是精准、可控、不会误把其他 Tag 带上远程。你可能觉得两条命令有点冗余但发布时“先推代码再推 Tag”这个顺序是很多 CI/CD 系统的约定如果先推 Tag 后推代码有可能出现 CI 拉取 Tag 时对应提交还没被推送的情况在远程仓库上留下一个“悬空”的 Tag。这不是绝对的错误但会让人觉得流程不干净。3.4 从 CI/CD 的角度看 TagCI/CD 里Tag 通常有两种用途。第一种是作为版本号来源流水线在构建时读取当前 Tag 名把版本号注入到构建产物中。第二种是作为触发信号很多 CI 平台允许配置“当某条分支上有新的 Tag 推送时执行流水线”。前一种用途要求 Tag 名必须是规范且可解析的否则 CI 里写解析逻辑会非常痛苦。后一种用途要求你理解 Tag 和分支的触发关系。比如 GitHub Actions 里on: push: tags: - v*这样的配置表示只要推送一个符合v*模式的 Tag就会触发流水线。这个触发机制和分支推送是独立的所以当你执行git push origin tag时CI 会触发跑流水线。我见过一种很典型的情况代码已经推到远程但 Tag 没推结果 CI 一直不跑发布任务排查半天才发现是 Tag 没推上去。这就是为什么我在脚本里把git push origin ${nextTag}放进同一段流程的原因避免“代码提交了但 Tag 没推”的半截状态。4. CI/CD 里怎么编排 release.mjs4.1 触发时机Tag 推送触发还是手动触发release.mjs在本地和 CI 两个场景里都可以跑。本地场景下开发者直接执行node release.mjs patch脚本会完成 commit、tag、push 三个动作。CI/CD 场景下常见的做法是让脚本只负责“生成版本号、更新文件、打标签”而 push 动作由 CI 来完成或者反过来——开发者在本地执行脚本完成 pushCI 监听 Tag 推送后再做构建和部署。我建议的方案是“本地做版本生成CI 做构建部署”。也就是说开发者在本地跑node release.mjs patch --no-push脚本生成版本号、更新 package.json、提交代码并打好 Tag但不推送。CI 流水线监测到 main 分支有新的提交包含 Tag时先跑测试、构建确认没问题后再由 CI 执行 push tag。这样可以保证“本地能跑通的内容”和“CI 真正发布的内容”是同一份而且如果测试失败Tag 还没推上去不会产生“发布到了但测试挂了”的事故。不过在多数小团队里这个流程还是偏重直接本地一条命令推上去也很常见。两种方式我都用过各有优点具体看团队的发布纪律。关键是脚本要支持--no-push这个参数让同一个脚本同时适配本地和 CI 两种执行环境。4.2 CI 环境里的 Git 是“浅克隆”处理不好会翻车CI 环境里跑 Git 相关操作第一个坑就是浅克隆。很多 CI 系统默认只 clone 最近一次提交--depth1这个模式下git describe --tags --abbrev0几乎一定会失败因为它需要历史提交和 Tag 信息才能找到最近的 Tag。解决方法是两步。第一在 CI 里把浅克隆关掉或者至少加深一点。GitHub Actions 里可以这样配置- uses: actions/checkoutv4 with: fetch-depth: 0 fetch-tags: truefetch-depth: 0表示拉取全部历史fetch-tags: true表示把 Tag 也拉下来。这两项缺一不可。只配了fetch-depth: 0不配fetch-tagsTag 可能还是为空。第二个坑是 CI 的 Git 用户身份。GitHub Actions 的 checkout action 默认会配置一个用于提交的身份很多人在 CI 里跑release.mjs时发现 commit 没问题但 push 报错就是因为身份不对或者权限不够。我在 CI 里一般会加一段git config user.name release-bot git config user.email release-botexample.com这个“release-bot”可以是任何一个有权限推送代码的机器人账号。用真实开发者的账号也行但会留下“XXX 在 CI 里发布了版本”这种记录时间长了看起来比较奇怪。4.3 权限令牌是 CI 里最容易出事的环节release.mjs在 CI 环境里执行 push 动作时需要有一个有推送权限的令牌。在 GitHub Actions 里最方便的是内置的GITHUB_TOKEN但如果仓库的分支保护规则比较严格GITHUB_TOKEN默认可能没有推 Tag 的权限。这种情况下我会改用 Personal Access TokenPAT然后在流水线里通过环境变量注入env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}脚本里读取process.env.GH_TOKEN如果存在就用它来推送否则走本地默认的 SSH 或 HTTPS 认证。这样脚本在本地和 CI 里都能工作且不会把令牌写死到代码里。关于权限令牌有一个很常见的坑用GITHUB_TOKEN推送 Tag 时如果 token 的权限范围没有勾选Contents: writepush 会直接失败。检查 token 权限时不要只看能不能 clone要看能不能 write。另外如果 CI 流水线里有多个 job记得在需要 release 的 job 里显式把 token 作为环境变量传递不要在 workflow 顶层定义之后就以为所有 job 都能拿到。4.4 发布失败的回滚策略自动化发布流程一定要考虑失败回滚。release.mjs的执行过程是计算版本号 - 更新文件 - 提交代码 - 打标签 - 推送。前几步失败直接把本地状态 reset 掉就行影响不大。麻烦的是 Tag 已经推到远程之后构建或部署阶段发现代码有问题需要回滚。Git Tag 在回滚时比分支灵活因为 Tag 一旦删掉远程仓库不会留下合并历史。回滚动作一般分两步删除远程 Tag再打一个新的“修复 Tag”。git push origin :refs/tags/v1.2.3 git tag -d v1.2.3 git push origin v1.2.4删除远程 Tag 的命令是git push origin :refs/tags/tag_name注意不是git push origin --delete v1.2.3这个命令虽然也能用但实际效果一样只是写法不同。更推荐的做法其实是“不删除旧 Tag直接发一个 patch 版本”因为 Tag 是审计记录的一部分删掉会丢失历史。除非发布错误非常严重否则我都建议用新版本号来修复问题而不是抹掉旧版本。这个原则也体现在脚本设计里只要 Tag 已经推送成功脚本就不再支持“重打”同一个 Tag而是强制递增新版本号。5. release.mjs 的完整实现和逐段拆解5.1 参数解析怎么定义 bump 类型和辅助参数脚本的第一步是解析命令行参数。我想实现的效果是node release.mjs patch表示发修订版node release.mjs minor表示发次版本node release.mjs major表示发主版本node release.mjs pre --preid beta表示发预发布版本。辅助参数包括--dry-run只打印要做什么不实际执行、--no-push本地提交和打标签但不推送。这一段没有用第三方库Node 18 自带process.argv就能满足需求。function parseArgs() { const args process.argv.slice(2) const bump args.find((arg) [major, minor, patch, pre, promote].includes(arg)) ?? patch const preId args[args.indexOf(--preid) 1] || beta const dryRun args.includes(--dry-run) const noPush args.includes(--no-push) return { bump, preId, dryRun, noPush } }这个函数不难理解bump的优先级是从major、minor、patch、pre、promote里选一个。如果不传默认是patch。这里故意把patch设为默认值因为日常发布里修 bug 发 patch 的频率最高少敲一个参数能省点事。5.2 版本号计算正则解析 bump 逻辑版本号计算是脚本的核心部分在你读到这里之前我已经研究了一个相当完整的bumpVersion实现。这里做一个总结和补充parseSemver负责把版本号解析成结构化对象bumpVersion负责基于 bump 类型计算新版本号。function parseSemver(version) { const match version.match(SEMVER_RE) if (!match) throw new Error(Invalid semver: ${version}) const [, major, minor, patch, prerelease] match return { major: Number(major), minor: Number(minor), patch: Number(patch), prerelease } }这里有一个小设计决策prerelease字段直接保留原始字符串比如beta.2没有进一步拆分。因为在bumpVersion里我只需要判断“当前有没有预发布标识”以及“继续递增哪个预发布标识”。保留原始字符串更灵活以后如果想支持多个预发布段虽然 Semver 允许但说实话很少见不用改解析层。5.3 文件更新和 Git 操作更新 package.json、提交、打标签计算完版本号之后脚本要做的事情是更新 package.json、提交代码、打附注标签、推送。import { existsSync } from node:fs function updatePackageJson(nextVersion) { const pkgPath resolve(process.cwd(), package.json) if (!existsSync(pkgPath)) { console.warn(No package.json found, skipping version update.) return } const pkg JSON.parse(readFileSync(pkgPath, utf8)) pkg.version nextVersion writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) \n) } function run(cmd, options {}) { return execSync(cmd, { encoding: utf8, stdio: options.silent ? pipe : inherit, ...options }) } function getCurrentVersion() { try { const tag run(git describe --tags --abbrev0, { silent: true }).trim() return tag.replace(/^v/, ) } catch { return 0.0.0 } }getCurrentVersion里有一个关键细节用try...catch兜底。如果仓库里一个 Tag 都没有git describe会直接报错脚本应该把这种情况当作“当前版本是 0.0.0”来处理而不是中断。绝大多数项目第一次用这个脚本时都没有 Tag这个兜底逻辑能让你在全新项目里直接跑。updatePackageJson里也有一个细节写入 package.json 时用JSON.stringify(pkg, null, 2) \n带上额外的换行符。很多项目的 package.json 末尾是有换行的JSON.stringify默认不带如果直接用会导致每次跑脚本都产生一个无意义的 diff。加\n是很多工具踩过坑之后的共识写法。提交代码和打标签的逻辑function commitAndTag({ nextVersion, nextTag, dryRun }) { if (dryRun) { console.log([dry-run] git add package.json) console.log([dry-run] git commit -m chore(release): ${nextTag}) console.log([dry-run] git tag -a ${nextTag} -m Release ${nextTag}) return } run(git add package.json) run(git commit -m chore(release): ${nextTag}) run(git tag -a ${nextTag} -m Release ${nextTag}) }这里特意没有把git add -A作为默认行为。理由是发布脚本应该只提交“和版本发布相关的文件”也就是 package.json而不是把你工作区里其他改动的文件一起提交进去。如果你还有 CHANGELOG.md 之类的文件要一起发布可以自己往git add后面追加但默认行为保持最小集这样可以避免把无关改动混进发布提交。5.4 CI 检测和环境变量处理我加上了一个小的 CI 检测函数function isCI() { return Boolean(process.env.CI) }在 CI 和本地环境里脚本行为会有一点差异。比如在本地你可以直接用git push origin HEAD git push origin tag在 CI 环境里你可能不希望脚本自己执行 push因为 CI 有自己的一套推送机制而是只把 package.json 和 commit/tag 准备好让后续的 CI step 去处理。我把这个差异收敛成一个--no-push参数但要判断“默认是否自动 push”可以看这个环境变量。CI 为 true 时没有显式传--push就默认不推本地则默认推。不过为了简单起见我在脚本里采用显式传参的方式不依赖 CI 变量做魔法行为。这样做的坏处是使用者在 CI 里要多带一个参数好处是脚本行为可预测不会因为不同 CI 平台的变量差异产生意外。5.5 完整脚本汇总把上面所有片段拼起来就是一个可以直接复制到项目里用的release.mjs#!/usr/bin/env node import { execSync } from node:child_process import { existsSync, readFileSync, writeFileSync } from node:fs import { resolve } from node:path const SEMVER_RE /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-](?:\.[0-9A-Za-z-])*))?(?:\([0-9A-Za-z-](?:\.[0-9A-Za-z-])*))?$/ function run(cmd, options {}) { return execSync(cmd, { encoding: utf8, stdio: options.silent ? pipe : inherit, ...options }) } function parseSemver(version) { const match version.match(SEMVER_RE) if (!match) throw new Error(Invalid semver: ${version}) const [, major, minor, patch, prerelease] match return { major: Number(major), minor: Number(minor), patch: Number(patch), prerelease } } function bumpVersion(current, bump, preId beta) { const { major, minor, patch, prerelease } parseSemver(current) if (bump major) return ${major 1}.0.0 if (bump minor) return ${major}.${minor 1}.0 if (bump patch) { if (prerelease) return ${major}.${minor}.${patch} return ${major}.${minor}.${patch 1} } if (bump pre) { if (prerelease) { const lastDash prerelease.lastIndexOf(-) const base lastDash 0 ? prerelease.slice(0, lastDash) : prerelease const parts base.split(.) const id parts[0] const num Number(parts[1] ?? 0) 1 return ${major}.${minor}.${patch}-${id}.${num} } return ${major}.${minor}.${patch 1}-${preId}.1 } if (bump promote) return ${major}.${minor}.${patch} throw new Error(Unknown bump type: ${bump}) } function getCurrentVersion() { try { const tag run(git describe --tags --abbrev0, { silent: true }).trim() return tag.replace(/^v/, ) } catch { return 0.0.0 } } function updatePackageJson(nextVersion) { const pkgPath resolve(process.cwd(), package.json) if (!existsSync(pkgPath)) { console.warn(No package.json found, skipping version update.) return } const pkg JSON.parse(readFileSync(pkgPath, utf8)) pkg.version nextVersion writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) \n) } function parseArgs() { const args process.argv.slice(2) const bump args.find((arg) [major, minor, patch, pre, promote].includes(arg)) ?? patch const preIdx args.indexOf(--preid) const preId preIdx 0 ? args[preIdx 1] : beta const dryRun args.includes(--dry-run) const noPush args.includes(--no-push) return { bump, preId, dryRun, noPush } } function main() { const { bump, preId, dryRun, noPush } parseArgs() const current getCurrentVersion() const nextVersion bumpVersion(current, bump, preId) const nextTag v${nextVersion} console.log(Current version: ${current}) console.log(Next version: ${nextVersion}) console.log(Next tag: ${nextTag}) if (dryRun) { console.log([dry-run] Skipping actual changes.) return } updatePackageJson(nextVersion) run(git add package.json) run(git commit -m chore(release): ${nextTag}) run(git tag -a ${nextTag} -m Release ${nextTag}) if (!noPush) { run(git push origin HEAD) run(git push origin ${nextTag}) } console.log(Release ${nextTag} ready.) } main()这个脚本总共不算多核心逻辑没有超过 150 行。你复制到项目里先加执行权限chmod x release.mjs然后跑一下node release.mjs --dry-run就能看到脚本会做什么。确认没问题后再加参数实际发布。注意脚本默认会把git push origin HEAD和git push origin tag都执行。如果你在本地已经把改动提交了但不想推送记得加--no-push。6. 实际操作中的常见问题与排查技巧6.1 Tag 推上去了CI 却不触发这是我在 GitHub Actions 上遇到最多的问题Tag 已经推送成功了远程仓库里也能看到v1.2.3但流水线就是没跑。排查的时候先看 workflow 文件的触发条件on: push: tags: - v*首先确认 Tag 名是否符合v*这个模式。如果你的 Tag 是release-1.2.3或者1.2.3那确实不会触发。GitHub Actions 的 tag 触发模式是 glob 匹配不是正则v*表示以v开头后面的部分不限。如果你的版本号是1.2.3不带 v需要把模式改成*或者[0-9]*这种更精确的写法。第二个容易忽略的点是GitHub Actions 在新建仓库的首次 push 时默认不触发 workflow需要在仓库的 Settings Actions 里把 “Allow all actions and reusable workflows” 打开。这个问题很隐蔽因为它只影响仓库刚创建时的第一次 push。第三个点如果你的 workflow 同时监听 main 分支和 tags而推 Tag 时用的不是同一个 commit那触发时间可能会错开。发布脚本里可以先推代码再推 Tag确保 CI 在拿到 Tag 时对应的 commit 已经在远程了。6.2 版本号冲突远程已经有同名 Tagpush 被拒发布脚本跑得很顺代码提交成功、本地 Tag 也打好了结果git push origin v1.2.3的时候报错error: failed to push some refs to。这是远程已经存在同名 Tag 了。常见原因是上一次发布没有成功但 Tag 已经推上去了这次重新跑了同样的版本号。排查时先跑git ls-remote --tags origin看远程有哪些 Tag如果确实有v1.2.3你就要决定是删除远程 Tag 重打还是换一个新版本号。我的建议是不删除远程 Tag改用新版本号。因为远程 Tag 一旦被其他人拉取过删除会导致他们本地出现“悬空引用”而且删除 Tag 再重打同一个版本号等于告诉别人“这个版本不存在了”比较危险。正确做法是把本地 Tag 删掉重新跑node release.mjs patch来生成一个新版本号。6.3 CI 环境里的 Git 用户身份不对导致推送失败在 CI 里跑release.mjs时如果git commit报错说Please tell me who you are说明 Git 不知道提交者身份。本地环境会自动用你电脑上 Git 全局配置的 user.name 和 user.email但 CI 环境是全新的没有这些配置。处理方法我已经在 4.2 节提到这里再强调一次在跑脚本前先执行git config user.name release-bot git config user.email release-botexample.com配置完成后再跑脚本。注意git config和git commit必须在同一个 job 的同一次执行里因为 CI 每次 job 都是全新的工作目录上一步配置不代表下一步还在。6.4 浅克隆导致 git describe 失灵今天最容易被忽视的问题就是 CI 环境里 checkout 用的是浅克隆。git describe --tags --abbrev0需要从当前 HEAD 往回遍历所有提交找到距离最近的带注释 Tag。如果只 clone 了最近一次提交它连当前提交的祖先都看不到自然找不到 Tag。处理方法我已经在 4.2 节写了用fetch-depth: 0拉取全部历史。这一步很关键不要以为脚本在本地跑通了 CI 就一定没问题CI 的 Git 上下文是“不完整”的。6.5 发布脚本的幂等性为什么第二次跑会报错最后要说一个容易被忽略的细节release.mjs不是幂等的。第一次跑成功之后再跑一次同样的命令脚本会拿着上一次生成的版本号再算出一个新版本号而不是原地重来。这个设计是有意为之的如果你发布了一个v1.2.3第二次跑node release.mjs patch应该生成v1.2.4而不是尝试重打v1.2.3。但这也意味着如果你的发布流程中间出错了比如 Tag 推上去了但 CR 不过你要小心“脚本已经做了的事”和“脚本还没做的事”之间的边界。我在实际使用中养成的习惯是先用--dry-run跑一遍确认要生成的版本号再真正执行。反正多跑一次也没成本但能避免在出错时还需要手动清理状态。另外提一个建议在 CI 流水线里发布这一步最好单独作为一个 job并且设置concurrency来防止多个发布同时触发。这个在脚本层面做不了需要在流水线配置里控制。7. 一点额外的实践经验写这个脚本的过程中我实际踩过不少坑最后分享几条经验。第一个经验是“发布脚本不要试图做所有事”。最开始我往脚本里塞过 CHANGELOG 生成、依赖更新检查、甚至打包流程结果脚本越来越长越来越难维护每次发布都提心吊胆。后来把它砍到只做“算版本号、改文件、打标签、推送”这四件事稳定了很多。其他事情交给各自的工具和流水线去管脚本只负责发布链条里最关键的一段。第二个经验是“commit message 要统一规范”。发布脚本里默认的 commit message 是chore(release): v1.2.3这个格式符合 conventional commits 规范能被很多 CHANGELOG 工具识别。如果你用的是其他规范比如release: v1.2.3记得在脚本里统一改掉避免一个仓库里出现多种 commit message 风格。第三个经验是“在 CI 里要有日志”。run函数默认会把命令输出直接打到 stdout这在 CI 里非常有用因为定位问题时能清楚看到每一步执行了什么、输出了什么。如果你的命令特别沉默建议在关键步骤前面加一段console.log说明正在做什么一行日志能省去不少排查时间。这个脚本从“埋在文章里的几条规则”变成“一个能直接跑的工具”中间最大的差别不是代码量而是“确定性和一致性”。规则写在文档里每个人理解可能都不太一样规则写在脚本里所有人的执行结果是一样的。如果你也被手动发布折磨过不妨从这篇文章里抄一个release.mjs回去根据自己项目的包管理器、CI 平台和发布节奏调整一下应该很快就能感受到“发版还能这么省事”。