Files
t 6de5d52363
build / build (push) Successful in 51s
feat(version): 开发号随提交自动递增 + 首个正式版本 0.1.0
按使用者要求定的版本规则(自动升的是**开发标识**,发布版本与 tag 仍由人批准):

- **开发号自动递增**:display_version(development) = `<正式版本的主.次>.<自 v<正式版本> 以来的提交数>`
  (正式 0.1.0 之后第 1 个提交 0.1.1、第 12 个 0.1.12;恰在发布提交上 = 正式号)。
  它**不写进 CHANGELOG**(发布记录只在发版时动),由 git 算出来 ⇒ 每个提交都有自己的号、数字只增。
- **浅克隆不猜号**:`--depth` 会把条数算少 ⇒ 浅仓库里拒绝算号,退回 `<版本>-dev+g<短SHA>`;
  检出步骤在仓库有 tag 时 `--unshallow` 补全历史。
- 接线三处齐全:`version.py --identity` → 门禁 `-X …CommitsSinceRelease=<n>`;Makefile 的
  `COMMITS_SINCE_RELEASE`;Dockerfile 同名 build-arg;Go 侧新增 `CommitsSinceRelease` 与
  DisplayVersion() 新规则;契约测试**交叉核对**"注入的提交数 == git 算出的"(防注入错号)。
- **首个正式版本 0.1.0**:CHANGELOG 的 `[Unreleased]` 提成 `## [0.1.0] - 2026-10-13`;
  头部补"版本怎么走"两行;RELEASE_RUNBOOK 补递增规则与"下一次发布把次版本加一"。
- 发版预检新增:打印**当前开发号**,并挡住"候选正式版本 <= 当前开发号"(会把开发号当正式号;建议次版本加一)。

验证:version.py --self-test(含 tag 后第 2 个提交 = 0.2.2、恰在 tag 上 = 0.2.0、无 tag 不猜号);
release_preflight --self-test(21 分支,含开发号两条);go test 本地三态 + 临时仓库 tag+2 提交实测
(Python 与 Go 都得出 0.2.2);反向夹具 85/0;make verify 15/15 success。
2026-10-13 16:45:56 +08:00

357 lines
18 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<release_version>` 以来的提交数(没有 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<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:
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 <sha>` + `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:]))