本层判断标准

现在立刻回滚,版本号、镜像、数据库停在哪一步,这三样是不是都写得出来?

答「否」,说明这次发布没有回滚点。缺的是发布说明里的「回滚到」那一行和迁移的反向步骤,补上再发。

版本号由提交类型推导

版本号不靠人拍,它由这一段区间里的提交类型决定——分支与提交那一页已经规定了类型,所以这一步可以算出来。标签用 v 开头,指向 main 上要发布的那一次提交。

这段区间里包含版本号变化例子
有不兼容变更(脚注里有 BREAKING CHANGE:,或类型后带 !)主版本号 +1,其余归零v1.4.2 → v2.0.0
至少一条 feat,且无不兼容变更次版本号 +1,修订号归零v1.4.2 → v1.5.0
只有 fix、perf、refactor、docs、test、chore修订号 +1v1.4.2 → v1.4.3
课程项目处在 0.x 阶段时遇到不兼容变更只把次版本号 +1v0.4.2 → v0.5.0
同一天第二次发布在上一次的基础上继续 +1v1.5.0 → v1.5.1

推导与打标签

第一条命令列出区间内的提交,第二条只挑出决定版本号的那几类。看结果对着上面的表选一个号,再打标签。

git describe --tags --abbrev=0          # 上一版标签,也就是回滚点
git log --oneline --no-merges "$(git describe --tags --abbrev=0)..main"
git log --format=%B "$(git describe --tags --abbrev=0)..main" |
  grep -E '^(feat|fix|perf)(\(|!|:)|BREAKING CHANGE'

git switch main && git pull --ff-only
git tag -a v1.5.0 -m "feat: 按会话里的所属范围读取笔记"
git push origin v1.5.0

0.x 用到什么时候:0.x 只是一个说法,含义是「还没有对外承诺兼容性」。第一次把服务交给这个小组之外的人使用之后,就升到 1.0.0,从那时起不兼容变更必须走主版本号。语义化版本的准确定义与 0.x 的处理以官方规范为准。

发布前与发布后的核对清单

顺序固定为:本地检查通过、测试环境、预发、生产。测试环境用测试数据;预发的配置接近生产,仍不使用生产库的写入口。清单里每一行都要给出命令或数值,不写「确认没问题」。

确认本地检查退 0make check,它已经包含目录白名单、迁移命名、文档同步与静态检查。
确认本次改动涉及的测试全绿go test ./... -race 或 pytest -q,退出码 0。
确认迁移能上能下make migrate-roundtrip,只对测试库跑。
确认回滚点存在且产物还在git describe --tags --abbrev=0 给出上一版标签;那一版要用的镜像或构建产物还能拉到。
把版本号算出来按上一节的表选号,写进发布说明:本次发布 v1.5.0,回滚到 v1.4.2。
确认密钥扫描为 0gitleaks detect --no-banner --redact,命中数 0。
把发布说明写完含三行:本次发布、回滚到、数据库停在哪一步(能反向时写「可反向」)。

发布之后核对的判据同样是数值。没有基线就没法判断「新的错误」,所以发布前先记下当前的错误条数。

逐条核对关键接口状态码:GET /healthz 200、GET /notes 200、跨所属范围 GET /notes/{id} 404。命令:curl -s -o /dev/null -w '%{http_code}\n' http://localhost:${WEB_PORT:-8080}/healthz。
确认错误条数不超过发布前基线docker compose logs --since 10m api | grep -c '"level":"ERROR"',与发布前同一时间窗的数字对照。
确认越权仍记成一行警告docker compose logs --since 10m api | grep -c 'cross-scope access denied' 应大于 0(在做过越权核对之后),字段见日志。
对不上的项写「未查」不写成通过。记录方式与留档放在安全检查的同一份记录里。

没有预发环境时:预发环境在课程项目里常常不存在,此时可以合并为「测试环境 + 生产」两段,但必须在发布说明里写明少了哪一段,不能默认跳过。灰度、蓝绿、金丝雀留到仓库里有对应流水线的那次改动,本次先把顺序、回滚点和核对清单写下来。

回滚步骤

回滚条件要事先定好,否则会变成现场争论。下面三条是阈值:任一条命中就回滚,不回滚也要在发布说明里写明理由。回滚不是失败,回滚时才开始讨论才是。

