从文章到脚本:把 Git Tag + Semver + CI/CD 收敛成一个 `release.mjs`

9 阅读7分钟

这篇记录默认你已经理解 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”变成了一条固定命令。

为什么还要再写一个脚本

上一篇文章里,发布链路已经很完整了:

  1. 本地完成开发。
  2. 合并到 main
  3. 更新版本号。
  4. 打语义化 tag。
  5. 推送 tag。
  6. 让 CI 根据 tag 自动构建并发布。

问题不在流程本身,而在于这个流程太容易被“部分正确”执行。

典型的失误有这些:

  • 忘记切到 main
  • 工作区还有未提交改动就开始发版
  • 本地落后远端还强行打 tag
  • 版本号已经改了,但忘了提交 package-lock.json
  • 本地 build 没跑,tag 先推上去了
  • tag 名和 package.json 版本不一致

这些问题的共同点是:它们都不复杂,但非常适合由脚本替你卡住。

所以这次在我们自己的实际项目里,我们没有再让发布依赖“记得做对每一步”,而是把它做成了一个强约束命令。

这次落地的目标

目标很明确:

  • 保留 Semver 和 Git Tag 的发布语义
  • 保留 GitHub Actions 的自动构建和 Release 发布
  • 发布入口统一成 npm run release:*
  • 在真正改版本和打 tag 之前,先把最容易踩坑的条件全部校验掉

最终我们在 package.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

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 页面”,它只需要把 maintag 安全推上去。

本地负责“保证输入正确”,CI 负责“完成远端发布”。

release.mjs 解决了哪些具体问题

脚本入口在 scripts/release.mjs

它做的事按顺序可以拆成 7 步。

1. 只允许三种发版类型

const bumpType = process.argv[2];
const validBumps = new Set(['patch', 'minor', 'major']);

这一步很小,但很必要。
我们不接受模糊输入,也不接受自由发挥。发版只能是:

  • patch
  • minor
  • major

这样 CLI 本身就把发版语义锁死了。

2. 发版前必须在 main

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. 工作区必须是干净的

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

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 不能重复

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. 真正改版本前先跑完整检查

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 当前等价于:

npm run tsc && npm run build

这个组合比较朴素,但非常有效。
对于前端管理后台,能过类型检查且能完整打包,已经能挡住一大批发版风险。

7. 自动提交版本文件、打 tag、推送

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.jsonpackage-lock.json 作为版本文件处理:

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,和我们自己的发布流程冲突

所以这里选的是:

npm version --no-git-tag-version 1.1.1

它只做一件事:更新版本文件。
提交和打 tag 仍然由脚本控制。

这样,版本变更的时机、commit message、tag message 都能保持统一。

失败回滚为什么也要写进脚本

脚本里还有一段很关键但容易被忽略的逻辑:

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;
}

以及:

function restoreVersion(version) {
  run(
    'npm',
    ['version', '--no-git-tag-version', version],
    'Restoring version files',
  );
  run('git', ['add', ...versionFiles], 'Restaging restored version files');
}

这段逻辑是为了处理一种很现实的情况:

  • 类型检查和构建通过了
  • 版本号已经更新了
  • 但 release commit 失败了

如果没有恢复逻辑,你的工作区会留在一个“版本已经变了,但发布并没有成功”的中间状态。 这种状态非常容易让下一次发版的人误判。

所以只要流程在“改版本之后、正式发布之前”失败,就应该把版本恢复回原值。

这套脚本怎么用

日常发版就三条命令:

npm run release:patch
npm run release:minor
npm run release:major

也支持显式传参:

npm run release -- patch
npm run release -- minor
npm run release -- major

脚本会自动完成下面这些动作:

  1. 确认当前分支是 main
  2. 确认工作区干净
  3. 拉取远端 tag
  4. 检查本地没有落后 upstream
  5. 检查目标版本 tag 不存在
  6. 执行 release:check
  7. 更新版本号
  8. 创建 chore: release vX.Y.Z 提交
  9. 创建 vX.Y.Z 注释 tag
  10. 推送 main
  11. 推送 tag
  12. 触发 GitHub Actions 自动构建并创建 GitHub Release

这次在项目里的实际结果

这套流程已经在我们自己的生产环境里真实跑过一遍。

最近一次发布链路是这样的:

  • 功能提交:feat: complete activity management flow
  • 发布工具提交:chore: add release workflow script
  • 版本发布提交:chore: release v1.1.1

实际执行命令:

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

如果以后要继续增强,我会优先考虑两个方向:

  1. release:check 扩展成 lint + tsc + test + build
  2. 基于 commit message 自动推导 bump 类型,减少人工选择 patch/minor/major

我最后保留的一条原则

发布流程不是越“高级”越好,而是越“不容易做错”越好。

如果一个团队的发布仍然依赖:

  • 记住十几条 Git 命令
  • 每次手动检查工作区状态
  • 每次手动改版本
  • 每次手动确认 tag 没冲突

那它迟早会在某一次忙碌发布里出问题。

把这些步骤收敛成一个明确的 release.mjs,本质上不是在炫技,而是在减少人为波动。

上一篇文章解决了“为什么要这么做”。
这次这个脚本,解决的是“以后每次都能这样做”。

延伸阅读