Files
knowledge-governance/scripts/version.py
T
姚章浩 05144309c3 feat(version): 构建身份(release_version/build_kind/display_version),无基线也放行
解决"开发构建沿用已发布版本号,分不清开发版与正式版":

- 新增 `scripts/version.py --identity`:release_version(无基线即空)/ build_kind /
  commit_sha(完整 40 位)/ display_version / built_at。显示规则:发布 `X.Y.Z`、
  开发 `X.Y.Z-dev+g<短SHA>`、无基线 `dev+g<短SHA>`、什么都拿不到 `dev`。
- `build_kind=release` 只认**显式发布上下文**(`BUILD_KIND=release` / `--release`);
  声明发布构建却没有已发布版本 ⇒ 拒绝(rc=2)。普通 main 构建永远是 development,
  **不许只显示上一正式版本号、不许自称正式版**。
- Go 侧 `internal/version` 加 ReleaseVersion/BuildKind/DisplayVersion()/IsRelease()/Info(),
  原 Number/CommitSHA/BuildTime 语义不变(向后兼容);契约测试改成:发布构建必须等于
  CHANGELOG 顶部、开发构建必须是 dev 形状;`make verify` 的 contracts 项新增
  **负例**「自称正式版 ⇒ 必须红」(只有正例的契约会被静默掏空)。
- 构建接线:Makefile/Dockerfile/ci_checks `--internal-build` 统一走 identity;
  Makefile 的 `make version` 打印身份 JSON;Dockerfile 新增 RELEASE_VERSION/BUILD_KIND。
- `tests-go` 的"按设计跳过"声明补上 `TestDevBuildDoesNotClaimRelease`(它的强制点在 contracts)。

验证:go test 三态实测(未注入 SKIP、开发身份 PASS、自称正式版 FAIL);
--internal-build 产出并打印 dev+g<sha>;--internal-identity 契约项 rc=0;
make verify 15/15 success;反向夹具 80/0。
2026-10-13 14:59:36 +08:00

122 lines
5.3 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)
构建身份字段(`--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
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 identity(*, release: bool = False, built_at: str | None = None,
root: Path = ROOT, sha: str | None = None) -> dict:
"""构建身份。`release=True` 但没有已发布版本 ⇒ 由调用方拒绝(见 main)。"""
rel = current_version(root / "CHANGELOG.md")
sha = sha if sha is not None else git_sha(root)
kind = "release" if (release and rel) else "development"
return {
"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"),
}
def main(argv: list[str]) -> int:
release = "--release" in argv or os.environ.get("BUILD_KIND") == "release"
if "--identity" in argv:
if release and current_version() is None:
print("❌ 声明了发布构建(--release / BUILD_KIND=release),但 CHANGELOG.md 里没有已发布版本条目。\n"
" 发布构建必须基于一个已发布的版本记录(`## [x.y.z] - YYYY-MM-DD`)——\n"
" 本仓还没有正式发布过,所以现在只能产出开发构建身份(dev+g<短SHA>)。",
file=sys.stderr)
return 2
print(json.dumps(identity(release=release), ensure_ascii=False, indent=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:]))