关键接口连续失败
3 次
连续三次 5xx 或超时,立即回滚。
错误条数相对基线
≥ 2 倍
同一时间窗内 level":"ERROR" 的条数达到发布前的两倍。
越权警告消失
0 条
做过越权核对却查不到 cross-scope access denied,说明记录链路断了。
确认触发条件对照上面三个数值,把命中的那一条与当时的日志时间窗记下来。
切回上一版产物跑 git switch --detach v1.4.2 && docker compose up --build -d,或直接拉上一版的镜像。
处理数据库能反向时跑 migrate -path migrations -database "$DSN" down 1;不能反向时按发布说明写的那一步停下,不要现场决定,更不要手工删列。
确认服务恢复跑 curl -fsS http://localhost:${WEB_PORT:-8080}/healthz,确认返回 200,且错误条数回到基线。
留痕把回滚原因、时间、两个版本号、数据库停在的编号写进本次发布说明,并新增一条 docs/decisions/ 记录(不改代码也要留)。
修掉再重发在 main 上用一次 fix 提交修问题,重发下一个修订号。不靠长期停在旧版本运行。

发布说明模板(放在本次合并请求或 docs/release.md 里)

# 发布 v1.5.0

- 时间:2026-11-12
- 本次发布:v1.5.0
- 回滚到:v1.4.2
- 数据库:迁移 0009 可反向,回滚时执行 down 1
  (若不可反向,写清停在哪一步,例如「停在 0009,不执行 down」)
- 发布前基线:近 10 分钟 ERROR 条数 0

## 发布后核对

- GET /healthz          200  通过
- GET /notes            200  通过
- 跨所属范围 GET /notes 404  通过
- cross-scope access denied 记录   未查

多条迁移怎么退:down 1 只回滚最后一条迁移。这次发布带了三条迁移时,要按 0009 → 0008 → 0007 的顺序逐条反向,顺序写反会撞上外键。迁移命令的参数以官方文档为准。

评审发布改动看什么

版本号打 v1.5.0 还是 1.5.0,这类评审不用讨论。要看的是下面六类——它们都会在真出事的那一天变成代价。

现象说明什么处理
发布说明里没有「回滚到」这一行出事时无路可退挡回,要求补上这一行再发
迁移不能反向,但发布说明没写停在哪一步回滚时只能现场决定改数据库要求补上停在哪个编号,见数据
版本号跳号,例如 v1.2.0 直接到 v1.4.0中间版本没发布却占用了号,或有人在拍脑袋按提交类型重新推导;确实要跳就写明原因
标签打在功能分支的提交上回滚时找不到对应 main 快照在 main 上重打;旧标签未推送时删掉,已推送则另发一个修订号
同一标签被 git tag -f 移动过已发布的产物与代码不再对应不移动已推送的标签,改发下一个修订号
核对清单里写「观察一段时间」「体验正常」没有阈值,观察不出结论,也无法写成「未查」改成数值与时间窗,用本页的三个阈值

标签能不能重打:第四条里标签还没推送时可以删掉重打,这是常见且安全的操作;一旦推送过,其他人可能已经拉到,此时按第五条处理。判断有没有推送,用 git ls-remote --tags origin 确认。

助手交出来的样子

## v0.3.0
- 更新了若干功能
- 修复了一些问题
- 优化性能

认它的办法:现在立刻回滚,版本号、镜像、数据库停在哪一步,这三样是不是都写得出来。上面三条一条都对不上:没有版本号对应的镜像、没有迁移跑到了第几步、也没有回滚点。这类说明多半是把提交信息拼起来的——提交信息是「update」时,它自然就成了「更新了若干功能」。

必须死守的四条

不移动已推送的标签,同一天多次发布逐个 +1判据:git ls-remote --tags origin 里同一版本号只出现一次。违反后果:两次不同的代码对同一个版本号,回滚时不知道该回到哪一份。
保证发布前必须有回滚点判据:发布说明里有「本次发布」与「回滚到」两行,且上一版标签的产物还能拉到。违反后果:出事时只能往前修,修复时间随问题复杂度上升。
迁移不能反向时,写明停在哪一步判据:发布说明里的数据库一行不是空的,也不是「视情况而定」。违反后果:回滚时有人当场删列,数据不可恢复。
核对清单里不许把「未查」写成通过判据:发布说明里每一项要么是数值加结果,要么是「未查」。违反后果:清单失去作用,下一次有人会照着这一份写通过。

不用回补历史版本号:第一条里的「逐个 +1」在仓库还没建立标签习惯时,可以先从当前版本起算,不必回补历史版本号。回补历史版本号本身比不做更容易出错。