这篇记录默认你已经理解 Git Tag、Semver 和 GitHub Actions 的基础概念。
如果你想先看完整背景,可以先读我上一篇文章:
《Git Tag + Semver + CI/CD:从打标签到自动发布的完整实践》
juejin.cn/post/761182...
TL;DR: 上一篇文章解决的是"发布链路怎么设计",这一篇解决的是"怎么把它变成团队每天都能安全执行的一个命令"。我们在自己的实际项目里新增了 scripts/release.mjs,把分支校验、工作区校验、版本号递增、release commit、tag 推送和 GitHub Release 触发全部收敛到 npm run release:patch|minor|major。
结果: 发布从"手动改版本 + 手动 commit + 手动打 tag + 手动 push"变成了一条固定命令。
为什么还要再写一个脚本
上一篇文章里,发布链路已经很完整了:
- 本地完成开发。
- 合并到
main。 - 更新版本号。
- 打语义化 tag。
- 推送 tag。
- 让 CI 根据 tag 自动构建并发布。
问题不在流程本身,而在于这个流程太容易被"部分正确"执行。
典型的失误有这些:
- 忘记切到
main - 工作区还有未提交改动就开始发版
- 本地落后远端还强行打 tag
- 版本号已经改了,但忘了提交
package-lock.json - 本地 build 没跑,tag 先推上去了
- tag 名和
package.json版本不一致
这些问题的共同点是:它们都不复杂,但非常适合由脚本替你卡住。
所以这次在我们自己的实际项目里,我们没有再让发布依赖"记得做对每一步",而是把它做成了一个强约束命令。
这次落地的目标
目标很明确:
- 保留 Semver 和 Git Tag 的发布语义
- 保留 GitHub Actions 的自动构建和 Release 发布
- 发布入口统一成
npm run release:* - 在真正改版本和打 tag 之前,先把最容易踩坑的条件全部校验掉
最终我们在 package.json 里补了几条命令:
json
{
"scripts": {
"release": "node scripts/release.mjs",
"release:check": "npm run tsc && npm run build",
"release:major": "node scripts/release.mjs major",
"release:minor": "node scripts/release.mjs minor",
"release:patch": "node scripts/release.mjs patch"
}
}
这几个命令的设计思路很直接:
release:check只负责"发版前验证"release:patch|minor|major只负责"决定版本增量"release.mjs负责把整个发布动作串起来
也就是说,开发者不需要记住一串 Git 命令,只需要知道自己这次发的是补丁版、小版本还是大版本。
配套的 GitHub Actions 长什么样
这套本地脚本不是单独工作的,它依赖仓库已经有一个稳定的 tag 发布工作流。
当前项目里,GitHub Release 触发器就是 .github/workflows/release.yml:
yaml
name: Release
on:
push:
tags:
- 'v*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Package dist
run: zip -r dist-${{ github.ref_name }}.zip dist/
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create ${{ github.ref_name }} \
--title "${{ github.ref_name }}" \
--generate-notes \
dist-${{ github.ref_name }}.zip
这意味着本地脚本其实不需要关心"怎么发布 release 页面",它只需要把 main 和 tag 安全推上去。
本地负责"保证输入正确",CI 负责"完成远端发布"。
release.mjs 解决了哪些具体问题
脚本入口在 scripts/release.mjs。
它做的事按顺序可以拆成 7 步。
1. 只允许三种发版类型
js
const bumpType = process.argv[2];
const validBumps = new Set(['patch', 'minor', 'major']);
这一步很小,但很必要。
我们不接受模糊输入,也不接受自由发挥。发版只能是:
patchminormajor
这样 CLI 本身就把发版语义锁死了。
2. 发版前必须在 main
js
function ensureMainBranch() {
const branch = capture('git', ['branch', '--show-current']);
if (branch !== 'main') {
throw new Error(
`Release must be executed from main. Current branch: ${branch}`,
);
}
}
这一步是为了避免"在 feature 分支上打了一个看起来像正式版本的 tag"。
如果团队的正式发版分支不是 main,这里当然可以改成 release 或其他命名,但规则必须单一,不要靠口头约定。
3. 工作区必须是干净的
js
function ensureCleanWorkingTree() {
const status = capture('git', ['status', '--porcelain']);
if (status) {
throw new Error(
'Working tree is not clean. Commit or stash changes before releasing.',
);
}
}
这个校验可以直接挡掉大量低级事故。
如果工作区不干净,你根本无法确定这次 release commit 到底包含了什么。 发版脚本最忌讳"顺手把本地临时改动也带上去"。
4. 本地不能落后 upstream
js
function ensureUpToDate(upstream) {
const [behindRaw] = capture('git', [
'rev-list',
'--left-right',
'--count',
`${upstream}...HEAD`,
]).split(/\s+/);
const behind = Number(behindRaw ?? '0');
if (behind > 0) {
throw new Error(
`Local branch is behind ${upstream}. Pull the latest changes first.`,
);
}
}
这里的重点不是"联网 fetch 一下"本身,而是明确拒绝"拿旧代码发新版本"。
发布应该基于你准备发布的最新主干状态,而不是某个本地过期副本。
5. tag 不能重复
js
function ensureTagDoesNotExist(tag) {
const existing = capture('git', ['tag', '-l', tag]);
if (existing === tag) {
throw new Error(`Tag ${tag} already exists.`);
}
}
这一步的意义很直接:vX.Y.Z 只能存在一次。
语义化版本如果允许重复覆盖,后面的 release 页面、二进制产物、回滚记录都会变得很难追踪。
6. 真正改版本前先跑完整检查
js
run('npm', ['run', 'release:check'], 'Running release checks');
run(
'npm',
['version', '--no-git-tag-version', nextVersion],
`Bumping version to ${nextVersion}`,
);
这里我刻意把"校验"和"改版本"拆成了两个步骤:
- 先
npm run release:check - 再
npm version --no-git-tag-version
这样做的原因是:
- 如果类型检查或构建失败,版本文件不会被污染
- 失败时你不用先回滚版本号,再继续修问题
release:check也可以单独复用
在这个项目里,release:check 当前等价于:
bash
npm run tsc && npm run build
这个组合比较朴素,但非常有效。
对于前端管理后台,能过类型检查且能完整打包,已经能挡住一大批发版风险。
7. 自动提交版本文件、打 tag、推送
js
run('git', ['add', ...versionFiles], 'Staging version files');
run(
'git',
['commit', '-m', `chore: release ${releaseTag}`],
`Creating release commit ${releaseTag}`,
);
run(
'git',
['tag', '-a', releaseTag, '-m', releaseTag],
`Creating tag ${releaseTag}`,
);
run('git', ['push', remoteName, 'main'], `Pushing main to ${remoteName}`);
run('git', ['push', remoteName, releaseTag], `Pushing tag ${releaseTag}`);
这部分就是把过去最容易漏掉的人工操作统一收口。
注意这里我只把 package.json 和 package-lock.json 作为版本文件处理:
js
const versionFiles = ['package.json', 'package-lock.json'].filter((file) =>
existsSync(join(rootDir, file)),
);
这个做法有两个好处:
- 不会把无关文件误加进 release commit
- 对 npm 项目足够稳定,也足够清晰
为什么用 npm version --no-git-tag-version
这是这类脚本里一个很实用的细节。
很多人会直接手改 package.json 里的版本号,或者直接用默认的 npm version patch。
但这两种方式都有问题:
- 手改版本号容易漏改
package-lock.json - 默认
npm version会顺带创建 Git commit 和 tag,和我们自己的发布流程冲突
所以这里选的是:
bash
npm version --no-git-tag-version 1.1.1
它只做一件事:更新版本文件。
提交和打 tag 仍然由脚本控制。
这样,版本变更的时机、commit message、tag message 都能保持统一。
失败回滚为什么也要写进脚本
脚本里还有一段很关键但容易被忽略的逻辑:
js
try {
run('git', ['add', ...versionFiles], 'Staging version files');
run(
'git',
['commit', '-m', `chore: release ${releaseTag}`],
`Creating release commit ${releaseTag}`,
);
} catch (error) {
restoreVersion(currentVersion);
throw error;
}
以及:
js
function restoreVersion(version) {
run(
'npm',
['version', '--no-git-tag-version', version],
'Restoring version files',
);
run('git', ['add', ...versionFiles], 'Restaging restored version files');
}
这段逻辑是为了处理一种很现实的情况:
- 类型检查和构建通过了
- 版本号已经更新了
- 但 release commit 失败了
如果没有恢复逻辑,你的工作区会留在一个"版本已经变了,但发布并没有成功"的中间状态。 这种状态非常容易让下一次发版的人误判。
所以只要流程在"改版本之后、正式发布之前"失败,就应该把版本恢复回原值。
这套脚本怎么用
日常发版就三条命令:
bash
npm run release:patch
npm run release:minor
npm run release:major
也支持显式传参:
bash
npm run release -- patch
npm run release -- minor
npm run release -- major
脚本会自动完成下面这些动作:
- 确认当前分支是
main - 确认工作区干净
- 拉取远端 tag
- 检查本地没有落后 upstream
- 检查目标版本 tag 不存在
- 执行
release:check - 更新版本号
- 创建
chore: release vX.Y.Z提交 - 创建
vX.Y.Z注释 tag - 推送
main - 推送 tag
- 触发 GitHub Actions 自动构建并创建 GitHub Release
这次在项目里的实际结果
这套流程已经在我们自己的生产环境里真实跑过一遍。
最近一次发布链路是这样的:
- 功能提交:
feat: complete activity management flow - 发布工具提交:
chore: add release workflow script - 版本发布提交:
chore: release v1.1.1
实际执行命令:
bash
npm run release:patch
最终结果:
package.json版本从1.1.0升到1.1.1- 本地通过
tsc - 本地通过
max build - 推送
main - 推送 tag
v1.1.1 - 由 GitHub Actions 自动构建并发布 release
这说明它不是"看起来正确"的脚本,而是已经跑通过的发布入口。
这套方案的取舍
它不是一个通用到所有项目的"终极发布系统",只是一个非常适合中小型前端仓库的稳定版本。
它的优点
- 认知负担很低,入口就是一条命令
- 约束足够强,能挡住多数手工失误
- 复用 npm 现有能力,不需要引入额外依赖
- 和 GitHub Actions 的 tag release 配合自然
- 可读性强,后续维护成本低
它的限制
- 默认只支持单仓库、单主干发布
- 版本策略还是人工决定,没有接 Conventional Commits 自动算版本
release:check现在只包含tsc + build,不包含自动化测试- 默认分支名被写死为
main
如果以后要继续增强,我会优先考虑两个方向:
- 把
release:check扩展成lint + tsc + test + build - 基于 commit message 自动推导 bump 类型,减少人工选择
patch/minor/major
我最后保留的一条原则
发布流程不是越"高级"越好,而是越"不容易做错"越好。
如果一个团队的发布仍然依赖:
- 记住十几条 Git 命令
- 每次手动检查工作区状态
- 每次手动改版本
- 每次手动确认 tag 没冲突
那它迟早会在某一次忙碌发布里出问题。
把这些步骤收敛成一个明确的 release.mjs,本质上不是在炫技,而是在减少人为波动。
上一篇文章解决了"为什么要这么做"。
这次这个脚本,解决的是"以后每次都能这样做"。
延伸阅读
- 上一篇完整背景文章:
juejin.cn/post/761182...