docs: 版本条目改固定四类 + 破坏性变化固定写法 + 构建身份(口径变更)

对着试点仓落地时暴露的问题做的口径与模板修正(本轮不推、不打 tag、不部署):

- 条目形状:四个自定义必填节 → **固定四类** Added/Changed/Fixed/Security
  (分类可省略、不许空栏目);过程记录进 docs/archive/,未合并事项进 docs/BACKLOG.md。
- 破坏性变化/迁移:固定写在 Changed 里、以 `**破坏性**` 开头并**必须链接发布文档**,
  仓内 `docs/….md` 链接**对账**(不存在即红)。
- 构建身份:release_version / build_kind / commit_sha / display_version / built_at;
  开发构建显示 X.Y.Z-dev+g<短SHA>(无基线 dev+g<短SHA>),**不许只显示上一正式版本号、
  不许自称正式版**;release 只认显式发布上下文。
- **还没有正式版本一律放行**:不判红;首个正式版本号**由人指定**(预检把它列成阻塞项)。
- **没核对远端就不许说校验成功**:CI/预检用 `--require-remote`(本地模式明确写「未核对远端」)。
- 修正"CI 取不到 tag 所以不用 tag"的过时口径:正式版本**就是**用注释 tag,
  只是**构建时不要用 git describe 取版本号**;**测试 tag 只在临时测试仓库里创建**。
- 模板同步:templates/check-changelog.py、templates/release-preflight.py、
  新增 templates/version.py;ci-gate.py 的 version 项在 CI 下强制核对远端;
  new-project.sh 落 scripts/version.py 并按四类重写 CHANGELOG 骨架;
  README/07/模板 README 同步;04 增加 §I(V-1~V-4 四条,含负例在哪)。

