05144309c3
解决"开发构建沿用已发布版本号,分不清开发版与正式版": - 新增 `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。
122 lines
5.3 KiB
Python
Executable File
122 lines
5.3 KiB
Python
Executable File
#!/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:])) |