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:
+72
-24
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 跑发布预检(只读,不产生任何副作用)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
+317
-191
@@ -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 --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"):
|
||||
problems.append("完全没有版本记录的文件竟然没报红")
|
||||
joined = " / ".join(check(b))
|
||||
for want in ["缺少日期", f"缺少小节 `{SEC_COMPAT}`", f"缺少小节 `{SEC_KNOWN}`", "严格递减"]:
|
||||
if want not in joined:
|
||||
problems.append(f"违规夹具没有报出 `{want}` —— 这条判据是死的")
|
||||
|
||||
# 前导零版本号必须报(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",
|
||||
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 not any("下面是空的" in p for p in check(Path(d) / "empty.md")):
|
||||
problems.append("空的『已知问题』节没有判红")
|
||||
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("完全没有版本记录的文件竟然没报红")
|
||||
# 破坏性变化写在非 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]}")
|
||||
|
||||
# 围栏代码块里的 `###`/`##` 不算标题
|
||||
(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 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")):
|
||||
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")):
|
||||
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"):
|
||||
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 是正常的)")
|
||||
finally:
|
||||
globals()["REMOTE_TAGS"] = REMOTE_TAGS
|
||||
globals()["ROOT"] = ROOT
|
||||
_ = os
|
||||
|
||||
# 非正式 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
@@ -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:]
|
||||
|
||||
@@ -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,7 +177,11 @@ 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" 当前已发布 : {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'})")
|
||||
|
||||
@@ -186,12 +190,18 @@ def main(argv: list[str]) -> int:
|
||||
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,6 +231,11 @@ 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}^{{}}")
|
||||
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 '(无)'}")
|
||||
|
||||
@@ -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── 结论 ──")
|
||||
|
||||
Executable
+122
@@ -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:]))
|
||||
Reference in New Issue
Block a user