Files
knowledge-governance/docs/RELEASE_RUNBOOK.md
T
t 950c1b429b feat(release): 抬档门禁(破坏性 ⇒ 0.x 抬次 / ≥1.0.0 抬主)+ 1.0.0 前置(稳定契约承诺)
原来预检只"打印破坏性条数",抬哪一档靠人自觉 ⇒ 改成硬门禁:

- **抬档门禁**:破坏性条数取自**目标提交里候选版本那一段**(发版事实看提交,不看工作区)。
  `0.x` 阶段有破坏性 ⇒ 必须抬**次版本**(或主版本),只抬修订 ⇒ 阻断并报出该抬到哪一档;
  `≥1.0.0` 之后有破坏性 ⇒ 必须抬**主版本**,抬次/修订都阻断。
  另加"候选号必须大于上一个正式版本"(版本只增不减)。
- **1.0.0 前置**:上一版本是 `0.x` 时,候选 `1.0.0` = 对外承诺契约稳定 ⇒ 必须先有写实的
  《稳定契约承诺》(覆盖 接口 / 数据模型 / 配置 / CLI 输出与退出码,不留 `待填`/`TODO`/`TBD`),
  **且候选发布记录里链接它**,缺任一条 ⇒ 阻断;`1.x → 2.0.0` 不重复要求。
- 预检新增"版本走向"事实行(上一版本 → 候选 / 抬哪一档 / 破坏性 N 条 ⇒ 要求哪一档 ✅⛔);
  `docs/RELEASE_RUNBOOK.md` 新增"抬哪一档"对照表与《稳定契约承诺》段(故意留占位)。

验证:release_preflight --self-test 32 分支全过(含新增 9 条);反向夹具新增 §24 六条真实临时仓库用例
(0.x 只抬修订 ⇒ 阻断、0.x 抬次 ⇒ 放行、≥1.0.0 抬次 ⇒ 阻断、1.0.0 留占位 ⇒ 阻断、写实+链接 ⇒ 放行、
写实但没链接 ⇒ 仍阻断)⇒ 夹具 91/0;make verify 15/15。
于是 0.1.0 之后的第一个提交(本提交)开发号 = 0.1.1。
2026-10-13 17:13:44 +08:00