验证:templates 两个检查器 --self-test 全过;new-project.sh bash -n 通过;
生成的新工程里 check_changelog --tags 绿、--identity 给出 dev+g<短SHA>、
CI=true 下 version 项因"未核对远端"判红(预期);试点仓反向夹具 80/0。
This commit is contained in:
姚章浩
2026-10-13 14:59:48 +08:00
parent 5d286dc6c8
commit 79c154f9af
10 changed files with 642 additions and 272 deletions
+72 -24
View File
@@ -438,12 +438,26 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
## 6. 版本号与发版
> **本节 2026-10-12 重写**(原 §6 只有"构建注入"与"默认不打 tag"两条)。为了能对着原稿审:
> **本节 2026-10-12 重写**(原稿 §6 只有"构建注入"与"默认不打 tag"两条):
> ① 权威来源从笼统的"构建时注入"改成**每个项目显式声明一个来源**;
> ② 正式发版改为**注释 tag + Gitea Release**(原稿写的是"默认不打 tag");
> ③ 正式版本条目新增两个必填节(兼容性与迁移影响、已知问题);
> ④ 新增"CI 成功 / 正式发版 / 部署"三态区分;
> ⑤ 新增 CI 自动检查与**只读**的发布预检入口。
> ③ 新增"CI 成功 / 正式发版 / 部署"三态区分;
> ④ 新增 CI 自动检查与**只读**的发布预检入口。
>
> **本节 2026-10-13 二次修正**(对着真实试点仓 `knowledge-governance` 落地时发现的问题):
> ① 版本条目形状由"**四个自定义必填节**(改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题)"
> 改为"**固定四类小节**(Added / Changed / Fixed / Security,分类可省略、不许空栏目)"——
> 原来的自定义节会把审查过程、测试命令、待办都塞进发布记录;过程记录改放 `docs/archive/`、待办放 `docs/BACKLOG.md`;
> ② **破坏性变化**改为固定写在 `### Changed` 里、以 `**破坏性**` 开头并**必须链接发布文档**(链接目标会被对账,不存在即红);
> ③ 新增**构建身份**(`release_version` / `build_kind` / `commit_sha` / `display_version` / `built_at`):
> 开发构建显示 `X.Y.Z-dev+g<短SHA>` 或 `dev+g<短SHA>`,**不许只显示上一正式版本号**,也不许自称正式版;
> ④ **还没有正式版本**(新仓常态)必须放行:`version` 检查不判红、构建落入 `dev+g<短SHA>`、
> 首个正式版本号**由人指定**(预检把它列为阻塞项,不替你编一个号);
> ⑤ **没核对远端 tag 就不许说"校验成功"**:CI 用 `--require-remote`,拿不到远端清单即红;
> ⑥ 修正"CI 取不到 tag 所以不用 tag"的过时口径:**正式版本就是要用注释 tag**,
> 只是**构建时不要用 `git describe` 取版本号**(浅克隆里它会静默退化)。
> ⑦ **测试 tag 只能在临时测试仓库里创建**,不许在真实仓里造 tag 试检查器。
>
> 操作步骤见同级 `07-发版操作说明(tag与Release).md`。
### 6.1 版本号只有一个权威来源(每个项目**显式声明**它)
@@ -460,25 +474,53 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
- 版本号**只增不减**;回退走部署层回滚(§5.6),**不改版本历史**。
- 运行时 `/version` 报出的 `version` / `commit_sha` / `build_time` 是"线上跑的是哪个提交"的**权威答案**;
CI 断言"注入进二进制的号 == 权威来源"。
- **没有 HTTP 面的项目**(纯脚本/内部服务)至少要能用 `python3 scripts/version.py --identity`
(或 `make version`)打印构建身份 JSON;把"用户可见标识"与"仓库/提交事实"一起给出来。
### 6.2 版本号格式与日常改动
### 6.2 版本号格式、日常改动与构建身份
- 三段十进制 `主版本.次版本.修订版本`;**不许前导零**(`01.2.3` 不合格)。
- 日常改动记入 `CHANGELOG.md` 的 `[Unreleased]`;**不要求每个提交升版本**。
- `[Unreleased]` 段若有,必须在文件**最上面**,且可以只有 `### 改了什么`(发版时把它改成版本号并补全)。
- 三段十进制 `主版本.次版本.修订版本`;**不许前导零**(`01.2.3` 不合格);**只增不减**。
- 日常改动记入 `CHANGELOG.md` 的 `[Unreleased]`(按 §6.3 的四类小节);
**不要求每个提交都改本文件** —— 有使用者可见的变化才记。
- `[Unreleased]` 段若有,必须在文件**最上面**;发版时把它改成 `## [x.y.z] - YYYY-MM-DD`。
- **构建身份**(开发构建也要能说清"这是哪个开发版"):
### 6.3 正式版本条目必须写四节
| 字段 | 含义 |
|---|---|
| `release_version` | 正式版本基线;**还没有正式发布过就是空** |
| `build_kind` | `development` / `release` —— **只有显式发布上下文**(如 `BUILD_KIND=release`)才是 release |
| `commit_sha` | 完整 40 位提交 SHA |
| `display_version` | 用户可见标识:`X.Y.Z` / `X.Y.Z-dev+g<短SHA>` / `dev+g<短SHA>` / `dev` |
| `built_at` | 构建时间 |
标题形如 `## [x.y.z] - YYYY-MM-DD`,条目里必须有:
⚠️ 开发构建**不能**只显示上一正式版本号;普通 `main` 构建**不许**自称正式版
(判据是显式上下文,不是"CHANGELOG 顶部有版本号")。
| 节 | 要求 |
### 6.3 条目形状:固定四类小节(**不写过程记录**)
标题形如 `## [x.y.z] - YYYY-MM-DD`。小节**只认四类**:
| 小节 | 写什么 |
|---|---|
| `### 改了什么` | 人话描述这次改了什么(不逐条抄提交) |
| `### 兼容性与迁移影响` | 有无破坏性变更、数据/接口迁移步骤与回退办法;没有就写"无" |
| `### 怎么验收` | **命令 → 期望现象**(实测过的值,不是设计意图) |
| `### 已知问题` | 已知缺陷与已声明缺口;没有就写"无" |
| `### Added` | 新增能力 |
| `### Changed` | 行为调整(**破坏性变化写在这里**) |
| `### Fixed` | 修复 |
| `### Security` | 安全相关变化 |
⚠️ 四节**都要有内容**:整节空着等于没写("无"是一个合法的内容)。
- **分类可省略**(有哪类写哪类),**但不要空栏目** —— 写了就得有内容,不需要就整节删掉。
- 面向**使用者**描述变化,**不逐条复制提交**;不写"第几轮审查"、不贴命令与测试输出。
- **过程记录**(审查轮次、命令输出、逐条修正明细)放 `docs/archive/`;
**未合并事项**放 `docs/BACKLOG.md`,不作为已完成变更写进变更记录。
- **破坏性变化 / 数据迁移**:写在 `### Changed` 里,**以 `**破坏性**` 开头**,并**链接发布文档**
(升级/迁移步骤与回退办法写在那份文档里):
```
### Changed
- **破坏性**:接口 `/api/old` 下线,升级步骤见 [迁移步骤](docs/RELEASE_RUNBOOK.md)
```
⚠️ 检查器会**对账**仓内链接:`docs/….md` 不存在 ⇒ 红(防止链接到一个不存在的 runbook)。
### 6.4 正式发版 = 注释 tag + Gitea Release(**要人批准**)
@@ -500,15 +542,20 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
### 6.6 自动检查(CI 必查,失败即红)
1. `CHANGELOG.md` 格式与版本号合法:三段十进制、无前导零、有日期、顺序严格递减、不重复;
2. 正式版本条目**四节齐备且非空**(§6.3);
3. **构建注入的版本 == 权威来源**(注入的断言被 SKIP 也算不通过);
1. `CHANGELOG.md` 格式与版本号合法:三段十进制、无前导零、**日期合法**、顺序严格递减、不重复;
2. 小节只属四类、**没有空栏目**、**破坏性变化在 Changed 里且带可用的发布文档链接**(§6.3);
3. **构建身份 == 权威来源**:发布构建的 `release_version` 必须等于 CHANGELOG 顶部;
开发构建必须是 `dev` 形状且**不许自称正式版**(这条要有一条"自称正式版 ⇒ 必须红"的负例);
**注入的断言被 SKIP 也算不通过**;
4. **存在正式 tag 时**:tag 版本 == 它指到的那个提交里的版本记录;正式 tag 必须是**注释 tag**
(轻量 tag 不合格);
5. 缺失 / 格式错 / 版本漂移 ⇒ **失败**。
(轻量 tag 不合格);**核对必须真的问远端**(`--require-remote`)——
拿不到远端清单 ⇒ 红(没核对就不许说"校验成功");非正式 tag(如本地 `x`)不当作发布版本;
5. 缺失 / 格式错 / 版本漂移 ⇒ **失败**;
6. **还没有正式版本**(新仓常态)⇒ 放行:不许因为"没有已发布版本"判红,首个版本号由人指定。
⚠️ **没有 tag 不算失败**:开发版本不打 tag 是常态,**日常 CI 不许因此变红**。
参考实现:`scripts/check_changelog.py`(`--self-test` 逐条证明会红;`--tags` 带 tag 一致性);
参考实现:`scripts/check_changelog.py`(`--self-test` 逐条证明会红;`--tags [--require-remote]` 带 tag 一致性)
与 `scripts/version.py --identity`(构建身份);
清单里的检查项 id 叫 `version`(与 `contracts` 分开:一个是"记录是否合规",一个是"注入是否等于来源")。
### 6.7 发布预检入口(**只读**,不产生副作用)
@@ -602,8 +649,9 @@ giteactl logs <ns>/<repo> <runId> # 单个作业日志
giteactl rerun <ns>/<repo> <runId> # 重跑(不推新提交即可验证整条链路)
# ── 发版(人工,CI 不代劳;详见 §6 与 07-发版操作说明)──────
# 0. CHANGELOG:把 [Unreleased] 改成 ## [x.y.z] - 日期,补齐四节(改了什么/兼容性与迁移影响/怎么验收/已知问题)
python3 scripts/check_changelog.py --tags # 结构 + 正式 tag 与提交内版本一致
# 0. CHANGELOG:把 [Unreleased] 改成 ## [x.y.z] - 日期,按四类小节写(破坏性变化带发布文档链接)
python3 scripts/check_changelog.py --tags --require-remote # 结构 + 正式 tag 与提交内版本一致(真查远端)
python3 scripts/version.py --identity # 构建身份(开发/发布、显示版本、完整 SHA)
python3 scripts/release_preflight.py # **只读**预检:版本/完整SHA/CI证据/CHANGELOG/远端tag与Release/遗留项
# 1. 主人批准(版本号 + 完整 SHA + 那次 CI 的 run 链接)之后才执行:
python3 scripts/release_preflight.py --execute --version <x.y.z> --approve-version <x.y.z> --approve-sha <完整SHA>
+22
View File
@@ -175,3 +175,25 @@
**不能在检出前依赖仓内文件**;预览仍然排在"报告核对"之后。
| U-5 | **把 `total_commits` 当声明条数**:真实新分支推送时 Gitea 给 `total_commits=0`、列表被截断(5 条),Git 范围 12 条 ⇒ 严判让门禁在真实 CI 首跑就红(与代码质量无关) | **Git 范围是唯一权威**:`total_commits > 0` 才交叉核对;`<=0`/缺失视为"未声明",以 Git 范围为准并把"负载条数不可信"写进来源;`--self-test` 加 `total_commits=0` 分支 | 真实 run #16(`ci-trial-20261012`);自检 15 分支 |
---
## I. 第四轮修正(版本与变更记录规范化,2026-10-13)
按试点仓 `knowledge-governance` 落地时暴露的三个问题(CHANGELOG 混入审查流水与命令;
文档说"不使用 tag"但 CI 已在核验 tag;开发构建沿用上一正式版本号)做的口径与模板修正。
| 编号 | 问题 | 修法 | 负例在哪 |
|---|---|---|---|
| V-1 | **变更记录变成过程日志**:`[Unreleased]` 里塞着审查轮次、测试命令、待办 ⇒ 发布记录读不出"这次发了什么" | 条目形状由"四个自定义必填节"改为**固定四类**(`Added`/`Changed`/`Fixed`/`Security`,分类可省略、**不许空栏目**);过程记录迁 `docs/archive/`,未合并事项迁 `docs/BACKLOG.md`;**不要求每个提交都改** | `check-changelog.py --self-test`:自创栏目 / 空栏目 / 重复 / 顺序倒置 / 非法日期 / 前导零 逐条报红;项目夹具真改文件再恢复 |
| V-2 | **破坏性变化无处安放**:混在"兼容性与迁移影响"里,且可能链接到不存在的文档 | 固定写在 `### Changed` 里、以 `**破坏性**` 开头,**必须链接发布文档**;仓内 `docs/….md` 链接**对账**(不存在即红) | 夹具:无链接 ⇒ 红;链接 `docs/NO_SUCH_DOC.md` ⇒ 红;写在 `### Added` 里 ⇒ 红(位置固定) |
| V-3 | **开发构建沿用上一正式版本号**,分不清开发版与正式版;且"还没有正式版本"会被当成错误 | 新增**构建身份**(`release_version` / `build_kind` / `commit_sha` / `display_version` / `built_at`):开发显示 `X.Y.Z-dev+g<短SHA>`、无基线 `dev+g<短SHA>`、发布才是 `X.Y.Z`;`build_kind=release` 只认**显式上下文**;**还没有正式版本一律放行**(不判红,首个版本号由人指定) | `internal/version` 契约:开发构建带 dev 标识且**不等于**正式版本号;**负例**"自称正式版 ⇒ 必须红"(门禁里真跑这条负例);夹具另有 `--identity --release` 无基线 ⇒ rc=2 |
| V-4 | **"没核对远端"却声称校验成功**(拿不到远端 tag 清单时照打✅) | `check_changelog.py --tags --require-remote`:CI 与预检都强制**真查远端**(`.ci-remote-tags` 或 `ls-remote`);查不到 ⇒ 红;本地模式明确写「未核对远端」,不再打印"校验成功" | 夹具:临时仓库里 6 个场景(无 tag 绿 / 注释 tag 一致绿 / 版本不符红 / 轻量 tag 红 / 拿不到远端+require-remote 红 / 未发布段写日期红);**测试 tag 只在临时仓库创建** |
**生成器同步**:`new-project.sh` 现在也落 `scripts/version.py`(构建身份),CHANGELOG 骨架改成四类小节 +
构建身份说明;`templates/ci-gate.py` 的 `version` 项改用 `--tags --require-remote`。
**口径变更(`01-规范` §6 二次修正)**:① 条目形状四节 → 四类;② 破坏性变化固定位置 + 必须带文档链接;
③ 新增构建身份与"开发构建不许自称正式版";④ "还没有正式版本"放行、首个版本号由人指定;
⑤ 拿不到远端 tag ⇒ 红;⑥ 修正"CI 取不到 tag 所以不用 tag"的过时口径(正式版本**就是**用注释 tag,
只是**构建时不要用 `git describe` 取版本号**);⑦ 测试 tag 只在临时仓库创建。
+31 -14
View File
@@ -18,7 +18,19 @@
---
## 1. 版本号的来源(每个项目声明的那个)
## 1. 版本号的来源与构建身份
**构建身份**(`python3 scripts/version.py --identity`):
| 情形 | `build_kind` | `release_version` | `display_version` |
|---|---|---|---|
| 开发构建(有正式基线) | `development` | 顶部已发布版本 | `X.Y.Z-dev+g<短SHA>` |
| 开发构建(**还没有正式版本**) | `development` | 空 | `dev+g<短SHA>` |
| 发布构建(**显式发布上下文**) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) |
⚠️ 开发构建**不许**只显示上一正式版本号,也不许自称正式版;`build_kind=release`
只能来自显式上下文(`BUILD_KIND=release`),普通 `main` 构建永远是 `development`。
- **本环境默认**:仓库根 `CHANGELOG.md` **顶部那条已发布版本**就是版本号的唯一来源。
- 读它:`python3 scripts/version.py`(解析不到 ⇒ 非 0 退出,构建据此失败)。
@@ -32,11 +44,13 @@
## 2. 日常改动(平时就做,不用发版)
1. 改完代码,把这次改动写进 `CHANGELOG.md` 顶部 `## [Unreleased]` 的 `### 改了什么`;
1. 改完代码,把**使用者可见**的变化写进 `CHANGELOG.md` 顶部 `## [Unreleased]` 的四类小节
(`Added` / `Changed` / `Fixed` / `Security`;分类可省略,但别留空栏目);
2. 提交前跑本仓门禁:`make verify`(唯一权威入口,本地与 CI 同一条命令);
3. 推送。**不需要**每次提交都升版本、也不需要打 tag。
`[Unreleased]` 段可以只有 `### 改了什么`;把四节补齐是**发版时**的事。
**过程记录不进变更记录**:审查轮次、命令输出、逐条修正明细放 `docs/archive/`;
未合并事项放 `docs/BACKLOG.md`。日常改动**不要求每个提交都写**,有使用者可见的变化才记。
---
@@ -45,21 +59,24 @@
### 3.1 先把记录补齐
1. 把 `## [Unreleased]` 改成 `## [x.y.z] - YYYY-MM-DD`
(三段十进制、**不许前导零**;版本号只增不减);
2. 在该版本块里补全**四节**,每节都要有内容("无"也算内容,但不能空着):
(三段十进制、**不许前导零**、日期合法;版本号只增不减)。
⚠️ **本仓还没有正式发布过时**:没有可 bump 的基线,版本号**由你指定**
(预检会把它列成阻塞项并给一个建议值,不替你编号);
2. 该版本块里按**四类小节**写(分类可省略,但**不要空栏目**):
`### Added` / `### Changed` / `### Fixed` / `### Security`;
3. **破坏性变化 / 数据迁移**写在 `### Changed` 里、以 `**破坏性**` 开头,并**链接发布文档**:
```
### 改了什么 —— 人话描述这次改了什么
### 兼容性与迁移影响 —— 破坏性变更 / 数据或接口迁移步骤与回退办法;没有就写"无"
### 怎么验收 —— 命令 → 期望现象(实测过的值)
### 已知问题 —— 已知缺陷与已声明缺口;没有就写"无"
```
```markdown
- **破坏性**:接口 `/api/old` 下线,升级步骤见 [迁移步骤](docs/RELEASE_RUNBOOK.md)
```
3. 清干净工作区(预检会因为"有未提交改动"直接拦住);
4. 跑一遍检查器:
检查器会**对账**仓内链接(`docs/….md` 不存在 ⇒ 红);
4. 清干净工作区(预检会因为"有未提交改动"直接拦住);
5. 跑一遍检查器与构建身份:
```bash
python3 scripts/check_changelog.py --tags # 结构 + 正式 tag 与提交内版本一致
python3 scripts/check_changelog.py --tags --require-remote # 结构 + 正式 tag 与提交内版本一致(真查远端)
python3 scripts/version.py --identity # 构建身份(开发/发布、显示版本、完整 SHA)
```
### 3.2 跑发布预检(只读,不产生任何副作用)
+1 -1
View File
@@ -18,7 +18,7 @@
| `04-模板评估与修正.md` | 附件 15 处问题的证据与改法(含实测反证) |
| `05-应用机制-让规范在以后所有工程生效.md` | 三渠道(技能 / 仓内指针 / 生成器+守门)、已配 vs 待配、验证记录 |
| `06-接入既有工程.md` | **既有仓怎么接进来**:前置体检(含 PR 锁死陷阱)、一条命令、试点记录、验收判据 |
| `07-发版操作说明(tag与Release).md` | **怎么发版**:三态区分、四节变更记录、只读预检、批准后打注释 tag + 建 Release 与读回判据、常见错误表 |
| `07-发版操作说明(tag与Release).md` | **怎么发版**:三态区分、四类小节变更记录(含破坏性变化的固定写法)、构建身份、只读预检、批准后打注释 tag + 建 Release 与读回判据、常见错误表 |
| `templates/` | 可直接落仓的骨架:`gitea-build.yml`(**只做 CI**)、`gitea-prod-deploy.yml`(人工发起;未实现的步骤故意红)、`gitignore`、`commit-msg`、`ci_gate.py`、`ci_report.py`、`README.md`(内含 AGENTS.md 骨架) |
| `scripts/new-project.sh` | 新工程生成器:一条命令产出默认合规的骨架(CI + 门禁 + 提交规范;已端到端测过) |
| `scripts/adopt-existing.py` | 既有工程接入器:体检 → 加指针(建 PR 后**停下等你批准**)→ 开保护(幂等,默认 DRY-RUN);`--merge-pr <n>` 才是显式批准的合并 |
+20 -14
View File
@@ -70,6 +70,7 @@ cp "$TPL/ci_report.py" .gitea/scripts/ci_report.py # 核对报告 + 上报
cp "$TPL/ci_preview.py" .gitea/scripts/ci_preview.py # **先核对完整报告,再发预览**
cp "$TPL/ci_checkout.sh" .gitea/scripts/ci_checkout.sh # 检出:固定事件 SHA、协议不降级、URL 不放凭据
cp "$TPL/check-changelog.py" scripts/check_changelog.py # 版本记录检查(正本 §6.6,门禁的 version 项调它)
cp "$TPL/version.py" scripts/version.py # 版本与构建身份(正本 §6.2,唯一来源 CHANGELOG.md)
cp "$TPL/release-preflight.py" scripts/release_preflight.py # 发版预检(**只读**;批准后才 --execute)
chmod +x scripts/ci_checks.py scripts/check_secrets.py scripts/check_changelog.py \
scripts/release_preflight.py .gitea/scripts/*.py .gitea/scripts/*.sh
@@ -77,31 +78,36 @@ chmod +x scripts/ci_checks.py scripts/check_secrets.py scripts/check_changelog.p
# 变更记录骨架:**版本号的唯一权威来源就是本文件顶部那条已发布版本**(正本 §6.1)。
# 首次发版前只有 `[Unreleased]` 是正常的(检查会放行;构建拿不到版本号则是另一回事)。
cat > CHANGELOG.md <<'CL'
# 变更记录
# Changelog
> 版本号的**唯一权威来源** = 本文件顶部那条已发布版本(正本 §6.1)。
> 日常改动先记在 `## [Unreleased]` 的 `### 改了什么` 里;正式发版时把它改成
> `## [x.y.z] - YYYY-MM-DD`,并补齐四节:改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题。
> 首次发版之前没有"已发布版本"是正常的。
> 发版流程见规范仓 `07-发版操作说明(tag与Release).md`;本仓预检:`python3 scripts/release_preflight.py`。
> 版本号的**唯一权威来源** = 本文件顶部那条已发布版本(正本 §6.1);不放 `VERSION` 文件、不写进源码常量。
> 日常改动写进 `## [Unreleased]`(**不要求每个提交都写**,有使用者可见的变化才记)。
> 正式发版时把它改成 `## [x.y.z] - YYYY-MM-DD`(版本号由人指定:**首次发版前没有已发布版本是正常的**)。
>
> 小节**只认四类**(分类可省略,但不要空栏目):`### Added` / `### Changed` / `### Fixed` / `### Security`。
> **破坏性变化**写在 `### Changed` 里、以 `**破坏性**` 开头,并链接发布文档(升级/迁移与回退步骤)。
>
> 构建身份:`python3 scripts/version.py --identity`(release_version / build_kind / commit_sha /
> display_version / built_at)。正式发版流程见规范仓 `07-发版操作说明(tag与Release).md`;
> 本仓只读预检:`python3 scripts/release_preflight.py`。
## [Unreleased]
### 改了什么
### Added
- (在这里记这次改了什么)
- (这次新增了什么 —— 写使用者能感知的变化,不逐条抄提交)
### 兼容性与迁移影响
### Changed
- 无
- (行为调整)
### 怎么验收
### Fixed
1. (命令 → 期望现象)
- (修了什么)
### 已知问题
### Security
- 无
- (安全相关;没有就整节删掉,别留空栏目)
CL
echo " CHANGELOG.md(版本权威来源骨架)"
+9 -4
View File
@@ -125,10 +125,15 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/actions/ta
与 `07-发版操作说明(tag与Release).md`。
- 版本号的**唯一权威来源**:本仓 `CHANGELOG.md` 顶部那条已发布版本(正本 §6.1)。
禁止再放 `VERSION` 文件、禁止写进源码常量、禁止在 CI 里用 `git describe`(检出取不到 tag)。
- **完成一个功能**时,把改动写进 `CHANGELOG.md` 的 `## [Unreleased]`(**不要求每个提交升版本**)。
- **完成一个功能**时,把**使用者可见**的变化写进 `CHANGELOG.md` 的 `## [Unreleased]`
(四类小节 `Added` / `Changed` / `Fixed` / `Security`,分类可省略、别留空栏目;
**不要求每个提交升版本**)。过程记录放 `docs/archive/`,未合并事项放 `docs/BACKLOG.md`。
- **构建身份**:`python3 scripts/version.py --identity` —— 开发构建显示 `X.Y.Z-dev+g<短SHA>`
(没有正式基线时 `dev+g<短SHA>`),**不许只显示上一正式版本号、不许自称正式版**。
- **准备发版**时,先给用户这五样,批准后才动:
1. **版本建议**(`python3 scripts/release_preflight.py` 给的候选值);
2. **变更说明**(四节:改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题);
1. **版本建议**(`python3 scripts/release_preflight.py` 给的候选值;
**还没有正式发布过时版本号必须由用户显式指定**,预检会把它列成阻塞项);
2. **变更说明**(四类小节;破坏性变化写在 `### Changed` 里、以 `**破坏性**` 开头并链接发布文档);
3. **准确提交的完整 40 位 SHA**;
4. 那次提交的 **CI 成功证据**(status context + run 链接)—— 不是"本地跑过了";
5. **未验证项与遗留风险**。
@@ -136,7 +141,7 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/actions/ta
CI **不**自动升版本、**不**打 tag、**不**发布。
- 正式发版用**注释 tag**(`v<版本号>`)指向验收过的提交;Gitea Release 关联该 tag,
说明**来自 CHANGELOG**,不另写一份。
- 自查命令:`python3 scripts/check_changelog.py --tags`(结构 + tag 一致性);
- 自查命令:`python3 scripts/check_changelog.py --tags --require-remote`(结构 + tag 一致性,**真查远端**);
`python3 scripts/release_preflight.py`(**只读**预检,不给"可以发"就是不能发)。
- ⚠️ **开发版本没打 tag 是正常的**:日常 CI 不许因此变红。
````
Regular → Executable
+319 -193
View File
@@ -1,30 +1,37 @@
#!/usr/bin/env python3
# 阅读时注意:默认的版本号权威来源是**仓库根 `CHANGELOG.md` 顶部那条已发布版本**(正本 §6.1)。
# 某个项目要用别的来源(例如别的文件),必须在它的 `AGENTS.md` 里写明,并让构建与本脚本都读那一个来源。
"""CHANGELOG / 版本记录检查 —— 把"版本规则"变成退出码(正本 §6)。
判据(每条都能被 --self-test 证明会红):
A. 结构(默认就跑)
1. 至少一个已发布版本条目;`[Unreleased]` 段若有,必须在最上面、可以只有 `改了什么`;
2. 已发布版本标题 `## [x.y.z] - YYYY-MM-DD`:三段数字、**不许前导零**、日期必备;
3. 每个已发布版本块必须有**四节**:`改了什么`、`兼容性与迁移影响`、`怎么验收`、`已知问题`
("已知问题"可以写“无”,但不能整节空着 —— 空节等于没写);
4. 版本号不重复、按出现顺序严格递减(最新在最上面);
5. 已发布版本块本身不为空。
1. `[Unreleased]` 段若有,必须在最上面;**只有 `[Unreleased]`、还没有任何正式版本**是合法的
(开发期常态,不许因此判红);
2. 已发布版本标题 `## [x.y.z] - YYYY-MM-DD`:三段十进制、**不许前导零**、**日期必备且必须是真日期**;
3. 小节**只认四类** `Added` / `Changed` / `Fixed` / `Security`:
**分类可省略**(不要求每个提交都写),但**不保留空栏目** —— 写了就得有内容;
每个版本块至少有一个非空分类(整块空白 ⇒ 红);
4. **破坏性变化**写在 `### Changed` 里、以 `**破坏性**` 开头,且必须**链接发布文档**;
仓内相对链接(`docs/….md`)会被**对账**:文件不存在 ⇒ 红(防"链接到不存在的 runbook");
5. 版本号不重复、按出现顺序**严格递减**(最新在最上面);已发布版本块本身不为空。
B. tag 一致性(`--tags` / CI 里跑)
6. 正式 tag 形如 `v<版本号>`,且必须是**注释 tag**(轻量 tag 不合格);
7. 每个正式 tag 指到的那个提交里,`CHANGELOG.md` 顶部的已发布版本 == 该 tag 的版本
(防止"tag 指着的提交从没被记进变更记录");
8. **没有 tag 是正常的**:开发版本不打 tag 绝不能让日常 CI 变红;
看不到远端 tag 时(缺 `.ci-remote-tags`)明说"没核对远端",不假绿。
7. 每个正式 tag **指到的那个提交里**的 CHANGELOG 顶部已发布版本 == 该 tag 的版本
(防止"tag 指着的提交从没被记进变更记录");**用那个提交的内容核对,不用当前 HEAD**;
8. **没有 tag 是正常的**(开发版本不打 tag 绝不能让日常 CI 变红);
9. **没核对远端就不许说"校验成功"**:本地模式明确打印「未核对远端」;
CI 用 `--require-remote`(或设 `CI=true`)⇒ 拿不到远端 tag 清单**直接判红**。
用法:
python3 scripts/check_changelog.py # 只查结构
python3 scripts/check_changelog.py --tags # 结构 + 本地 tag 一致性
python3 scripts/check_changelog.py --self-test # 反向自检(含造 tag 的夹具)
python3 scripts/check_changelog.py # 只查结构
python3 scripts/check_changelog.py --tags # 结构 + tag 一致性
python3 scripts/check_changelog.py --tags --require-remote # CI 用:远端清单缺失即红
python3 scripts/check_changelog.py --self-test # 反向自检(含造真仓库的 tag 夹具)
"""
from __future__ import annotations
import datetime as dt
import os
import re
import subprocess
import sys
@@ -33,71 +40,152 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
HEADING = re.compile(r"^##\s*\[([^\]]+)\](\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*$", re.MULTILINE)
HEADING = re.compile(r"^##\s*\[([^\]]+)\](\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*$")
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") # 不许前导零(01.2.3 不合格)
TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$")
SEC_CHANGE = "### 改了什么"
SEC_COMPAT = "### 兼容性与迁移影响"
SEC_ACCEPT = "### 怎么验收"
SEC_KNOWN = "### 已知问题"
REQUIRED_SECS = (SEC_CHANGE, SEC_COMPAT, SEC_ACCEPT, SEC_KNOWN)
SEC_H = re.compile(r"^###\s+(.+?)\s*$")
CATEGORIES = ("Added", "Changed", "Fixed", "Security")
BREAKING = "**破坏性**"
# 破坏性变化条目:允许列表项前缀(`- **破坏性**:…` / `1. **破坏性**:…`)
BREAK_LINE = re.compile(r"^\s*(?:[-*+]|\d+[.)])?\s*\*\*破坏性\*\*")
REMOTE_TAGS = ROOT / ".ci-remote-tags"
LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
# ── 结构解析(**围栏代码块内的 `##`/`###` 不算标题**)─────────────────────
def _blocks(text: str):
"""版本块:每块的正文到**下一个二级标题**为止(`### x` 不算)。
⚠️ 边界必须是**任意** `## ` 标题,不只是 `## [x.y.z]`:像「## 能力基线(未发布)」这种
不带方括号的二级标题若不当边界,它的正文会被算进上一个版本块 ⇒ 让"空栏目"看起来有内容
(夹具真的抓到过这个假绿)。
"""
lines = text.splitlines()
fence = None
heads: list[tuple[int, bool, str, str | None]] = []
for i, line in enumerate(lines):
s = line.strip()
if fence is None and s.startswith("```"):
fence = "```"
continue
if fence is not None:
if s.startswith("```"):
fence = None
continue
if not re.match(r"^##\s", line): # `### x` 不匹配:`##` 后面必须是空白
continue
m = HEADING.match(line)
heads.append((i, bool(m), m.group(1) if m else "", m.group(3) if m else None))
out = []
matches = list(HEADING.finditer(text))
for i, m in enumerate(matches):
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
out.append((m.group(0), m.group(1), m.group(3), text[m.end():end]))
for k, (i, is_version, label, date) in enumerate(heads):
end = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
if is_version: # 不带版本号的二级标题只当边界,本身不校验
out.append((lines[i], label, date, lines[i + 1:end]))
return out
def _sec_body(body: str, sec: str) -> str:
tail = body.split(sec, 1)[1]
return re.split(r"^###\s", tail, maxsplit=1, flags=re.MULTILINE)[0]
def _sections(body_lines: list[str]):
"""返回 {小节名: 内容行} 与出现顺序。围栏内的行归入当前小节(不算标题)。"""
out: dict[str, list[str]] = {}
order: list[str] = []
cur = ""
fence = False
for line in body_lines:
s = line.strip()
if s.startswith("```"):
fence = not fence
if cur:
out[cur].append(line)
continue
m = None if fence else SEC_H.match(line)
if m:
cur = m.group(1).strip()
out.setdefault(cur, [])
order.append(cur)
continue
if cur:
out[cur].append(line)
return out, order
def check(path: Path | None = None) -> list[str]:
def _nonempty(lines: list[str]) -> bool:
return any(l.strip() and not l.strip().startswith("```") for l in lines)
def _date_ok(s: str) -> bool:
try:
dt.date.fromisoformat(s)
except ValueError:
return False
return True
def _doc_link_problem(line: str, root: Path) -> str | None:
"""破坏性变化那行必须带一个能用的发布文档链接;返回问题描述(None = 没问题)。"""
targets = LINK.findall(line)
if not targets:
return "没有链接(要指向发布文档,写清升级/迁移步骤)"
for t in targets:
clean = t.split("#", 1)[0].split("?", 1)[0]
if clean.startswith(("http://", "https://")):
return None
if not clean.endswith(".md"):
continue
if (root / clean).is_file():
return None
return f"链接的发布文档不存在:{clean}"
return f"链接目标不是文档(要 `docs/….md` 或 https 链接):{targets[0]}"
def check(path: Path | None = None, root: Path | None = None) -> list[str]:
path = path or (ROOT / "CHANGELOG.md")
root = root or ROOT
if not path.is_file():
return [f"找不到 {path}"]
text = path.read_text(encoding="utf-8")
blocks = _blocks(text)
blocks = _blocks(path.read_text(encoding="utf-8"))
released = [b for b in blocks if b[1] != "Unreleased"]
if not released:
# ⚠️ "还没有发过版"不是错误:新工程只有 `[Unreleased]` 是正常的 ——
# 首次发版时把 Unreleased 改成 `## [0.1.0] - YYYY-MM-DD` 并补齐四节即可。
# (真一个段都没有,才是"没有版本记录"。)
if any(b[1] == "Unreleased" for b in blocks):
return []
return ["没有已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)—— 也没有 `[Unreleased]` 段"]
if not released and not any(b[1] == "Unreleased" for b in blocks):
return ["没有版本记录:既没有已发布版本条目(`## [0.1.0] - 2026-10-10`),也没有 `[Unreleased]` 段"]
problems: list[str] = []
# [Unreleased] 必须在最上面(日常改动先记这里)
if blocks and blocks[0][1] == "Unreleased":
pass
elif any(b[1] == "Unreleased" for b in blocks):
if any(b[1] == "Unreleased" for b in blocks) and blocks[0][1] != "Unreleased":
problems.append("[Unreleased] 段必须在最上面(日常改动先记这里,发版时把它改成版本号)")
seen: list[str] = []
for heading, ver, date, body in released:
if ver == "Unreleased":
for heading, label, date, body in blocks:
secs, order = _sections(body)
unknown = [s for s in order if s not in CATEGORIES]
if unknown:
problems.append(f"{heading} 有小节不属于允许的四类({'/'.join(CATEGORIES)}):{unknown}"
f"(分类可省略,但不能自创栏目)")
empty = [s for s in order if s in CATEGORIES and not _nonempty(secs[s])]
if empty:
problems.append(f"{heading} 的 {empty} 是空栏目(写了就得有内容;不需要就整节删掉)")
if not [s for s in order if s in CATEGORIES and _nonempty(secs[s])]:
problems.append(f"{heading} 没有任何有内容的分类(至少写一条使用者可见的变化)")
for name in order:
if name != "Changed" and any(BREAK_LINE.match(l) for l in secs.get(name, [])):
problems.append(f"{heading} 的 `{name}` 里有破坏性变化条目 —— "
f"破坏性变化必须写在 `### Changed` 里(位置固定,便于发版前扫出来)")
for line in secs.get("Changed", []):
if BREAK_LINE.match(line):
bad = _doc_link_problem(line, root)
if bad:
problems.append(f"{heading} 的破坏性变化不合规:{bad}")
if label == "Unreleased":
if date:
problems.append(f"{heading} 是未发布段,不该写日期")
continue
if not SEMVER.match(ver):
problems.append(f"{heading} 的版本号不合法(要 `主.次.修订` 三段十进制、不许前导零):{ver!r}")
if not SEMVER.match(label):
problems.append(f"{heading} 的版本号不合法(要 `主.次.修订` 三段十进制、不许前导零):{label!r}")
continue
seen.append(ver)
seen.append(label)
if not date:
problems.append(f"{heading} 缺少日期(要写成 `## [{ver}] - YYYY-MM-DD`)")
for sec in REQUIRED_SECS:
if sec not in body:
problems.append(f"[{ver}] 缺少小节 `{sec}` —— 正式版本必须记录"
f"改了什么 / 兼容性与迁移影响 / 怎么验收 / 已知问题")
continue
if not [l for l in _sec_body(body, sec).splitlines()
if l.strip() and not l.strip().startswith("```")][:1]:
problems.append(f"[{ver}] 的 `{sec}` 下面是空的(不知道就写“无”,但不能空着)")
problems.append(f"{heading} 缺少日期(要写成 `## [{label}] - YYYY-MM-DD`)")
elif not _date_ok(date):
problems.append(f"{heading} 的日期不是合法日期:{date!r}")
dupes = {v for v in seen if seen.count(v) > 1}
if dupes:
problems.append(f"版本号重复:{sorted(dupes)}")
@@ -107,36 +195,43 @@ def check(path: Path | None = None) -> list[str]:
return problems
# ── tag 一致性 ───────────────────────────────────────────────────────────
def _git(*args, cwd=None) -> tuple[int, str]:
p = subprocess.run(["git", *args], cwd=cwd or ROOT, capture_output=True, text=True)
return p.returncode, (p.stdout if p.returncode == 0 else p.stderr)
def remote_tag_lines() -> tuple[list[str], str]:
"""tag 清单,每行「对象sha + refs/tags/<名>」。
def remote_tag_lines(cwd: Path | None = None) -> tuple[list[str], str, bool]:
"""tag 清单,每行「对象 sha + refs/tags/<名>」。
取数顺序:① 检出步骤写的 `.ci-remote-tags`(CI 用,能证明远端有哪些 tag);
② 本地 `for-each-ref refs/tags`(**没核对远端**,来源里必须写明);③ `git ls-remote`。
返回 (行, 来源说明, 是否核对过远端)。顺序:
① 检出步骤写的 `.ci-remote-tags`(CI,能证明远端有哪些 tag);
② `git ls-remote --tags origin`(真查远端 → 算核对过);
③ 本地 `for-each-ref refs/tags`(**没核对远端**)。
"""
if REMOTE_TAGS.is_file():
return [l for l in REMOTE_TAGS.read_text(encoding="utf-8").splitlines() if l.strip()], \
f"远端清单 {REMOTE_TAGS.name}"
import subprocess as _sp
q = _sp.run(["git", "for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"],
cwd=ROOT, capture_output=True, text=True)
if q.returncode == 0 and q.stdout.strip():
return [l for l in q.stdout.splitlines() if l.strip()], "本地 refs/tags(**只看得到本机取到的 tag,没核对远端**)"
rc, out = _git("ls-remote", "--tags", "origin")
if rc == 0:
return [l for l in out.splitlines() if l.strip()], "git ls-remote --tags origin(本机实时查)"
return [], "拿不到任何 tag 信息(本机没有 tag,也查不了远端)"
cwd = cwd or ROOT
f = cwd / ".ci-remote-tags"
if f.is_file():
return [l for l in f.read_text(encoding="utf-8").splitlines() if l.strip()], \
f"远端清单 {f.name}(检出步骤写入)", True
p = subprocess.run(["git", "ls-remote", "--tags", "origin"], cwd=cwd,
capture_output=True, text=True)
if p.returncode == 0:
return [l for l in p.stdout.splitlines() if l.strip()], "git ls-remote --tags origin(本机实时查远端)", True
q = subprocess.run(["git", "for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"],
cwd=cwd, capture_output=True, text=True)
if q.returncode == 0:
return [l for l in q.stdout.splitlines() if l.strip()], "本地 refs/tags(**未核对远端**)", False
return [], "拿不到任何 tag 信息(本机没有 tag,也查不了远端)", False
def check_tags(changelog: Path | None = None) -> list[str]:
def check_tags(changelog: Path | None = None, *, require_remote: bool = False,
cwd: Path | None = None) -> list[str]:
"""正式 tag 的版本必须与**它指到的那个提交里**的版本记录一致。没有 tag ⇒ 不是问题。"""
changelog = changelog or (ROOT / "CHANGELOG.md")
cwd = cwd or ROOT
changelog = changelog or (cwd / "CHANGELOG.md")
problems: list[str] = []
lines, source = remote_tag_lines()
lines, source, consulted = remote_tag_lines(cwd)
formal, notes = [], []
for line in lines:
parts = line.split()
@@ -148,20 +243,28 @@ def check_tags(changelog: Path | None = None) -> list[str]:
continue
m = TAG_RE.match(name)
(formal if m else notes).append((name, sha, m.group(1) if m else ""))
print(f"-- tag 核对:来源 {source}|正式 tag {len(formal)} 个"
f"{'|非正式(不判版本一致性):' + ', '.join(n for n, _, _ in notes) if notes else ''}")
print(f"-- tag 核对:来源 {source}|{'已核对远端' if consulted else '⚠️ 未核对远端'}"
f"|正式 tag {len(formal)} 个"
f"{'|非正式(不当作发布版本、不判版本一致性):' + ', '.join(n for n, _, _ in notes) if notes else ''}")
if not consulted:
msg = ("没核对远端 tag 信息 ⇒ 不能声称 tag 校验成功"
"(CI 应传入 `.ci-remote-tags`,或让 `git ls-remote --tags origin` 可用)")
if require_remote:
problems.append(msg)
else:
print(f" ⚠️ {msg}(本机模式:只报事实,不判红)")
for name, sha, ver in formal:
rc, commit = _git("rev-parse", sha + "^{commit}")
rc, commit = _git("rev-parse", sha + "^{commit}", cwd=cwd)
if rc != 0 or not commit.strip():
problems.append(f"tag {name}({sha[:12]})指向的提交在本机取不到 —— "
f"没法核对它的版本记录(检出步骤应当把 tag 取下来)")
continue
commit = commit.strip()
rc, kind = _git("cat-file", "-t", sha)
rc, kind = _git("cat-file", "-t", sha, cwd=cwd)
if rc == 0 and kind.strip() == "commit":
problems.append(f"tag {name} 是**轻量 tag**(不是注释 tag)—— 正式发布必须用注释 tag")
rc, blob = _git("show", commit + ":CHANGELOG.md")
rc, blob = _git("show", commit + ":CHANGELOG.md", cwd=cwd)
if rc != 0:
problems.append(f"tag {name} 指到的提交里没有 CHANGELOG.md")
continue
@@ -178,165 +281,186 @@ def check_tags(changelog: Path | None = None) -> list[str]:
return problems
GOOD = """# 变更记录
# ── 自检夹具 ─────────────────────────────────────────────────────────────
GOOD = """# Changelog
## [Unreleased]
### 改了什么
- 还没发布的东西
### Added
- 新东西
## [0.2.0] - 2026-02-01
### 改了什么
### Changed
- 调整了 X 的行为
- **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md)
- 加了 X
### 兼容性与迁移影响
- 无(向后兼容)
### 怎么验收
1. 跑 `curl /health` 期望 200
### 已知问题
- 无
### Fixed
- 修了 Y
## [0.1.0] - 2026-01-01
### 改了什么
### Added
- 初版
"""
### 兼容性与迁移影响
BAD = """# Changelog
- 首次发布,无迁移
## [0.1.0] - 2026-02-30
### 怎么验收
### Added
1. 起容器,打开首页
- 日期不存在,还引了不存在的文档
- **破坏性**:删掉了旧接口,见 [说明](docs/NO_SUCH_DOC.md)(这里破坏性写在 Added 里,也要报)
### 已知问题
- 集成用例需要 TEST_DATABASE_URL,CI 里跳过(已声明)
"""
BAD = """# 变更记录
## [0.1.0]
### 改了什么
- 没有日期、缺两节、而且版本顺序还倒着
- 自创栏目
## [0.2.0] - 2026-02-01
### 改了什么
### Changed
- 顺序倒置(这条比上面那条大却排在下面)
- **破坏性**:改了配置格式(没链接)
- 上面那条比这条旧,顺序错了(版本号合法,但比上面的大)
## [0.2.0] - 2026-03-01
### Changed
- **破坏性**:删掉了 `/old` 接口,升级步骤见 [说明](docs/NO_SUCH_DOC.md)
### Fixed
## [01.0.0] - 2026-03-02
### Fixed
- 前导零
"""
def _seed_repo(d: Path, changelog_text: str, tag: str, annotated: bool, tag_ver: str) -> str:
"""造一个最小 git 仓库:一个提交 + 一个 tag(指向该提交,tag 名与提交内版本可刻意不一致)。"""
env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
def _seed_repo(d: Path, changelog_text: str, tag: str, annotated: bool) -> str:
"""造一个最小 git 仓库:一个提交 + 一个 tag(指向该提交)。"""
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")}
(d / "no-hooks").mkdir(exist_ok=True)
for args in (["init", "-q", "-b", "main"],):
subprocess.run(["git", *args], cwd=d, env={**dict(**__import__("os").environ), **env}, check=True)
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=d, env=env, check=True)
(d / "CHANGELOG.md").write_text(changelog_text, encoding="utf-8")
for args in (["add", "-A"], ["commit", "-q", "-m", "chore(release): 夹具提交"],
(d / "docs").mkdir(exist_ok=True)
(d / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
for args in (["add", "-A"], ["commit", "-q", "-m", "chore: 夹具提交"],
["tag", "-a", tag, "-m", f"release {tag}"] if annotated else ["tag", tag]):
subprocess.run(["git", *args], cwd=d, env={**dict(**__import__("os").environ), **env}, check=True)
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
text=True).stdout.strip()
subprocess.run(["git", *args], cwd=d, env=env, check=True)
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d,
capture_output=True, text=True).stdout.strip()
def self_test() -> int:
import os
problems: list[str] = []
with tempfile.TemporaryDirectory() as d:
g, b = Path(d) / "good.md", Path(d) / "bad.md"
dd = Path(d)
(dd / "docs").mkdir()
(dd / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
g, b = dd / "good.md", dd / "bad.md"
g.write_text(GOOD, encoding="utf-8")
b.write_text(BAD, encoding="utf-8")
if check(g):
problems.append(f"合规夹具竟然报红:{check(g)}")
# 首次发版前(只有 Unreleased)必须放行 —— 否则每个新工程一开箱就红
(Path(d) / "new.md").write_text(
"# 变更记录\n\n## [Unreleased]\n\n### 改了什么\n\n- 还没发布\n", encoding="utf-8")
if check(Path(d) / "new.md"):
problems.append(f"只有 Unreleased 的新工程竟然报红:{check(Path(d) / 'new.md')}")
(Path(d) / "none.md").write_text("# 变更记录\n\n(什么都没有)\n", encoding="utf-8")
if not check(Path(d) / "none.md"):
got = check(g, root=dd)
if got:
problems.append(f"合规夹具竟然报红:{got}")
# 开发期:只有 Unreleased(还没有任何正式版本)必须放行
(dd / "new.md").write_text("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 还没发布\n",
encoding="utf-8")
if check(dd / "new.md", root=dd):
problems.append(f"只有 Unreleased 的新工程竟然报红:{check(dd / 'new.md', root=dd)}")
# 完全没有版本记录 ⇒ 红
(dd / "none.md").write_text("# Changelog\n\n(什么都没有)\n", encoding="utf-8")
if not check(dd / "none.md", root=dd):
problems.append("完全没有版本记录的文件竟然没报红")
joined = " / ".join(check(b))
for want in ["缺少日期", f"缺少小节 `{SEC_COMPAT}`", f"缺少小节 `{SEC_KNOWN}`", "严格递减"]:
if want not in joined:
problems.append(f"违规夹具没有报出 `{want}` —— 这条判据是死的")
# 破坏性变化写在非 Changed 分类里 ⇒ 也要红(位置固定)
(dd / "wrongsec.md").write_text(
"# Changelog\n\n## [Unreleased]\n\n### Added\n\n- **破坏性**:删了接口(见 "
"[说明](docs/RELEASE_RUNBOOK.md))\n", encoding="utf-8")
if not check(dd / "wrongsec.md", root=dd):
problems.append("破坏性变化写在 Added 里竟然没报红(位置必须固定在 Changed)")
# 末尾跟一个"不带版本号的二级标题":上一节是空的就必须判红(不能被那段文字兜成"有内容")
(dd / "tailh2.md").write_text(
"# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n### Security\n\n"
"## 能力基线(未发布)\n\n- 这段文字不属于 Security 节\n", encoding="utf-8")
tail_got = " / ".join(check(dd / "tailh2.md", root=dd))
if "是空栏目" not in tail_got:
problems.append(f"空的最后一节被后面的二级标题兜成了『有内容』:{tail_got[:200]}")
# 前导零版本号必须报(B 夹具里已含,单独再钉一次)
(Path(d) / "zero.md").write_text(
"# 变更记录\n\n## [Unreleased]\n\n## [01.0.0] - 2026-01-01\n\n" + "".join(
f"{s}\n\n- x\n\n" for s in REQUIRED_SECS), encoding="utf-8")
if not any("不合法" in p for p in check(Path(d) / "zero.md")):
problems.append("带前导零的版本号没有判红")
# 空节必须报
(Path(d) / "empty.md").write_text(
"# 变更记录\n\n## [Unreleased]\n\n## [0.1.0] - 2026-01-01\n\n"
+ f"{SEC_CHANGE}\n\n- x\n\n{SEC_COMPAT}\n\n- 无\n\n{SEC_ACCEPT}\n\n- 无\n\n{SEC_KNOWN}\n\n",
# 围栏代码块里的 `###`/`##` 不算标题
(dd / "fence.md").write_text(
"# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 命令示例:\n\n```bash\n## [9.9.9] - 1999-01-01\n### Changed\n```\n",
encoding="utf-8")
if not any("下面是空的" in p for p in check(Path(d) / "empty.md")):
problems.append("空的『已知问题』节没有判红")
if check(dd / "fence.md", root=dd):
problems.append(f"围栏里的假标题被当成了真结构:{check(dd / 'fence.md', root=dd)}")
joined = " / ".join(check(b, root=dd))
for want in ["日期不是合法日期", "不属于允许的四类", "是空栏目", "版本号重复",
"严格递减", "版本号不合法", "破坏性变化不合规"]:
if want not in joined:
problems.append(f"违规夹具没有报出 `{want}` —— 这条判据是死的(实际:{joined[:200]})")
for want in ["链接的发布文档不存在", "没有链接", "位置固定"]:
if want not in joined:
problems.append(f"破坏性变化的判据没有报出 `{want}`(实际:{joined[:300]})")
# ── tag 一致性(造真仓库)──
with tempfile.TemporaryDirectory() as d:
repo_ok = Path(d) / "ok"
repo_ok.mkdir()
_seed_repo(repo_ok, GOOD, "v0.2.0", annotated=True, tag_ver="0.2.0")
globals()["REMOTE_TAGS"] = repo_ok / "absent" # 走本地 ls-remote 失败 → tag -l 分支
globals()["ROOT"] = repo_ok
try:
if check_tags(repo_ok / "CHANGELOG.md"):
problems.append(f"tag 一致时竟然报红:{check_tags(repo_ok / 'CHANGELOG.md')}")
dd = Path(d)
ok = dd / "ok"
ok.mkdir()
_seed_repo(ok, GOOD, "v0.2.0", annotated=True)
if check_tags(ok / "CHANGELOG.md", cwd=ok):
problems.append(f"tag 一致时竟然报红:{check_tags(ok / 'CHANGELOG.md', cwd=ok)}")
repo_bad = Path(d) / "bad"
repo_bad.mkdir()
_seed_repo(repo_bad, GOOD, "v9.9.9", annotated=True, tag_ver="9.9.9")
globals()["ROOT"] = repo_bad
if not any("!=" in p for p in check_tags(repo_bad / "CHANGELOG.md")):
problems.append("tag 版本与提交内版本不一致时没有判红")
bad = dd / "bad"
bad.mkdir()
_seed_repo(bad, GOOD, "v9.9.9", annotated=True)
if not any("!=" in p for p in check_tags(bad / "CHANGELOG.md", cwd=bad)):
problems.append("tag 版本与提交内版本不一致时没有判红")
repo_light = Path(d) / "light"
repo_light.mkdir()
_seed_repo(repo_light, GOOD, "v0.2.0", annotated=False, tag_ver="0.2.0")
globals()["ROOT"] = repo_light
if not any("轻量 tag" in p for p in check_tags(repo_light / "CHANGELOG.md")):
problems.append("轻量 tag 没有判红")
light = dd / "light"
light.mkdir()
_seed_repo(light, GOOD, "v0.2.0", annotated=False)
if not any("轻量 tag" in p for p in check_tags(light / "CHANGELOG.md", cwd=light)):
problems.append("轻量 tag 没有判红")
repo_none = Path(d) / "none"
repo_none.mkdir()
_seed_repo(repo_none, GOOD, "v0.2.0", annotated=True, tag_ver="0.2.0")
subprocess.run(["git", "tag", "-d", "v0.2.0"], cwd=repo_none, capture_output=True)
globals()["ROOT"] = repo_none
if check_tags(repo_none / "CHANGELOG.md"):
problems.append("没有 tag 时竟然报红(开发版本不打 tag 是正常的)")
finally:
globals()["REMOTE_TAGS"] = REMOTE_TAGS
globals()["ROOT"] = ROOT
_ = os
none = dd / "none"
none.mkdir()
_seed_repo(none, GOOD, "v0.2.0", annotated=True)
subprocess.run(["git", "tag", "-d", "v0.2.0"], cwd=none, capture_output=True)
if check_tags(none / "CHANGELOG.md", cwd=none):
problems.append("没有 tag 时竟然报红(开发版本不打 tag 是正常的)")
# 非正式 tag(如本地 `x`)不当作发布版本、不判红
note = dd / "note"
note.mkdir()
_seed_repo(note, GOOD, "x", annotated=False)
if check_tags(note / "CHANGELOG.md", cwd=note):
problems.append("非正式 tag 竟然报红(它不该被当成发布版本)")
# 没核对远端 ⇒ 本地模式只提示;`--require-remote` 模式必须判红
local = dd / "local"
local.mkdir()
_seed_repo(local, GOOD, "v0.2.0", annotated=True)
subprocess.run(["git", "remote", "add", "origin", str(dd / "nowhere.git")],
cwd=local, capture_output=True)
if check_tags(local / "CHANGELOG.md", cwd=local):
problems.append("本地模式(未核对远端)不该判红,只该提示")
if not any("没核对远端" in p for p in
check_tags(local / "CHANGELOG.md", cwd=local, require_remote=True)):
problems.append("--require-remote 时没核对远端竟然没判红(不许声称校验成功)")
if problems:
print("❌ 反向自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 反向自检通过:合规夹具全绿;缺日期/缺小节/前导零/空节/顺序倒置/tag 版本不符/"
"轻量 tag/无记录逐条报红;无 tag 与「只有 Unreleased」不报红")
print("✅ 反向自检通过:合规夹具全绿;非法日期/自创栏目/空栏目/重复/顺序倒置/前导零/"
"破坏性无链接或链接不存在/轻量 tag/tag 版本不符/未核对远端(--require-remote) 逐条报红;"
"只有 Unreleased、无 tag、非正式 tag 不报红")
return 0
@@ -345,20 +469,22 @@ def main(argv: list[str]) -> int:
return self_test()
target = next((a for a in argv if not a.startswith("-")), None)
path = Path(target) if target else None
require_remote = "--require-remote" in argv or os.environ.get("CI") == "true"
problems = check(path)
if "--tags" in argv or "--tag-check" in argv:
problems += check_tags(path)
problems += check_tags(path, require_remote=require_remote)
if problems:
print("❌ CHANGELOG / 版本记录不合规:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
print("\n正式版本条目写成:\n"
" ## [x.y.z] - YYYY-MM-DD\n\n ### 改了什么\n - …\n\n"
" ### 兼容性与迁移影响\n - …(看不清就写“无”,但不能空着)\n\n"
" ### 怎么验收\n 1. 命令 → 期望现象\n\n ### 已知问题\n - …\n",
file=sys.stderr)
print("\n小节只认四类(分类可省略,但不要空栏目):\n"
" ### Added / ### Changed / ### Fixed / ### Security\n"
"破坏性变化写在 `### Changed` 里,以 `**破坏性**` 开头并链接发布文档:\n"
" - **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md)\n"
"正式版本条目:`## [x.y.z] - YYYY-MM-DD`\n", file=sys.stderr)
return 1
print("✅ CHANGELOG / 版本记录合规" + ("(含 tag 一致性)" if ("--tags" in argv or "--tag-check" in argv) else ""))
print("✅ CHANGELOG / 版本记录合规"
+ ("(含 tag 一致性)" if ("--tags" in argv or "--tag-check" in argv) else ""))
return 0
+10 -4
View File
@@ -597,10 +597,11 @@ def c_tests() -> tuple[str, str]:
def c_version() -> tuple[str, str]:
"""CHANGELOG 与版本记录(正本 §6.6):格式 / 四节齐备 / 正式 tag 与提交内版本一致。
"""CHANGELOG 与版本记录(正本 §6.6):四类小节 / 无空栏目 / 正式 tag 与提交内版本一致。
三条要点:**没有 tag 不算失败**(开发版本不打 tag 是常态);检查器缺失即红;
项目没声明版本来源(默认就是 CHANGELOG 顶部)也判红 —— 不许"没有来源所以跳过"。
四条要点:**没有 tag 不算失败**(开发版本不打 tag 是常态);检查器缺失即红;
项目没声明版本来源(默认就是 CHANGELOG 顶部)也判红 —— 不许"没有来源所以跳过";
**拿不到远端 tag 清单也要红**(`--require-remote`)—— 没核对远端就不许声称"校验成功"。
"""
script = ROOT / "scripts" / "check_changelog.py"
if not script.is_file():
@@ -608,7 +609,12 @@ def c_version() -> tuple[str, str]:
if not (ROOT / "CHANGELOG.md").is_file():
return FAILURE, ("没有 CHANGELOG.md —— 每个项目必须声明版本号权威来源"
"(默认是 CHANGELOG 顶部那条已发布版本,正本 §6.1)")
rc, out, err = run([sys.executable or "python3", str(script), "--tags"])
# ⚠️ CI 里必须真的核对远端 tag(检出步骤会写 `.ci-remote-tags`);查不到远端清单 ⇒ 红,
# 而不是打一句 ✅ 然后放过去。
# 本地(没有 CI=true)不加这个开关:一个还没有 origin 的仓不该因为"没法问远端"而在本地假红
# —— 本地模式下检查器会明确打印「未核对远端」,不会声称"校验成功"。
strict = ["--require-remote"] if os.environ.get("CI") == "true" else []
rc, out, err = run([sys.executable or "python3", str(script), "--tags", *strict])
if rc:
return FAILURE, f"版本记录检查未通过:{(out or err).strip()[-300:]}"
tail = [l for l in (out or "").strip().splitlines() if l.strip()][-1:]
+36 -18
View File
@@ -1,6 +1,4 @@
#!/usr/bin/env python3
# 阅读时注意:默认的版本号权威来源是**仓库根 `CHANGELOG.md` 顶部那条已发布版本**(正本 §6.1)。
# 某个项目要用别的来源(例如别的文件),必须在它的 `AGENTS.md` 里写明,并让构建与本脚本都读那一个来源。
"""发版预检 —— **只读**,把"要不要发、发什么、有没有证据"一次列清楚。
设计上的三条硬规矩:
@@ -99,7 +97,9 @@ def block_of(text: str, heading: str) -> str:
m = re.search(rf"^##\s*\[{re.escape(heading)}\][^\n]*$", text, re.MULTILINE)
if not m:
return ""
nxt = re.search(r"^##\s*\[", text[m.end():], re.MULTILINE)
# 下一个二级标题就停(`### x` 不算:`^##\s` 不匹配三井号)。
# ⚠️ 不能只认 `## [`:像「## 能力基线(未发布)」这种没有方括号的二级标题会把整段吞进来。
nxt = re.search(r"^##\s", text[m.end():], re.MULTILINE)
end = m.end() + (nxt.start() if nxt else len(text) - m.end())
return text[m.start():end].rstrip()
@@ -154,11 +154,11 @@ def main(argv: list[str]) -> int:
text = CHANGELOG.read_text(encoding="utf-8") if CHANGELOG.is_file() else ""
rel = released_versions(text)
if not rel:
print("❌ CHANGELOG.md 里没有已发布版本 ⇒ 没有版本基线,发不了版", file=sys.stderr)
return 2
current = rel[0]
candidate = args.version or bump(current, args.bump)
current = rel[0] if rel else None
first_release = not rel
# 还没有正式发布过时:没有可 --bump 的基线,版本号必须由人显式指定 ——
# 这里只给一个建议值,并把它当成阻塞项(不假装有基线)。
candidate = args.version or (bump(current, args.bump) if current else "1.0.0")
if not SEMVER.match(candidate):
print(f"❌ 候选版本号不合法:{candidate!r}(要 主.次.修订 三段十进制、无前导零)", file=sys.stderr)
return 2
@@ -177,21 +177,31 @@ def main(argv: list[str]) -> int:
print(f" 分支/提交 : {branch} @ {head or '(取不到 HEAD)'}"
f"{'' if FULL_SHA.match(head) else ' ⚠️ 不是完整 40 位'}")
print(f" 工作区 : {'干净' if not dirty_lines else f'有 {len(dirty_lines)} 处未提交 —— 发版要在干净树上看'}")
print(f" 当前已发布 : {current}")
print(f" 待发布版本 : {candidate}({'显式 --version' if args.version else f'--bump {args.bump} 的建议值;'
f'可选 major/minor/patch'})")
print(f" 当前已发布 : {current if current else '(还没有正式发布过 —— CHANGELOG 里没有已发布版本条目)'}")
if first_release:
print(f" 待发布版本 : {candidate}(**建议值**:本仓还没有正式版本基线,"
f"首个正式版本号由你指定,用 `--version X.Y.Z` 覆盖)")
else:
print(f" 待发布版本 : {candidate}({'显式 --version' if args.version else f'--bump {args.bump} 的建议值;'
f'可选 major/minor/patch'})")
# CHANGELOG 内容
unreleased = block_of(text, "Unreleased")
print("\n CHANGELOG(Unreleased 原样打印):")
print(" " + (unreleased.replace("\n", "\n ")[:1500] if unreleased.strip() else "(没有 Unreleased 段:"
"正式发版前要把这次改动写进去)"))
known = section(block_of(text, current), "### 已知问题") if current in rel else ""
print(f"\n 当前版本的『已知问题』: {known.splitlines()[0] if known else '(没有写)'}")
breaking = [l.strip() for l in text.splitlines()
if l.strip().startswith(("- **破坏性**", "* **破坏性**"))]
print(f"\n 破坏性变化 : {len(breaking)} 条(写在 `### Changed` 里、必须带发布文档链接,"
f"写清升级/迁移与回退)")
for b in breaking[:5]:
print(f" · {b[:150]}")
print(" 遗留风险 : 见 docs/BACKLOG.md 与 Unreleased 里写明的未验证项"
"(预检不替你判断它们的可接受性)")
# 检查器(结构与 tag 一致性)
chk = subprocess.run([sys.executable or "python3", str(ROOT / "scripts" / "check_changelog.py"), "--tags"],
cwd=ROOT, capture_output=True, text=True)
chk = subprocess.run([sys.executable or "python3", str(ROOT / "scripts" / "check_changelog.py"),
"--tags", "--require-remote"], cwd=ROOT, capture_output=True, text=True)
checker_ok = chk.returncode == 0
print(f"\n CHANGELOG/版本记录检查: {'✅ 通过' if checker_ok else '❌ 不通过 —— 先修它再发版'}")
if not checker_ok:
@@ -200,6 +210,9 @@ def main(argv: list[str]) -> int:
# 远端 tag / Release / CI 证据
tag = f"v{candidate}"
blockers: list[str] = []
if first_release:
blockers.append(f"还没有已发布版本:首个正式版本号必须由人显式指定(--version X.Y.Z);"
f"{candidate} 只是建议值")
if dirty_lines:
blockers.append(f"工作区有 {len(dirty_lines)} 处未提交")
if not FULL_SHA.match(head):
@@ -218,8 +231,13 @@ def main(argv: list[str]) -> int:
remote_tags = [l.split("refs/tags/")[-1] for l in tags_out.splitlines() if "refs/tags/" in l]
if f"{tag}^{{}}" in remote_tags:
remote_tags.remove(f"{tag}^{{}}")
print(f"\n 远端 tag : {tag} {'已存在 ⛔ 不能覆盖已发布的 tag' if tag in remote_tags else '不存在(可发)'}"
f"|已存在的正式 tag: {[t for t in remote_tags if re.match(r'^v\\d', t)] or '(无)'}")
if rc != 0 or args.no_api:
why = "显式 --no-api" if args.no_api else "`git ls-remote --tags origin` 失败"
print(f"\n 远端 tag : **未核对**({why})—— 不能声称 tag 已核验")
blockers.append("没有核对远端 tag 信息(查不到远端)—— 未核验不等于可以发")
else:
print(f"\n 远端 tag : {tag} {'已存在 ⛔ 不能覆盖已发布的 tag' if tag in remote_tags else '不存在(可发)'}"
f"|已存在的正式 tag: {[t for t in remote_tags if re.match(r'^v\\d', t)] or '(无)'}")
run_line = "(未查询)"
ctx_line = "(未查询)"
@@ -253,7 +271,7 @@ def main(argv: list[str]) -> int:
blockers.append("没有核对远端 Release/CI 状态(缺凭据)")
print(f" CI 状态 : {ctx_line}")
print(f" 最近运行 : {run_line}")
print(" 遗留风险/未验证: 见 CHANGELOG 的『已知问题』与本次 Unreleased 里写明的未验证项"
print(" 遗留风险/未验证: 见 docs/BACKLOG.md 与本次 Unreleased 里写明的未验证项"
"(预检不替你判断它们的可接受性)")
print("\n── 结论 ──")
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""版本与构建身份 —— 版本号的唯一来源是仓库根的 CHANGELOG.md。
为什么是它而不是 git tag:**日常检出不做 `git describe`**(CI 检出是 `git fetch --depth 50`,
`git describe --tags` 会失败或给出误导结果)。正式 tag 的**创建**是人工批准的发布动作,
CI 只做**核验**(tag 版本 == 它指到那个提交里的版本记录),见 `check_changelog.py --tags`。
仓库里的文件则一定在。
为什么不是另放一个 `VERSION` 文件:那是第二份副本,发版要改两处,忘一处就漂移。
用法:
python3 scripts/version.py # 打印顶部「已发布」版本号;没有就非 0 退出
python3 scripts/version.py --identity # 打印构建身份 JSON(**开发构建也能用**)
python3 scripts/version.py --identity --release # 声明发布构建;没有已发布版本 ⇒ 拒绝(rc=2)
python3 scripts/version.py --check # 打印版本号并跑结构自检(等价 check-changelog.py)
构建身份字段(`--identity`):
release_version 正式版本基线;本仓还没有正式发布过 ⇒ `null`
build_kind development | release(**只有显式声明**才是 release,普通 main CI 永远不是)
commit_sha 完整 40 位提交 SHA(取不到 ⇒ `unknown`)
display_version 用户可见标识:`X.Y.Z` / `X.Y.Z-dev+g<短SHA>` / `dev+g<短SHA>` / `dev`
built_at 构建时间(`BUILT_AT` 环境变量,缺省取当前 UTC)
⚠️ 开发构建**不能**只显示上一正式版本号(那正是要修的问题),也不许自称正式版。
"""
from __future__ import annotations
import datetime as dt
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CHANGELOG = ROOT / "CHANGELOG.md"
# 已发布版本标题:`## [0.1.0] - 2026-10-10` / `## [0.1.0]`;`## [Unreleased]` 不算
RELEASE_HEADING = re.compile(r"^##\s*\[(\d+\.\d+\.\d+)\]", re.MULTILINE)
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
def current_version(path: Path = CHANGELOG) -> str | None:
"""取最上面那个已发布版本号;没有就返回 None。"""
if not path.is_file():
return None
m = RELEASE_HEADING.search(path.read_text(encoding="utf-8"))
return m.group(1) if m else None
def git_sha(root: Path = ROOT) -> str:
p = subprocess.run(["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True)
s = p.stdout.strip()
return s if FULL_SHA.match(s) else "unknown"
def display_version(release_version: str | None, kind: str, sha: str) -> str:
short = sha[:12] if FULL_SHA.match(sha) else ""
if kind == "release" and release_version:
return release_version
if release_version and short:
return f"{release_version}-dev+g{short}"
if release_version:
return f"{release_version}-dev"
return f"dev+g{short}" if short else "dev"
def identity(*, release: bool = False, built_at: str | None = None,
root: Path = ROOT, sha: str | None = None) -> dict:
"""构建身份。`release=True` 但没有已发布版本 ⇒ 由调用方拒绝(见 main)。"""
rel = current_version(root / "CHANGELOG.md")
sha = sha if sha is not None else git_sha(root)
kind = "release" if (release and rel) else "development"
return {
"release_version": rel,
"build_kind": kind,
"commit_sha": sha,
"display_version": display_version(rel, kind, sha),
"built_at": built_at or os.environ.get("BUILT_AT") or os.environ.get("BUILD_TIME")
or dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
def main(argv: list[str]) -> int:
release = "--release" in argv or os.environ.get("BUILD_KIND") == "release"
if "--identity" in argv:
if release and current_version() is None:
print("❌ 声明了发布构建(--release / BUILD_KIND=release),但 CHANGELOG.md 里没有已发布版本条目。\n"
" 发布构建必须基于一个已发布的版本记录(`## [x.y.z] - YYYY-MM-DD`)——\n"
" 本仓还没有正式发布过,所以现在只能产出开发构建身份(dev+g<短SHA>)。",
file=sys.stderr)
return 2
print(json.dumps(identity(release=release), ensure_ascii=False, indent=2))
return 0
v = current_version()
if v is None:
print(
"❌ CHANGELOG.md 里找不到已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)。\n"
" 本仓还没有正式发布过 ⇒ 没有正式版本号可用。\n"
" 要构建身份(开发构建也能用)请跑 `python3 scripts/version.py --identity`;\n"
" 发布构建必须先在 CHANGELOG.md 写下正式版本条目。",
file=sys.stderr,
)
return 1
if "--check" in argv:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from check_changelog import check # type: ignore # noqa: PLC0415
problems = check()
if problems:
for p in problems:
print(f"❌ {p}", file=sys.stderr)
return 1
print(v)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))