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 就加修订号;发布前打 Tag,Tag 和版本号一一对应。这些你读的时候都觉得懂了,但真操作起来,问题就来了。比如当前版本是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.js,Shell 脚本也能做,Makefile 也行。但我在实际项目中综合比较下来,Node.js 有几个不可替代的优势。
第一个优势是跨平台。Shell 脚本在 macOS 和 Linux 上没问题,但项目里只要有一个人用 Windows,sed、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.3+build.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.1+build.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 --abbrev=0:
git describe --tags --abbrev=0这条命令的语义是“从当前 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 列表混乱。更稳妥的做法是只推送当前要发布的这一个 Tag:
git 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 的角度看 Tag
CI/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.mjs
4.1 触发时机:Tag 推送触发还是手动触发
release.mjs在本地和 CI 两个场景里都可以跑。本地场景下,开发者直接执行node release.mjs patch,脚本会完成 commit、tag、push 三个动作。CI/CD 场景下,常见的做法是让脚本只负责“生成版本号、更新文件、打标签”,而 push 动作由 CI 来完成,或者反过来——开发者在本地执行脚本完成 push,CI 监听 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 最近一次提交(--depth=1),这个模式下git describe --tags --abbrev=0几乎一定会失败,因为它需要历史提交和 Tag 信息才能找到最近的 Tag。
解决方法是两步。第一,在 CI 里把浅克隆关掉,或者至少加深一点。GitHub Actions 里可以这样配置:
- uses: actions/checkout@v4 with: fetch-depth: 0 fetch-tags: truefetch-depth: 0表示拉取全部历史,fetch-tags: true表示把 Tag 也拉下来。这两项缺一不可。只配了fetch-depth: 0不配fetch-tags,Tag 可能还是为空。
第二个坑是 CI 的 Git 用户身份。GitHub Actions 的 checkout action 默认会配置一个用于提交的身份,很多人在 CI 里跑release.mjs时发现 commit 没问题,但 push 报错,就是因为身份不对或者权限不够。我在 CI 里一般会加一段:
git config user.name "release-bot" git config user.email "release-bot@example.com"这个“release-bot”可以是任何一个有权限推送代码的机器人账号。用真实开发者的账号也行,但会留下“XXX 在 CI 里发布了版本”这种记录,时间长了看起来比较奇怪。
4.3 权限令牌是 CI 里最容易出事的环节
release.mjs在 CI 环境里执行 push 动作时,需要有一个有推送权限的令牌。在 GitHub Actions 里,最方便的是内置的GITHUB_TOKEN,但如果仓库的分支保护规则比较严格,GITHUB_TOKEN默认可能没有推 Tag 的权限。这种情况下我会改用 Personal Access Token(PAT),然后在流水线里通过环境变量注入:
env: GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}脚本里读取process.env.GH_TOKEN,如果存在就用它来推送,否则走本地默认的 SSH 或 HTTPS 认证。这样脚本在本地和 CI 里都能工作,且不会把令牌写死到代码里。
关于权限令牌,有一个很常见的坑:用GITHUB_TOKEN推送 Tag 时,如果 token 的权限范围没有勾选Contents: write,push 会直接失败。检查 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 --abbrev=0', { 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 --abbrev=0', { 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 版本号冲突:远程已经有同名 Tag,push 被拒
发布脚本跑得很顺,代码提交成功、本地 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-bot@example.com"配置完成后再跑脚本。注意git config和git commit必须在同一个 job 的同一次执行里,因为 CI 每次 job 都是全新的工作目录,上一步配置不代表下一步还在。
6.4 浅克隆导致 git describe 失灵
今天最容易被忽视的问题,就是 CI 环境里 checkout 用的是浅克隆。git describe --tags --abbrev=0需要从当前 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 平台和发布节奏调整一下,应该很快就能感受到“发版还能这么省事”。