102 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 发布说明(正式版本)
> 这份文档是**破坏性变化与数据迁移步骤的落脚点**:`CHANGELOG.md` 里以 `**破坏性**` 开头的条目
> **必须**链接到本文(或本文的某个小节)。发布记录本身仍在 `CHANGELOG.md` —— 本文不维护第二份变更列表。
>
> 本仓 CI **只做 CI**:不打 tag、不发 Release、不部署。发布与部署都是**独立的人工审批动作**。
## 一、正式发布(由人批准后执行)
1. **确认基线**:`git rev-parse HEAD` 拿到完整 40 位 SHA,工作区干净(`git status --porcelain` 为空)。
2. **写记录**:把这次要发的东西从 `[Unreleased]` 提到一个正式版本条目
`## [x.y.z] - YYYY-MM-DD`(四类小节,无空栏目;破坏性变化写在 `### Changed` 里并链接本文)。
版本号**只增不减**(回退走部署层,不改这个文件);每个项目只有本文件这一个版本来源。
3. **跑预检(只读)**:`python3 scripts/release_preflight.py --version X.Y.Z`
—— 输出**两段**:`准备建议`(还差什么)与 `发布就绪判定`(是否就绪)。
⚠️ 预检**不执行、也不推荐自动发布路径**:**自动发布路径已禁用** —— `--execute` / `--approve-*`
只保留参数兼容(不报 unknown argument),传了会被**明确拒绝**(rc=2,拒绝发生在收集事实之前 ⇒
**零写操作、零 API 调用**)。正式发版动作由人按本文执行。
**就绪 = 证据齐(缺一条都不算)**:
| 判据 | 说明 |
|---|---|
| 候选版本**已是目标提交里的顶部版本** | 查 `git show <sha>:CHANGELOG.md`;只在工作区写了、没提交 ⇒ 不就绪;只在**历史**里出现过(顶部是别的版本)⇒ **也不就绪**;预检会把该提交的顶部版本打出来 |
| 完整 40 位 SHA 指向准确提交 | 目标提交 == HEAD,或 `--sha` 逐字符一致 |
| 记录检查通过 | 结构与 tag 一致性(真查远端) |
| **必需 context 集合全部 success** | 集合来自 `.gitea/required-contexts.txt`;缺失/为空 ⇒ 没有策略 ⇒ 阻断;失败 / 缺项 / pending / 查询异常一律阻断 |
| 远端 tag 已核对且未被占用 | 查不到远端 ⇒ 阻断 |
| Release 不存在 | **只有 HTTP 404 才算「不存在」**;403/5xx/超时 ⇒ 查询异常 ⇒ 阻断 |
| 首发版本号 | 还没有已发布版本 ⇒ 必须**显式指定**;**不把「记录里有这个版本」当作已发布** |
自查(不依赖网络、不打真 tag):`python3 scripts/release_preflight.py --self-test`、
`python3 scripts/version.py --self-test`。
4. **由人执行**(预检不做这件事):创建**注释 tag** `vX.Y.Z`(指向验收过的那个提交,完整 40 位 SHA)
并建关联 Release,正文取自 `CHANGELOG.md`。⚠️ `release_preflight.py --execute` **已被禁用**
(见第 3 步),不要试图用它自动发版。
5. **读回**:`git ls-remote --tags origin` 里该 tag 的 SHA == 本地 `git rev-parse vX.Y.Z^{commit}`;
Release 的 `tag_name` 正确。**已发布的 tag 不移动、不覆盖。**
⚠️ 三个状态分开说:**CI 成功 ≠ 正式发版 ≠ 已部署**。
## 二、构建身份怎么看
```bash
python3 scripts/version.py --identity # JSON:release_version / build_kind / commit_sha / display_version / built_at
```
| 情形 | `build_kind` | `release_version` | `display_version` |
|---|---|---|---|
| 普通开发构建(有正式基线,tag 也在) | `development` | 顶部已发布版本 | **`<主.次>.<自该 tag 的提交数>`**,如 `0.1.12` |
| 开发构建恰好在发布提交上(提交数 = 0) | `development` | 同左 | 就是那个正式版本号 |
| 普通开发构建(**还没有正式基线 / 取不到 tag**) | `development` | `null` | `dev+g<短SHA>` |
| 发布构建(显式发布上下文 + 证据齐) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) |
**开发版本自动递增规则**:`<正式版本的主.次>.<自 `v<正式版本>` 以来的提交数>` ——
第 1 个提交 `0.1.1`、第 12 个 `0.1.12`;**数字只增、每次提交必不同**,且**不写进 CHANGELOG**
(发布记录只在发版时动)。打新 tag 后从新 tag 重新起算 ⇒ 所以**下一次正式发布请把"次版本"加一**
(`0.1.x` 之后发 `0.2.0`),这样正式号永远严格大于此前出现过的所有开发号,不会撞号。
证据字段:`commits_since_release`(自 tag 以来的提交数,无 tag 为 `null`)。
- `build_kind=release` **只能**来自显式的发布上下文(`BUILD_KIND=release`),并且**要核证据**:
完整 40 位 SHA + **指向该提交的注释 tag** `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致;
缺任何一条 ⇒ **拒绝标为正式版**(`--identity --release` 以 rc=2 退出、身份退回 `development`
并列出缺什么)。普通 `main` CI 永远是 `development`,**不得**因为"CHANGELOG 顶部有版本号"就自称正式版。
- 二进制里的注入字段(`internal/version`)与上表一一对应;契约测试断言:
注入 == CHANGELOG 顶部(发布构建)、开发构建必须是 `dev` 形状(**不许声称正式版**),且**跳过不算通过**。
## 二·补、抬哪一档(硬门禁,预检会拦)
| 抬哪一位 | 什么情况 | 预检判据(不满足 ⇒ 阻断,发不出去) |
|---|---|---|
| **修订** `0.1.N → 0.1.N+1` | 只修 bug、内部改动:对外行为不变、无破坏性、无成规模新能力 | 候选号必须 **> 当前开发号**(开发号到 `0.1.7` 时只能发 `0.1.8` 及以上);候选 ≤ 上一个正式版本 ⇒ 阻断(号只增不减) |
| **次版本** `0.1.x → 0.2.0` | **有破坏性变化**(0.x 阶段一律走次版本),或成规模的对外新能力/行为变化 | 候选发布记录里破坏性条数 ≥1 且当前是 `0.x` ⇒ 必须抬**次版本**;只抬修订 ⇒ 阻断 |
| **主版本** `0.x → 1.0.0`、`1.x → 2.0.0` | 首次声明"对外契约稳定"(`1.0.0`);之后**任何**破坏性变化(`2.0.0`) | ≥1.0.0 阶段破坏性 ⇒ 必须抬**主版本**(抬次/修订都阻断);**首发 `1.0.0` 还要求本文《稳定契约承诺》写实并在发布记录里链接它** |
判据里用的"破坏性条数"取自**目标提交里候选版本那一段**(发版事实看提交里的记录,不看工作区)。
## 二·补·2、《稳定契约承诺》(发 1.0.0 的前置条件)
> ⛔ 现在**故意留着占位**:只要还有 `(待填)` / `TODO` / `TBD`,预检在候选为 `1.0.0`(且上一个正式版本是 `0.x`)时就会阻断。
> 逐项写实、并在 `CHANGELOG.md` 的 `## [1.0.0]` 记录里链接本文后,门禁才放行。四项一个都不能少。
- **接口**(待填):对外 HTTP / CLI / SDK 接口的稳定范围与版本策略(哪些字段冻结、变更怎么通知)。
- **数据模型**(待填):库表 / 文件 / 消息结构的兼容窗口与迁移责任。
- **配置**(待填):配置项与默认值的冻结范围、弃用流程、回退方式。
- **CLI 输出与退出码**(待填):脚本可依赖的输出形态与退出码约定。
## 三、破坏性变化 / 数据迁移怎么写
在 `CHANGELOG.md` 的 `### Changed` 里:
```markdown
- **破坏性**:接口 `/api/old` 下线,升级步骤见 [迁移步骤](docs/RELEASE_RUNBOOK.md#三破坏性变化--数据迁移怎么写)
```
要求:**必须以 `**破坏性**` 开头**(便于发版前扫出来)、**必须带本文链接**;仓内相对链接会被
`scripts/check_changelog.py` **对账**(文件不存在 ⇒ 红)。迁移步骤要写清「先做什么、怎么验证、怎么回退」。
## 四、回滚
回滚**不改** `CHANGELOG.md`、**不动** tag:走部署层(恢复上一个已验收的镜像/版本 + 读回证据)。
本仓部署流程在 `docs/OPERATIONS_RUNBOOK.md` 与各环境的部署脚本里;部署是**独立审批**,
并要记录"实际部署的版本 + 完整 SHA"。