# 07 · 发版操作说明(注释 tag 与 Gitea Release) > 正本条款见 `01-规范-Git与CI-CD(Gitea版).md` **§6**。本文件只讲**怎么操作**。 > 适用:单主干 `main` 的所有仓库(本机 Gitea)。 > ⚠️ 一句话前提:**CI 不会替你发版**。CI 只做检查;打 tag、建 Release 是**人在明确批准后**的动作。 --- ## 0. 三个状态,先说清楚(别混成一个"绿") | 状态 | 什么时候算成立 | 证据 | |---|---|---| | CI 成功 | 提交推上去,必需检查全过 | 提交上的 status context + run 链接 + **完整 SHA** | | 正式发版 | 批准后打了注释 tag、建了 Release | 远端 tag SHA == 验收过的提交;Release 的 `tag_name` 读回一致 | | 部署 | 某个环境真的在跑这个版本 | `/version` 的 `commit_sha` + 部署读回证据 | "CI 绿"不代表"发过版";"发过版"不代表"部署了"。汇报时必须说是哪一个。 --- ## 1. 版本号的来源与构建身份 **构建身份**(`python3 scripts/version.py --identity`): | 情形 | `build_kind` | `release_version` | `display_version` | |---|---|---|---| | 开发构建(有正式基线 + tag) | `development` | 顶部已发布版本 | **`<主>.<次>.<自该 tag 的提交数>`**(如 `0.1.12`) | | 开发构建恰在发布提交上(提交数 0) | `development` | 同左 | 就是那个正式版本号 | | 开发构建(无基线 / 取不到 tag / 浅克隆) | `development` | 空 | `dev+g<短SHA>` | | 发布构建(**显式发布上下文** + 证据齐) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) | **开发号自动递增**:第 1 个提交 `0.1.1`、第 12 个 `0.1.12` —— **不写进 CHANGELOG**、数字只增; 因此**下一次正式发布请把次版本加一**(`0.1.x` → `0.2.0`),保证正式号严格大于此前所有开发号。 ⚠️ 开发构建**不许**只显示上一正式版本号,也不许自称正式版;`build_kind=release` 只能来自显式上下文(`BUILD_KIND=release`),普通 `main` 构建永远是 `development`。 - **本环境默认**:仓库根 `CHANGELOG.md` **顶部那条已发布版本**就是版本号的唯一来源。 - 读它:`python3 scripts/version.py`(解析不到 ⇒ 非 0 退出,构建据此失败)。 - 构建注入:`-ldflags "-X /internal/version.Number=<该值>"`;CI 断言"注入 == 来源",且注入的断言 **被 SKIP 也算不通过**(`contracts` 检查项)。 - ⚠️ 不要再放 `VERSION` 文件、不要把版本写进源码常量、不要在 CI 里用 `git describe` (检出是 `--depth 50`,取不到 tag ⇒ 静默给错值)。 - 某个项目要用别的权威来源,**必须在它自己的 `AGENTS.md` 里写明**,并让构建与 CI 都读那一个来源。 --- ## 2. 日常改动(平时就做,不用发版) 1. 改完代码,把**使用者可见**的变化写进 `CHANGELOG.md` 顶部 `## [Unreleased]` 的四类小节 (`Added` / `Changed` / `Fixed` / `Security`;分类可省略,但别留空栏目); 2. 提交前跑本仓门禁:`make verify`(唯一权威入口,本地与 CI 同一条命令); 3. 推送。**不需要**每次提交都升版本、也不需要打 tag。 **过程记录不进变更记录**:审查轮次、命令输出、逐条修正明细放 `docs/archive/`; 未合并事项放 `docs/BACKLOG.md`。日常改动**不要求每个提交都写**,有使用者可见的变化才记。 --- ## 3. 准备发版 ### 3.1 先把记录补齐 1. 把 `## [Unreleased]` 改成 `## [x.y.z] - YYYY-MM-DD` (三段十进制、**不许前导零**、日期合法;版本号只增不减)。 ⚠️ **本仓还没有正式发布过时**:没有可 bump 的基线,版本号**由你指定** (预检会把它列成阻塞项并给一个建议值,不替你编号); 2. 该版本块里按**四类小节**写(分类可省略,但**不要空栏目**): `### Added` / `### Changed` / `### Fixed` / `### Security`; 3. **破坏性变化 / 数据迁移**写在 `### Changed` 里、以 `**破坏性**` 开头,并**链接发布文档**: ```markdown - **破坏性**:接口 `/api/old` 下线,升级步骤见 [迁移步骤](docs/RELEASE_RUNBOOK.md) ``` 检查器会**对账**仓内链接(`docs/….md` 不存在 ⇒ 红); 4. 清干净工作区(预检会因为"有未提交改动"直接拦住); 5. 跑一遍检查器与构建身份: ```bash python3 scripts/check_changelog.py --tags --require-remote # 结构 + 正式 tag 与提交内版本一致(真查远端) python3 scripts/version.py --identity # 构建身份(开发/发布、显示版本、完整 SHA) ``` ### 3.2 就绪判据(预检说的「发布就绪」是什么意思) **就绪 = 证据齐**,缺一条都不算: | 判据 | 说明 | |---|---| | 候选版本**已是目标提交里的顶部版本** | 查 `git show :CHANGELOG.md`;只在工作区写了、没提交 ⇒ 不就绪;只在**历史**里出现(顶部是别的版本)⇒ **也不就绪** | | 完整 40 位 SHA 指向准确提交 | 目标提交 == HEAD(或显式 `--sha` 逐字符一致) | | 记录检查通过 | `check_changelog.py --tags --require-remote`(四类小节、破坏性变化带文档链接、tag 一致性) | | **必需 context 集合全部 success** | 集合来自 `.gitea/required-contexts.txt`(缺失/空 ⇒ 没有策略 ⇒ 阻断);**失败 / 缺项 / pending / 查询异常一律阻断** | | 远端 tag 已核对且未占用 | 查不到远端 ⇒ 阻断(未核验不等于可以发) | | Release 不存在 | **只有 HTTP 404 才算「不存在」**;403/5xx/超时 ⇒ 查询异常 ⇒ 阻断 | | 首发版本号 | 还没有已发布版本 ⇒ 必须**显式指定**(`--version X.Y.Z`);**不把「记录里有这个版本」当作已发布** | | 版本号不与开发号撞车 | 候选正式版本必须**严格大于当前开发号**(预检会挡住 `<=` 的候选;下一次发布用次版本加一) | | **抬档够不够(硬门禁)** | 候选发布记录里有破坏性变化 ⇒ `0.x` 阶段必须抬**次版本**(或主版本)、`≥1.0.0` 必须抬**主版本**;只抬修订 ⇒ 阻断。判据取**目标提交里候选那一段**,不看工作区 | | **`1.0.0` 的前置** | 必须先把《稳定契约承诺》(接口 / 数据模型 / 配置 / CLI 输出与退出码)写实——**不许留占位**——并在 `## [1.0.0]` 记录里链接它;否则阻断。`1.x → 2.0.0` 不重复要求 | 预检输出分两段:**`准备建议`**(还差什么)与 **`发布就绪判定`**(是否就绪)。 ⚠️ 预检是**只读**的:**自动发布路径已禁用**(`--execute` / `--approve-*` 仅保留参数兼容,传了会被 **明确拒绝**并 rc=2,且零写操作、零 API 调用),输出里也不推荐自动路径 —— 正式发版动作由人按本文执行。 ### 3.3 跑发布预检(只读,不产生任何副作用) ```bash python3 scripts/release_preflight.py # 默认建议 patch python3 scripts/release_preflight.py --version 0.2.0 # 首发必须显式指定版本号 python3 scripts/release_preflight.py --no-api # 显式不查远端 ⇒ 必然"不就绪"(只列事实) ``` 自查(不依赖网络):`python3 scripts/release_preflight.py --self-test` (就绪判定逐条阻断 + `git show :CHANGELOG.md` 的临时仓库验证)与 `python3 scripts/version.py --self-test`(正式构建证据的正/负例,全部在临时仓库里造 tag)。 它会打印:待发布版本 / **完整提交 SHA** / 该提交上的 CI 成功证据 / `[Unreleased]` 原文 / 同名**远端 tag 与 Release 是否已存在** / 遗留风险与未验证项,并在最后给出 **"可以发"或"不能发 + 阻塞项"**。 预检的只读性是可验证的:`python3 scripts/release_preflight.py --self-test` (断言写类 git 命令与 `POST/PUT/DELETE/PATCH` 在预检路径上**被拒**)。 ### 3.3 请主人批准 批准要包含三样:**版本号**、**准确提交的完整 SHA**、**那次 CI 的成功证据**(run 链接)。 不要拿"本地跑过了"当 CI 证据,也不要用短 sha 当"准确提交"。 --- ## 4. 批准后执行(只有这一步会动远端) ```bash # 三个参数缺一不可;--approve-version / --approve-sha 必须**逐字符**等于预检结果 python3 scripts/release_preflight.py --execute \ --version 0.2.0 --approve-version 0.2.0 --approve-sha <完整40位SHA> ``` 内部顺序固定:**打注释 tag (`git tag -a v0.2.0`) → 推送该 tag → 建 Gitea Release → 读回**。 读回判据(缺一即不算完成): - 远端 tag 的 SHA == 本地 tag 指向的提交(完整比对,不是前缀); - Release 的 `tag_name` 读回一致; - Release 说明**来自 CHANGELOG**,不另写正文。 ⚠️ tag 推送成功、建 Release 失败 ⇒ **不要重复推 tag**(tag 已经存在会被拒),先把 Release 补上。 --- ## 5. 发版之后 - **部署是另一件事**(§5 / 各项目 CD runbook):正式发版 ≠ 已部署。 - 已发布的 tag **不移动、不覆盖**。要"重来一次"就发一个新版本号。 - 回滚走部署层(恢复旧产物),**不改版本历史**。 - 汇报时给:tag、完整 SHA、Release 链接、CI 证据、(若已部署)`/version` 读回。 --- ## 6. 常见错误(都踩过) | 症状 | 原因 | 处理 | |---|---|---| | 日常 CI 红了,说我"没有 tag" | 把 tag 一致性检查写成了"必须有 tag" | **没有 tag 不算失败**:开发版本不打 tag 是常态,检查只能在**存在**正式 tag 时判 | | tag 打上了,但变更记录里没有这个版本 | tag 指向的提交里 `CHANGELOG.md` 顶部还是旧的版本号 | 检查会红:`tag 版本 != 该提交里 CHANGELOG 顶部的版本`;先补 CHANGELOG 再打 tag | | 打了轻量 tag | `git tag v1.2.3`(没有 `-a`) | 正式 tag 必须是**注释 tag**;已发布的轻量 tag 不要强推覆盖,发新版本号 | | 预检通过了却发不出去 | 预检是**只读**的,发版要显式 `--execute` 加两个批准参数 | 按 §4 执行,别指望预检替你发 | | CI 里 `git describe` 拿不到版本 | 检出是 `--depth 50`,没有 tag | 不要用 `git describe` 取版本;读 `CHANGELOG` 顶部 | | Release 说明和 CHANGELOG 不一致 | 又写了一份正文 | Release 说明**来自 CHANGELOG**,只此一份 |