docs: 开发号随提交自动递增(口径变更)

- **开发版本号自动递增**:`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。
This commit is contained in:
t
2026-10-13 16:45:56 +08:00
parent cdf9c14901
commit 041f18bfd2
6 changed files with 162 additions and 17 deletions
+21 -1
View File
@@ -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<release_version>` 以来的提交数(无 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=<n>`;`Makefile` 的 `COMMITS_SINCE_RELEASE`;`Dockerfile` 的同名 build-arg。
契约测试会**交叉核对**:注入进来的提交数必须等于 git 算出的(防注入错号)。
⚠️ **下一次正式发布请把次版本加一**(`0.1.x` 之后发 `0.2.0`),保证正式号严格大于此前所有开发号、不撞号;
发版预检会挡住"候选 <= 当前开发号"的候选。
### 6.3 条目形状:固定四类小节(**不写过程记录**)
+13
View File
@@ -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 两态 |
+8 -3
View File
@@ -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-*` 仅保留参数兼容,传了会被
+5 -2
View File
@@ -128,8 +128,11 @@ python3 ~/.hermes/scripts/gitea_api.py GET "/api/v1/repos/<ns>/<repo>/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 负例)。
+46 -2
View File
@@ -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 调用")
+69 -9
View File
@@ -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<release_version>` 以来的提交数(没有 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<release_version>` 那个 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>`;`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