From 79c154f9af339efc3adcfc7ce5cdbe1f825fd043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Tue, 13 Oct 2026 14:59:48 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=89=88=E6=9C=AC=E6=9D=A1=E7=9B=AE?= =?UTF-8?q?=E6=94=B9=E5=9B=BA=E5=AE=9A=E5=9B=9B=E7=B1=BB=20+=20=E7=A0=B4?= =?UTF-8?q?=E5=9D=8F=E6=80=A7=E5=8F=98=E5=8C=96=E5=9B=BA=E5=AE=9A=E5=86=99?= =?UTF-8?q?=E6=B3=95=20+=20=E6=9E=84=E5=BB=BA=E8=BA=AB=E4=BB=BD=EF=BC=88?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=E5=8F=98=E6=9B=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对着试点仓落地时暴露的问题做的口径与模板修正(本轮不推、不打 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。 --- 01-规范-Git与CI-CD(Gitea版).md | 96 ++++-- 04-模板评估与修正.md | 22 ++ 07-发版操作说明(tag与Release).md | 45 ++- README.md | 2 +- scripts/new-project.sh | 34 +- templates/README.md | 13 +- templates/check-changelog.py | 512 ++++++++++++++++++----------- templates/ci-gate.py | 14 +- templates/release-preflight.py | 54 ++- templates/version.py | 122 +++++++ 10 files changed, 642 insertions(+), 272 deletions(-) mode change 100644 => 100755 templates/check-changelog.py create mode 100755 templates/version.py diff --git a/01-规范-Git与CI-CD(Gitea版).md b/01-规范-Git与CI-CD(Gitea版).md index 105f984..a62a9c4 100644 --- a/01-规范-Git与CI-CD(Gitea版).md +++ b/01-规范-Git与CI-CD(Gitea版).md @@ -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 / # 单个作业日志 giteactl rerun / # 重跑(不推新提交即可验证整条链路) # ── 发版(人工,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 --approve-version --approve-sha <完整SHA> diff --git a/04-模板评估与修正.md b/04-模板评估与修正.md index 7de0959..f2e7a53 100644 --- a/04-模板评估与修正.md +++ b/04-模板评估与修正.md @@ -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 只在临时仓库创建。 diff --git a/07-发版操作说明(tag与Release).md b/07-发版操作说明(tag与Release).md index a5716f4..83b9b61 100644 --- a/07-发版操作说明(tag与Release).md +++ b/07-发版操作说明(tag与Release).md @@ -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 跑发布预检(只读,不产生任何副作用) diff --git a/README.md b/README.md index c10062b..18947fa 100644 --- a/README.md +++ b/README.md @@ -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 ` 才是显式批准的合并 | diff --git a/scripts/new-project.sh b/scripts/new-project.sh index 1c443f5..cf7e0b1 100755 --- a/scripts/new-project.sh +++ b/scripts/new-project.sh @@ -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(版本权威来源骨架)" diff --git a/templates/README.md b/templates/README.md index 6e8a92e..4b53432 100644 --- a/templates/README.md +++ b/templates/README.md @@ -125,10 +125,15 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///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///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 不许因此变红。 ```` \ No newline at end of file diff --git a/templates/check-changelog.py b/templates/check-changelog.py old mode 100644 new mode 100755 index 97463d9..e856b95 --- a/templates/check-changelog.py +++ b/templates/check-changelog.py @@ -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 diff --git a/templates/ci-gate.py b/templates/ci-gate.py index ba72331..2612bb1 100755 --- a/templates/ci-gate.py +++ b/templates/ci-gate.py @@ -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:] diff --git a/templates/release-preflight.py b/templates/release-preflight.py index 00f7a39..8bde2ac 100644 --- a/templates/release-preflight.py +++ b/templates/release-preflight.py @@ -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── 结论 ──") diff --git a/templates/version.py b/templates/version.py new file mode 100755 index 0000000..aee67ff --- /dev/null +++ b/templates/version.py @@ -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:])) \ No newline at end of file