#!/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) **正式构建身份不是"加个 --release 标志"就算**:`--identity --release` 会**核证据**—— 完整 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`(正式)/ `<主.次>.<提交数>`(开发,随提交递增)/ `dev+g<短SHA>`(还没有正式基线)/ `dev`(连 SHA 都取不到) built_at 构建时间(`BUILT_AT` 环境变量,缺省取当前 UTC) commits_since_release 自 `v` 以来的提交数(没有 tag ⇒ null) ⚠️ 开发构建**不能**只显示上一正式版本号(那正是要修的问题),也不许自称正式版。 """ from __future__ import annotations import datetime as dt import json import os import re import subprocess import sys import tempfile 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 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: return f"{release_version}-dev" return f"dev+g{short}" if short else "dev" def _git(*args, cwd: Path = ROOT) -> tuple[int, str]: p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) return p.returncode, (p.stdout if p.returncode == 0 else p.stderr) def release_evidence(version: str | None, sha: str, root: Path = ROOT) -> list[str]: """正式构建要核的证据;返回缺失项(空列表 = 证据齐)。 三条都必需(缺一条就**不许**把产物标成正式版): 1. 完整 40 位提交 SHA —— 没有准确提交就谈不上"正式版"; 2. 该提交上有**注释 tag** `v<版本号>`(`git tag --points-at ` + `cat-file -t` 判类型); 3. 该提交里的 `CHANGELOG.md` 顶部已发布版本 == 这个版本号(版本一致性)。 """ problems: list[str] = [] if not version: problems.append("CHANGELOG.md 里没有已发布版本条目(没有正式版本基线)") if not FULL_SHA.match(sha or ""): problems.append(f"提交 SHA 不是完整 40 位({sha!r})—— 没有准确提交就不能标为正式版") if version and FULL_SHA.match(sha or ""): name = f"v{version}" rc, out = _git("tag", "--points-at", sha, cwd=root) tags = out.split() if rc == 0 else [] if name not in tags: problems.append(f"提交 {sha[:12]} 上没有 tag {name}" f"(正式构建必须有一个指向该提交的合规 tag;现有指向该提交的 tag:{tags or '无'})") else: rc, kind = _git("cat-file", "-t", f"refs/tags/{name}", cwd=root) if rc != 0 or kind.strip() != "tag": problems.append(f"tag {name} 不是**注释 tag**(`git tag -a`)—— 轻量 tag 不算发布证据") rc, blob = _git("show", f"{sha}:CHANGELOG.md", cwd=root) if rc != 0: problems.append(f"取不到该提交里的 CHANGELOG.md({sha[:12]})—— 没法核对版本一致性") else: m = RELEASE_HEADING.search(blob) top = m.group(1) if m else None if top != version: problems.append(f"该提交里 CHANGELOG 顶部的已发布版本是 {top!r},与 {version!r} 不一致") return problems def identity(*, release: bool = False, built_at: str | None = None, root: Path = ROOT, sha: str | None = None) -> dict: """构建身份(不含证据核验,供 `--identity` 的非发布路径与测试使用)。""" return build_identity(release=release, built_at=built_at, root=root, sha=sha)[0] def build_identity(*, release: bool = False, built_at: str | None = None, root: Path = ROOT, sha: str | None = None) -> tuple[dict, list[str]]: """构建身份 + 证据缺失项。`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, "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"), } if problems: ident["release_blocked"] = problems return ident, problems # ── 反向自检:**全部在临时仓库里造 tag**,不给真仓造任何 tag ───────────────── _SAMPLE = """# Changelog ## [Unreleased] ### Added - 还没发布 ## [0.2.0] - 2026-02-01 ### Fixed - y """ def _tmp_repo(changelog: str = _SAMPLE) -> tuple[Path, str, dict]: """造一个临时仓库(一个提交),返回 (目录, HEAD, git 环境)。""" d = Path(tempfile.mkdtemp(prefix="kg-ver-")) (d / "no-hooks").mkdir(exist_ok=True) 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 / "CHANGELOG.md").write_text(changelog, encoding="utf-8") for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具提交"]): subprocess.run(["git", *args], cwd=d, env=env, check=True) sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True, text=True).stdout.strip() return d, sha, env def self_test() -> int: problems: list[str] = [] dirs: list[Path] = [] def case(name: str, cond: bool, detail: str = "") -> None: if not cond: problems.append(f"{name} —— {detail}") # ① 证据齐:注释 tag 指向该提交 + 提交内 CHANGELOG 一致 ⇒ 正式构建 d, sha, env = _tmp_repo(); dirs.append(d) subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release 0.2.0"], cwd=d, env=env, check=True) ident, blocked = build_identity(release=True, root=d, sha=sha, built_at="t") case("证据齐 ⇒ build_kind=release", ident["build_kind"] == "release" and not blocked, f"{ident}") case("证据齐 ⇒ display_version=0.2.0", ident["display_version"] == "0.2.0", f"{ident['display_version']!r}") # ② 有版本但**没有 tag** ⇒ 不许标正式 d2, sha2, _ = _tmp_repo(); dirs.append(d2) ident2, blocked2 = build_identity(release=True, root=d2, sha=sha2, built_at="t") case("有版本但无 tag ⇒ 不标正式", ident2["build_kind"] == "development" and any("没有 tag" in x for x in blocked2), f"{blocked2}") # ③ SHA 不是完整 40 位 ⇒ 不许标正式 ident3, blocked3 = build_identity(release=True, root=d, sha="unknown", built_at="t") case("unknown SHA ⇒ 不标正式", ident3["build_kind"] == "development" and any("不是完整 40 位" in x for x in blocked3), f"{blocked3}") ident3b, blocked3b = build_identity(release=True, root=d, sha=sha[:12], built_at="t") case("短 SHA ⇒ 不标正式", ident3b["build_kind"] == "development" and any("不是完整 40 位" in x for x in blocked3b), f"{blocked3b}") # ④ tag 指错提交 ⇒ 不许标正式(tag 打在**后一个**提交上,却按前一个提交去问) d4, first4, env4 = _tmp_repo(); dirs.append(d4) (d4 / "extra.txt").write_text("x", encoding="utf-8") subprocess.run(["git", "add", "-A"], cwd=d4, env=env4, check=True) subprocess.run(["git", "commit", "-q", "-m", "chore: 第二个提交"], cwd=d4, env=env4, check=True) second4 = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d4, capture_output=True, text=True).stdout.strip() subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release", second4], cwd=d4, env=env4, check=True) ident4, blocked4 = build_identity(release=True, root=d4, sha=first4, built_at="t") case("tag 指错提交 ⇒ 不标正式", ident4["build_kind"] == "development" and any("没有 tag v0.2.0" in x for x in blocked4), f"{blocked4}") # ⑤ 轻量 tag ⇒ 不许标正式(注释 tag 才是发布证据) d5, sha5, env5 = _tmp_repo(); dirs.append(d5) subprocess.run(["git", "tag", "v0.2.0"], cwd=d5, env=env5, check=True) ident5, blocked5 = build_identity(release=True, root=d5, sha=sha5, built_at="t") case("轻量 tag ⇒ 不标正式", ident5["build_kind"] == "development" and any("注释 tag" in x for x in blocked5), f"{blocked5}") # ⑥ 提交内 CHANGELOG 顶部版本与"声称的版本"不一致 ⇒ 不许标正式 # 做法:提交里的记录是 0.2.0,tag 却叫 v0.3.0(指向该提交),工作区又写着 0.3.0 ⇒ 三条中 # "版本一致性"这一条会红(tag 存在、注释、SHA 也完整,问题只出在版本对不上)。 d6, sha6, env6 = _tmp_repo(); dirs.append(d6) subprocess.run(["git", "tag", "-a", "v0.3.0", "-m", "release 0.3.0"], cwd=d6, env=env6, check=True) (d6 / "CHANGELOG.md").write_text(_SAMPLE.replace("0.2.0", "0.3.0"), encoding="utf-8") ident6, blocked6 = build_identity(release=True, root=d6, sha=sha6, built_at="t") case("声称版本与提交内记录不一致 ⇒ 不标正式", ident6["build_kind"] == "development" and any("不一致" in x for x in blocked6), f"{blocked6}") # ⑥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>", dev7["release_version"] is None and dev7["display_version"] == f"dev+g{sha7[:12]}", f"{dev7}") for x in dirs: subprocess.run(["rm", "-rf", str(x)], capture_output=True) if problems: print("❌ 构建身份自检失败:", file=sys.stderr) for x in problems: print(f" · {x}", file=sys.stderr) return 1 print("✅ 构建身份自检通过:证据齐(注释 tag + 完整 SHA + 提交内版本一致)才是正式构建;" "无 tag / unknown 或短 SHA / tag 指错提交 / 轻量 tag / 版本不一致 逐条拒绝;" "开发号随提交递增(tag 后第 2 个提交 = 0.2.2,恰在发布提交上 = 0.2.0)," "取不到 tag / 浅克隆时不猜号(退回 dev+g<短SHA>)") return 0 def main(argv: list[str]) -> int: if "--self-test" in argv: return self_test() release = "--release" in argv or os.environ.get("BUILD_KIND") == "release" if "--identity" in argv: ident, problems = build_identity(release=release) print(json.dumps(ident, ensure_ascii=False, indent=2)) if release and problems: print("❌ 声明了正式构建(--release / BUILD_KIND=release),但发布证据不齐 —— " "**不标为正式版**(身份已退回 development):", file=sys.stderr) for x in problems: print(f" · {x}", file=sys.stderr) return 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:]))