Files
knowledge-governance/scripts/version.py
T
姚章浩 7a9d51cc70 fix(version): 正式构建核证据 + 预检分"建议/就绪" + CI 就绪按必需 context
按要求补三项(本轮不推、不打真实 tag、不部署;全部用临时仓库与假对象验):

1) 正式构建身份不能只认 --release:
   - 新增 release_evidence():核①完整 40 位 SHA ②**指向该提交的注释 tag** v<版本号>
     ③该提交里 CHANGELOG 顶部版本一致。缺任一条 ⇒ rc=2,身份退回 development 并把
     release_blocked 的原因写进 JSON(给机器读,也给人看)。
   - version.py --self-test:六条负例(有版本无 tag / unknown SHA / 短 SHA / tag 指错提交 /
     轻量 tag / 版本不一致)+ 证据齐正例,**tag 全部在 mktemp 临时仓库里创建**。
   - Go 侧新增 TestReleaseBuildRequiresEvidence:release 构建必须 CommitSHA 完整且
     v<ReleaseVersion> 是**注释 tag** 并指向该提交(独立于 Python 的二次复核)。

2) 发布预检区分"准备建议"与"发布就绪":
   - 新增纯函数 readiness(facts) → (是否就绪, 阻塞项, 建议);输出分两段。
   - **就绪要求候选版本已写进目标提交**(version_in_commit:查 git show <sha>:CHANGELOG.md,
     工作区写了没提交不算);首发按**显式指定版本**处理,**不把"记录里有这个版本"当已发布**
     (已发布的证据是 tag,不是记录条目)。
   - 预检**不执行、也不在输出里推荐自动发布路径**(正式发版由人按 docs/RELEASE_RUNBOOK.md)。

3) CI 就绪判据:必需 context 集合来自 .gitea/required-contexts.txt(缺失/为空 ⇒ 没有策略 ⇒
   阻断,不用观察到的状态顶替);**全部 success 才通过**,失败/缺项/pending/查询异常一律阻断。
   Release 查询加 release_state():**只有 HTTP 404 才算不存在**,403/5xx/超时算查询异常 ⇒ 阻断。

验证:version.py --self-test 绿;release_preflight --self-test 绿(就绪判定 19 个分支 +
临时仓库验证 git show 语义 + 五个 HTTP 码语义);go test 三态(未注入 SKIP / 开发 PASS /
自称正式 RED);反向夹具 83/0(含临时仓库 go test 六例、预检"记录未进提交"两态);
make verify 15/15 success。
2026-10-13 15:26:49 +08:00

297 lines
14 KiB
Python
Executable File
Raw 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` 说明缺什么)。
构建身份字段(`--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
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 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 _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)
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),
"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}")
# ⑦ 开发构建:有基线 ⇒ 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}")
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 / 版本不一致 逐条拒绝;"
"开发构建带显式 dev 标识")
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:]))