From 041f18bfd2be0b1eb22a14dd1eaead995d88f991 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 13 Oct 2026 16:45:56 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=BC=80=E5=8F=91=E5=8F=B7=E9=9A=8F?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E8=87=AA=E5=8A=A8=E9=80=92=E5=A2=9E=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 - **开发版本号自动递增**:`display_version`(development) = `<正式版本的主.次>.<自 v<正式版本> 以来的提交数>` (0.1.1、0.1.12;恰在发布提交上 = 正式号),由 git 算出来、**不写进 CHANGELOG** ⇒ 每个提交都有自己的版本号,而发布版本与 tag 仍由人批准(与"CI 不自动升版本/不打 tag"并存)。 - **浅克隆拒绝算号**(退回 `-dev+g<短SHA>`),检出步骤在有 tag 时 `--unshallow`; 接线三处(门禁 ldflags / Makefile / Dockerfile)都要接 `CommitsSinceRelease`,契约测试交叉核对。 - **发布时次版本加一**(`0.1.x` → `0.2.0`),预检挡住"候选 <= 当前开发号"。 - 同步:templates/version.py、templates/release-preflight.py、正本 §6 四次修正(⑪⑫⑬)与 §6.2 表、 07 身份表与就绪判据、templates/README.md、04 §L(V-11~V-13)。 验证:templates 两个自检全过;试点仓反向夹具 85/0、make verify 15/15。 --- 01-规范-Git与CI-CD(Gitea版).md | 22 ++++++++- 04-模板评估与修正.md | 13 +++++ 07-发版操作说明(tag与Release).md | 11 +++-- templates/README.md | 7 ++- templates/release-preflight.py | 48 +++++++++++++++++- templates/version.py | 78 ++++++++++++++++++++++++++---- 6 files changed, 162 insertions(+), 17 deletions(-) diff --git a/01-规范-Git与CI-CD(Gitea版).md b/01-规范-Git与CI-CD(Gitea版).md index e07c19d..4d2f7fc 100644 --- a/01-规范-Git与CI-CD(Gitea版).md +++ b/01-规范-Git与CI-CD(Gitea版).md @@ -476,6 +476,13 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; } > 直接阻断,不许拿"体检时观察到的状态"顶替);**失败 / 缺项 / pending / 查询异常一律阻断**。 > **Release 查询只有 HTTP 404 才算「不存在」**,403/5xx/超时都算查询异常,不得当作可发。 > +> **本节 2026-10-13 四次修正**(版本递增方式): +> ⑪ **开发版本号自动递增**:`<主>.<次>.<自 `v<正式版本>` 以来的提交数>`(`0.1.1`、`0.1.12`…), +> 由 git 算出来、**不写进 CHANGELOG** ⇒ 每个提交都有自己的版本号,而**发布版本仍由人批准** +> (CI 不自动升版本、不打 tag —— 两条同时成立); +> ⑫ **浅克隆里拒绝算号**(退回 `-dev+g<短SHA>`),检出步骤在有 tag 时 `--unshallow` 补全历史; +> ⑬ **发布时次版本加一**(`0.1.x` → 下一个 `0.2.0`),预检挡住"候选 <= 当前开发号"。 +> > 操作步骤见同级 `07-发版操作说明(tag与Release).md`。 ### 6.1 版本号只有一个权威来源(每个项目**显式声明**它) @@ -508,11 +515,24 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; } | `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` | + | `display_version` | 用户可见标识:正式 `X.Y.Z` | **开发 `<主>.<次>.<自 v<正式版本> 以来的提交数>`**(如 `0.1.12`)|无基线/取不到 tag `dev+g<短SHA>` | + | `commits_since_release` | 自 `v` 以来的提交数(无 tag / 浅克隆 ⇒ 空) | | `built_at` | 构建时间 | ⚠️ 开发构建**不能**只显示上一正式版本号;普通 `main` 构建**不许**自称正式版 (判据是显式上下文,不是"CHANGELOG 顶部有版本号")。 +- **开发版本自动递增(零人工)**:`<正式版本的主.次>.<自 `v<正式版本>` 以来的提交数>` —— + 例:正式 `0.1.0` 之后第 1 个提交 = `0.1.1`、第 12 个 = `0.1.12`;恰在发布提交上 = `0.1.0`。 + 它**不写进 CHANGELOG**(发布记录只在发版时动),由 `scripts/version.py` 从 git 算出来 ⇒ + **每个提交都有自己的版本号、数字只增**;自动升的是**开发标识**,发布版本与 tag 仍由人批准 + (与"CI 不自动升版本/不打 tag"不冲突)。 + ⚠️ **浅克隆里拒绝算号**(`--depth` 截断会把条数算少 ⇒ 会报错号):退回 `-dev+g<短SHA>`; + 检出步骤在仓库有 tag 时补全历史(`--unshallow`)。 + 接线(三处都已接好,别只改一处):`scripts/version.py --identity` → 门禁 `--internal-build` 的 + `-ldflags -X …CommitsSinceRelease=`;`Makefile` 的 `COMMITS_SINCE_RELEASE`;`Dockerfile` 的同名 build-arg。 + 契约测试会**交叉核对**:注入进来的提交数必须等于 git 算出的(防注入错号)。 + ⚠️ **下一次正式发布请把次版本加一**(`0.1.x` 之后发 `0.2.0`),保证正式号严格大于此前所有开发号、不撞号; + 发版预检会挡住"候选 <= 当前开发号"的候选。 ### 6.3 条目形状:固定四类小节(**不写过程记录**) diff --git a/04-模板评估与修正.md b/04-模板评估与修正.md index 9a8860d..b4cc545 100644 --- a/04-模板评估与修正.md +++ b/04-模板评估与修正.md @@ -223,3 +223,16 @@ |---|---|---|---| | V-9 | **只判候选版本"在提交的历史里出现过"**:旧版本条目还列在下面时,也会被当成"已写进目标提交" | `version_in_commit` 改为比**该提交里的顶部已发布版本**:`RELEASE_HEADING.search(blob)` 取第一条 `## [x.y.z]` 并要求 == 候选版本(顺带兜住"最新在最上面 / 只增不减") | 自检临时仓库:顶部 `0.3.0` + 历史 `0.2.0` ⇒ 候选 `0.2.0` **拒绝**、`0.3.0` 通过;夹具 §23 同场景两态都跑(顶部 vs 历史) | | V-10 | **预检里还留着能把仓改动的自动发布路径**(`--execute` 会打 tag/推送/建 Release) | 按只读约定**删掉写路径**:`--execute` / `--approve-*` 只保留参数兼容并**明确拒绝**(rc=2),拒绝发生在收集事实之前 ⇒ **零写操作、零 API 调用**;自检里用 spy 断言"拒绝路径一个子进程都没跑、一个 API 都没发",并扫描生产路径不许出现直接执行 git 写命令的行 | `release_preflight.py --self-test`(spy 计数 + 源码扫描);夹具 §23:`--execute` rc=2、stderr 明说已禁用、stdout 为空、**跑完仓里 tag 不变** | + +--- + +## L. 第七轮修正(版本自动递增,2026-10-13) + +使用者要求"每提交一次自动升一个版本",且版本规则由实现方定义。要点:**自动升的是开发标识, +不是发布版本** —— 这样与"CI 不自动升版本、不打 tag"并存。 + +| 编号 | 事项 | 做法 | 负例/断言在哪 | +|---|---|---|---| +| V-11 | 开发版本自动递增 | `display_version`(development)= **`<正式版本的主.次>.<自 `v<正式版本>` 以来的提交数>`**:`0.1.1`、`0.1.12`;恰在发布提交上 = 正式号;**不写进 CHANGELOG**(发布记录只在发版时动),由 `scripts/version.py` 从 git 算 | `version.py --self-test`:临时仓库打 tag → 恰在 tag 上 = `0.2.0`、tag 后第 2 个提交 = `0.2.2`、且**不等于**正式号;夹具里 Go 侧同场景再跑一遍 | +| V-12 | 浅克隆会把提交数算少(会报错号) | 浅仓库里**拒绝算号**,退回 `<版本>-dev+g<短SHA>`;检出步骤在仓库有 tag 时 `git fetch --unshallow` 补全历史 | `version.py --self-test`(有基线但无 tag ⇒ 回退形态);Go 契约同样判 `--is-shallow-repository` | +| V-13 | 正式版本可能撞上已经出现过的开发号 | 发版预检新增判据:**候选正式版本必须严格大于当前开发号**,否则阻断并建议"次版本加一" | `release_preflight.py --self-test` 两条分支(`0.1.5` vs 开发号 `0.1.12` ⇒ 阻断;`0.2.0` ⇒ 放行)+ 夹具 §23 两态 | diff --git a/07-发版操作说明(tag与Release).md b/07-发版操作说明(tag与Release).md index fa42546..7b7d830 100644 --- a/07-发版操作说明(tag与Release).md +++ b/07-发版操作说明(tag与Release).md @@ -24,9 +24,13 @@ | 情形 | `build_kind` | `release_version` | `display_version` | |---|---|---|---| -| 开发构建(有正式基线) | `development` | 顶部已发布版本 | `X.Y.Z-dev+g<短SHA>` | -| 开发构建(**还没有正式版本**) | `development` | 空 | `dev+g<短SHA>` | -| 发布构建(**显式发布上下文**) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) | +| 开发构建(有正式基线 + tag) | `development` | 顶部已发布版本 | **`<主>.<次>.<自该 tag 的提交数>`**(如 `0.1.12`) | +| 开发构建恰在发布提交上(提交数 0) | `development` | 同左 | 就是那个正式版本号 | +| 开发构建(无基线 / 取不到 tag / 浅克隆) | `development` | 空 | `dev+g<短SHA>` | +| 发布构建(**显式发布上下文** + 证据齐) | `release` | 顶部已发布版本 | `X.Y.Z`(另带完整 SHA) | + +**开发号自动递增**:第 1 个提交 `0.1.1`、第 12 个 `0.1.12` —— **不写进 CHANGELOG**、数字只增; +因此**下一次正式发布请把次版本加一**(`0.1.x` → `0.2.0`),保证正式号严格大于此前所有开发号。 ⚠️ 开发构建**不许**只显示上一正式版本号,也不许自称正式版;`build_kind=release` 只能来自显式上下文(`BUILD_KIND=release`),普通 `main` 构建永远是 `development`。 @@ -92,6 +96,7 @@ python3 scripts/version.py --identity # 构建身份( | 远端 tag 已核对且未占用 | 查不到远端 ⇒ 阻断(未核验不等于可以发) | | Release 不存在 | **只有 HTTP 404 才算「不存在」**;403/5xx/超时 ⇒ 查询异常 ⇒ 阻断 | | 首发版本号 | 还没有已发布版本 ⇒ 必须**显式指定**(`--version X.Y.Z`);**不把「记录里有这个版本」当作已发布** | +| 版本号不与开发号撞车 | 候选正式版本必须**严格大于当前开发号**(预检会挡住 `<=` 的候选;下一次发布用次版本加一) | 预检输出分两段:**`准备建议`**(还差什么)与 **`发布就绪判定`**(是否就绪)。 ⚠️ 预检是**只读**的:**自动发布路径已禁用**(`--execute` / `--approve-*` 仅保留参数兼容,传了会被 diff --git a/templates/README.md b/templates/README.md index a94d250..325cf64 100644 --- a/templates/README.md +++ b/templates/README.md @@ -128,8 +128,11 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos///actions/ta - **完成一个功能**时,把**使用者可见**的变化写进 `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>`),**不许只显示上一正式版本号、不许自称正式版**。 +- **构建身份**:`python3 scripts/version.py --identity` —— **开发号自动递增**: + `<主>.<次>.<自 v<正式版本> 以来的提交数>`(如 `0.1.12`;无基线/取不到 tag 时 `dev+g<短SHA>`), + **不写进 CHANGELOG**、数字只增;**不许只显示上一正式版本号、不许自称正式版**。 + 正式发布时请把**次版本加一**(`0.1.x` → `0.2.0`),预检会挡住"候选 <= 当前开发号"。 + ⚠️ 浅克隆里拒绝算号(退回 `dev+g<短SHA>`),检出步骤在有 tag 时会 `--unshallow` 补全历史。 - ⚠️ **正式构建要核证据**:`--identity --release` 必须能核到 **完整 40 位 SHA + 指向该提交的注释 tag `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致**;缺一条 ⇒ **拒绝标为正式版**(rc=2)。 自查:`python3 scripts/version.py --self-test`(正例 + 无 tag / 短 SHA / tag 不符 / 轻量 tag 负例)。 diff --git a/templates/release-preflight.py b/templates/release-preflight.py index fb174a5..c297034 100644 --- a/templates/release-preflight.py +++ b/templates/release-preflight.py @@ -39,6 +39,7 @@ CHANGELOG = ROOT / "CHANGELOG.md" FULL_SHA = re.compile(r"^[0-9a-f]{40}$") 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*)$") +SIGNUM = re.compile(r"^\d+\.\d+\.\d+$") # 预检路径允许的只读 git 子命令(写命令在这里直接拒) GIT_READ_OK = {"ls-remote", "rev-parse", "log", "show", "cat-file", "for-each-ref", "status", "diff", "describe", "tag", "remote"} @@ -188,6 +189,30 @@ def release_state(status: int) -> str: return "error" +def current_dev_number() -> tuple[str | None, int | None]: + """当前开发号(`<主>.<次>.<自 tag 的提交数>`)与提交数;取不到 ⇒ (None, None)。 + + 直接复用 `scripts/version.py` 的身份计算(同一份规则,不另写一套)。 + """ + try: + sys.path.insert(0, str(ROOT / "scripts")) + import version as ver # type: ignore # noqa: PLC0415 + + ident = ver.build_identity()[0] + n = ident.get("commits_since_release") + dev = ident.get("display_version") + if ident.get("build_kind") == "development" and dev and SIGNUM.match(str(dev)): + return str(dev), (int(n) if isinstance(n, int) else None) + return None, (int(n) if isinstance(n, int) else None) + except Exception: + return None, None + + +def _ver_tuple(v: str) -> tuple[int, int, int]: + a, b, c = (int(x) for x in v.split(".")) + return a, b, c + + def readiness(f: dict) -> tuple[bool, list[str], list[str]]: """把**事实**翻成(是否就绪, 阻塞项, 准备建议)——纯函数,可用假事实直接自检。 @@ -209,6 +234,17 @@ def readiness(f: dict) -> tuple[bool, list[str], list[str]]: f"—— 版本记录要先提交,再发版") advice.append(f"把 `## [Unreleased]` 改成 `## [{cand}] - YYYY-MM-DD`(四类小节、" f"破坏性变化带发布文档链接)并**提交**,然后重跑预检") + dev = f.get("dev_number") + if dev and SEMVER.match(str(f.get("candidate") or "")): + try: + if _ver_tuple(str(f["candidate"])) <= _ver_tuple(str(dev)): + blockers.append(f"候选正式版本 {f['candidate']} 不大于当前开发号 {dev}" + f"—— 会把已经出现过的开发号当成正式版(开发号随提交递增," + f"发布请把次版本加一,例如 " + f"{str(dev).split('.')[0]}.{int(str(dev).split('.')[1]) + 1}.0)") + advice.append("下一次正式发布建议用 <主>.<次+1>.0(保证正式号严格大于此前所有开发号)") + except ValueError: + pass if f.get("candidate_tagged"): blockers.append(f"远端已经有 tag v{cand} ⇒ 不能覆盖已发布的 tag(版本号只增不减)") if f.get("dirty"): @@ -334,6 +370,9 @@ def main(argv: list[str]) -> int: # ── 事实收集(只读)────────────────────────────────────────────── tag = f"v{candidate}" required, policy_src = required_contexts() + dev_number, dev_commits = current_dev_number() + print(f" 当前开发号 : {dev_number or '(无正式基线 / 取不到 tag ⇒ dev+g<短SHA>)'}" + f"{f'(自 v{current} 起 {dev_commits} 个提交)' if (dev_number and dev_commits is not None) else ''}") top_in_commit = top_version_in_commit(want_sha) in_target = top_in_commit == candidate mark = "✅ 就是顶部" if in_target else "❌ 不是顶部" @@ -397,7 +436,7 @@ def main(argv: list[str]) -> int: facts = { "candidate": candidate, "tag": tag, "first_release": first_release, "explicit_version": bool(args.version), "in_target_commit": in_target, - "top_in_commit": top_in_commit, + "top_in_commit": top_in_commit, "dev_number": dev_number, "dirty": len(dirty_lines), "head_full": bool(FULL_SHA.match(head)), "sha_is_head": want_sha == head, "checker_ok": checker_ok, "unreleased_present": bool(unreleased.strip()), @@ -481,6 +520,11 @@ def self_test() -> int: first_release=True, explicit_version=False) rd("首发**显式**指定且记录已进提交 ⇒ 就绪", True, first_release=True, explicit_version=True, contexts={"build / build (push)": "success"}) + rd("候选正式版本不大于当前开发号 ⇒ 不就绪(不许把开发号当正式号)", + False, "不大于当前开发号", candidate="0.1.5", dev_number="0.1.12", in_target_commit=True) + rd("候选正式版本 > 当前开发号 ⇒ 该条不拦(次版本加一)", + True, "", candidate="0.2.0", dev_number="0.1.12", in_target_commit=True, + contexts={"build / build (push)": "success"}) rd("候选版本没进目标提交 ⇒ 不就绪", False, "还没写进目标提交", in_target_commit=False) rd("候选版本只在历史里(顶部是别的版本)⇒ 不就绪,且信息里给出真实顶部版本", False, "顶部**已发布版本是 `## [0.3.0]`", @@ -595,7 +639,7 @@ def self_test() -> int: print(f" · {p}", file=sys.stderr) return 1 print("✅ 预检自检通过:写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上全部被拒;版本推进正确;" - "就绪判定 19 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" + "就绪判定 21 个分支(context 缺项/pending/failure/查询异常、无策略、Release 非 404、" "tag 占用、未核对远端、首发未显式指定、记录未进提交…逐条阻断;证据齐才就绪);" "『顶部版本才算数』与『写进提交才算数』用临时仓库验证;" "`--execute` 明确拒绝且零写操作、零 API 调用") diff --git a/templates/version.py b/templates/version.py index c7ba436..3e812b3 100755 --- a/templates/version.py +++ b/templates/version.py @@ -18,12 +18,19 @@ CI 只做**核验**(tag 版本 == 它指到那个提交里的版本记录) 完整 40 位 SHA + 一个**指向该提交的合规注释 tag** `v<版本号>` + 该提交里 CHANGELOG 顶部版本一致; 缺任何一条 ⇒ **拒绝标为正式版**(rc=2,JSON 里带 `release_blocked` 说明缺什么)。 +**开发版本自动递增(零人工)**:`<正式版本的主.次>.<自 v<正式版本> 以来的提交数>` —— +例:正式 `0.1.0` 之后第 1 个提交 = `0.1.1`,第 12 个 = `0.1.12`;打新 tag 后从新 tag 重新起算。 +它**不写进 CHANGELOG**(发布记录只在发版时动),而是从 git 算出来 ⇒ 每次提交必然不同、数字只增。 +自动升的是**开发标识**,不是发布版本:正式版本与 tag 仍需人批准(规范禁止 CI 自动打 tag)。 + 构建身份字段(`--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` + display_version 用户可见标识:`X.Y.Z`(正式)/ `<主.次>.<提交数>`(开发,随提交递增)/ + `dev+g<短SHA>`(还没有正式基线)/ `dev`(连 SHA 都取不到) built_at 构建时间(`BUILT_AT` 环境变量,缺省取当前 UTC) + commits_since_release 自 `v` 以来的提交数(没有 tag ⇒ null) ⚠️ 开发构建**不能**只显示上一正式版本号(那正是要修的问题),也不许自称正式版。 """ @@ -61,10 +68,42 @@ def git_sha(root: Path = ROOT) -> str: return s if FULL_SHA.match(s) else "unknown" -def display_version(release_version: str | None, kind: str, sha: str) -> str: +def commits_since_release(release_version: str | None, root: Path = ROOT) -> int | None: + """自 `v` 那个 tag 以来的提交数(含 HEAD;HEAD 恰在 tag 上 ⇒ 0)。 + + 取不到 tag(还没发布过 / 检出没取 tag)⇒ None —— 这时退回 `dev+g<短SHA>`,不猜号。 + ⚠️ **浅克隆里不算**:`--depth` 截断的历史会把条数算少 ⇒ 宁可退回 `dev+g<短SHA>`, + 也不报一个"看起来正常但其实错"的开发号(检出步骤会在有 tag 时补全历史,见 `ci_checkout.sh`)。 + """ + if not release_version: + return None + rc, sh = _git("rev-parse", "--is-shallow-repository", cwd=root) + if rc == 0 and sh.strip() == "true": + return None + rc, out = _git("rev-list", "--count", f"v{release_version}..HEAD", cwd=root) + t = out.strip() + return int(t) if rc == 0 and t.isdigit() else None + + +def dev_version(release_version: str | None, n: int | None) -> str | None: + """开发版本号:`<主>.<次>.`;`n==0` ⇒ 就是发布提交本身(返回正式版本号)。""" + if not release_version or n is None: + return None + if n == 0: + return release_version + a, b, _c = release_version.split(".") + return f"{int(a)}.{int(b)}.{n}" + + +def display_version(release_version: str | None, kind: str, sha: str, + n: int | None = None) -> str: short = sha[:12] if FULL_SHA.match(sha) else "" if kind == "release" and release_version: return release_version + # 开发构建:有正式基线就报"随提交递增"的开发号(无 tag/取不到计数才退回 dev+g<短SHA>) + dev = dev_version(release_version, n) + if dev: + return dev if release_version and short: return f"{release_version}-dev+g{short}" if release_version: @@ -123,13 +162,15 @@ def build_identity(*, release: bool = False, built_at: str | None = None, """构建身份 + 证据缺失项。`release=True` 但证据不齐 ⇒ 身份退回 development 并给出原因。""" rel = current_version(root / "CHANGELOG.md") sha = sha if sha is not None else git_sha(root) + n = commits_since_release(rel, root) problems = release_evidence(rel, sha, root) if release else [] kind = "release" if (release and not problems) else "development" ident = { "release_version": rel, "build_kind": kind, "commit_sha": sha, - "display_version": display_version(rel, kind, sha), + "commits_since_release": n, + "display_version": display_version(rel, kind, sha, n), "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"), } @@ -230,11 +271,29 @@ def self_test() -> int: case("声称版本与提交内记录不一致 ⇒ 不标正式", ident6["build_kind"] == "development" and any("不一致" in x for x in blocked6), f"{blocked6}") - # ⑦ 开发构建:有基线 ⇒ X.Y.Z-dev+g<短SHA>;无基线 ⇒ dev+g<短SHA> - dev, _ = build_identity(root=d, sha=sha, built_at="t") - case("开发构建(有基线)= 0.2.0-dev+g<短SHA>", - dev["build_kind"] == "development" and dev["display_version"] == f"0.2.0-dev+g{sha[:12]}", - f"{dev['display_version']!r}") + # ⑥b 开发号随提交递增:临时仓库里打 tag 后再加 2 个提交 ⇒ 0.1.2;恰在 tag 上 ⇒ 0.1.0 + d8, _, env8 = _tmp_repo(); dirs.append(d8) + subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release 0.2.0"], cwd=d8, env=env8, check=True) + at_tag, _ = build_identity(root=d8, built_at="t") + case("恰在发布提交上 ⇒ 显示正式版本号 0.2.0", + at_tag["display_version"] == "0.2.0" and at_tag["commits_since_release"] == 0, f"{at_tag}") + for i in (1, 2): + (d8 / f"f{i}.txt").write_text("x", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=d8, env=env8, check=True) + subprocess.run(["git", "commit", "-q", "-m", f"chore: 第 {i} 个提交"], cwd=d8, env=env8, check=True) + after, _ = build_identity(root=d8, built_at="t") + case("tag 之后第 2 个提交 ⇒ 开发号 0.2.2(随提交递增)", + after["display_version"] == "0.2.2" and after["commits_since_release"] == 2, f"{after}") + case("tag 之后不再等于正式版本号(不许只显示上一正式号)", + after["display_version"] != after["release_version"], f"{after}") + + # ⑦ 开发构建(取不到 tag 时):有基线 ⇒ X.Y.Z-dev+g<短SHA>;无基线 ⇒ dev+g<短SHA> + # 有基线但**一个 tag 都没有** ⇒ 退回 `<版本>-dev+g<短SHA>`(不猜号) + d9, sha9, _env9 = _tmp_repo(); dirs.append(d9) + dev, _ = build_identity(root=d9, sha=sha9, built_at="t") + case("有基线但取不到 tag = 0.2.0-dev+g<短SHA>(不猜号)", + dev["build_kind"] == "development" and dev["commits_since_release"] is None + and dev["display_version"] == f"0.2.0-dev+g{sha9[:12]}", f"{dev}") d7, sha7, _ = _tmp_repo("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- x\n"); dirs.append(d7) dev7, _ = build_identity(root=d7, sha=sha7, built_at="t") case("开发构建(无基线)= dev+g<短SHA>", @@ -251,7 +310,8 @@ def self_test() -> int: return 1 print("✅ 构建身份自检通过:证据齐(注释 tag + 完整 SHA + 提交内版本一致)才是正式构建;" "无 tag / unknown 或短 SHA / tag 指错提交 / 轻量 tag / 版本不一致 逐条拒绝;" - "开发构建带显式 dev 标识") + "开发号随提交递增(tag 后第 2 个提交 = 0.2.2,恰在发布提交上 = 0.2.0)," + "取不到 tag / 浅克隆时不猜号(退回 dev+g<短SHA>)") return 0