Files
knowledge-governance/scripts/check_changelog.py
T
姚章浩 5f3a71cade
build / build (push) Successful in 26s
feat(version): 版本号唯一来源 = CHANGELOG.md,并要求写清改了什么与怎么验收
背景:本仓此前没有任何版本/构建元信息 —— 没有 ldflags 注入、也没有产物可读的
版本号,"线上跑的是哪个提交"答不出来;"这次改了什么、怎么验收"也没有载体。

落地:
- CHANGELOG.md:**版本号的唯一来源**(正本 §6.1:版本号只准有一处)。刻意不放
  VERSION 文件、也不靠 git tag —— CI 是 `git fetch --depth 50`,取不到 tag,
  `git describe` 会静默退化。每个已发布版本必须有两节:### 改了什么 与 ### 怎么验收。
- scripts/version.py:从 CHANGELOG 顶部解析版本号;解析不到即非 0 退出(构建据此失败)。
- scripts/check_changelog.py:结构自检(两节齐、带日期、版本号不重复且严格递减、
  小节不为空),带 --self-test 反向自检。
- internal/version:三个构建期注入变量 + 契约测试(注入的号必须等于 CHANGELOG 顶部)。
- Makefile:build 带 -ldflags 注入;verify 增加 changelog-check 与 version-contract。
- Dockerfile:ARG VERSION/COMMIT_SHA/BUILD_TIME + ldflags(未透传时落 dev —— 故意显眼,
  好过看起来正常的假版本号)。
- CI:新增「版本与变更记录契约」步,并显式断言契约测试真的 PASS(被跳过的断言等于没有断言)。
- docs/提交规范.md(ci_gate.py 的报错文案一直在指它)、docs/agents/traps.md(7 条实测陷阱)。

验证(都真跑过):
- make version → 0.1.0;make changelog-check → 合规
- make version-contract → PASS;注入 9.9.9(CHANGELOG 里没有)→ FAIL(反向自检)
- 删掉真实 CHANGELOG 的「怎么验收」小节 → 报红(对真实文件做,不只是夹具)
- go build ./... 与 go vet ./internal/version/... 通过

未做(会与在审 PR #2 冲突,待其合并后在 main 上补):
- GET /version 端点(internal/httpapi/server.go)与 cmd/api 的 -version 开关
- compose.yaml 透传 build args(当前由 compose 构建的镜像会报 dev)

⚠️ 未跑 make verify 全量(含 go test ./... 与前端),CI 是本次的测试证据。
2026-10-10 18:13:24 +08:00

168 lines
5.5 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 结构自检 —— 把"版本变更要写清改了什么、怎么验收"变成退出码。
判据(每条都能被 --self-test 证明会红):
1. 至少有一个已发布版本条目;
2. 每个已发布版本都带日期(`## [x.y.z] - YYYY-MM-DD`);`[Unreleased]` 段免检日期;
3. 每个已发布版本块里**必须有** `### 改了什么` 与 `### 怎么验收` 两节;
4. 版本号不重复,且按出现顺序**严格递减**(最新在最上面);
5. 已发布版本块本身不为空(两节下面得有内容,不能只留个标题)。
用法:
python3 scripts/check-changelog.py # 检查仓库根的 CHANGELOG.md
python3 scripts/check-changelog.py <path> # 检查指定文件
python3 scripts/check-changelog.py --self-test # 反向自检:造正/反夹具断言会红会绿
为什么要有 --self-test:一个永远返回 0 的校验器比没有校验更糟 —— 它给人一种
已经检查过的错觉(正本 §0-2)。
"""
from __future__ import annotations
import re
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
HEADING = re.compile(r"^##\s*\[([^\]]+)\](\s*-\s*(\d{4}-\d{2}-\d{2}))?\s*$", re.MULTILINE)
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
SEC_CHANGE = "### 改了什么"
SEC_ACCEPT = "### 怎么验收"
def _blocks(text: str):
"""把文件切成 [(heading, version, date, body), ...],保持出现顺序。"""
out = []
matches = list(HEADING.finditer(text))
for i, m in enumerate(matches):
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
out.append((m.group(0), m.group(1), m.group(3), text[m.end():end]))
return out
def check(path: Path | None = None) -> list[str]:
path = path or (ROOT / "CHANGELOG.md")
if not path.is_file():
return [f"找不到 {path}"]
blocks = _blocks(path.read_text(encoding="utf-8"))
released = [b for b in blocks if SEMVER.match(b[1])]
if not released:
return ["没有已发布版本条目(形如 `## [0.1.0] - 2026-10-10`)"]
problems: list[str] = []
seen: list[str] = []
for heading, ver, date, body in released:
seen.append(ver)
if not date:
problems.append(f"{heading} 缺少日期(要写成 `## [{ver}] - YYYY-MM-DD`)")
for sec in (SEC_CHANGE, SEC_ACCEPT):
if sec not in body:
problems.append(f"[{ver}] 缺少小节 `{sec}` —— 版本条目必须说明改了什么、怎么验收")
# 两节下面无内容:只剩标题或空行
for sec in (SEC_CHANGE, SEC_ACCEPT):
if sec in body:
tail = body.split(sec, 1)[1]
tail = re.split(r"^###\s", tail, maxsplit=1, flags=re.MULTILINE)[0]
if not [l for l in tail.splitlines() if l.strip() and not l.strip().startswith("```")][:1]:
problems.append(f"[{ver}] 的 `{sec}` 下面是空的")
dupes = {v for v in seen if seen.count(v) > 1}
if dupes:
problems.append(f"版本号重复:{sorted(dupes)}")
nums = [tuple(int(x) for x in v.split(".")) for v in seen]
if nums != sorted(nums, reverse=True):
problems.append(f"版本号没有按出现顺序严格递减(最新应在最上面):{seen}")
return problems
GOOD = """# 变更记录
## [Unreleased]
### 改了什么
- 还没发布的东西
## [0.2.0] - 2026-02-01
### 改了什么
- 加了 X
### 怎么验收
1. 跑 `curl /health` 期望 200
## [0.1.0] - 2026-01-01
### 改了什么
- 初版
### 怎么验收
1. 起容器,打开首页
"""
BAD = """# 变更记录
## [0.1.0]
### 改了什么
- 没有日期、没有怎么验收、而且版本顺序还倒着
## [0.2.0] - 2026-02-01
### 改了什么
- 上面那个比这个旧,顺序错了
"""
def self_test() -> int:
problems: list[str] = []
with tempfile.TemporaryDirectory() as d:
g = Path(d) / "good.md"
b = Path(d) / "bad.md"
g.write_text(GOOD, encoding="utf-8")
b.write_text(BAD, encoding="utf-8")
gk = check(g)
bk = check(b)
if gk:
problems.append(f"合规夹具竟然报红:{gk}")
want = ["缺少日期", f"缺少小节 `{SEC_ACCEPT}`", "严格递减"]
joined = " / ".join(bk)
for w in want:
if w not in joined:
problems.append(f"违规夹具没有报出 `{w}` —— 这条判据是死的")
if not bk:
problems.append("违规夹具完全没报红")
if problems:
print("❌ 反向自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 反向自检通过:合规夹具全绿;违规夹具按预期报出日期/验收节/顺序三类问题")
return 0
def main(argv: list[str]) -> int:
if "--self-test" in argv:
return self_test()
target = next((a for a in argv if not a.startswith("-")), None)
problems = check(Path(target) if target else None)
if problems:
print("❌ CHANGELOG.md 结构不合规:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
print("\n每个已发布版本都要写成:\n"
" ## [x.y.z] - YYYY-MM-DD\n\n ### 改了什么\n - …\n\n ### 怎么验收\n 1. 命令 → 期望现象\n",
file=sys.stderr)
return 1
print("✅ CHANGELOG.md 结构合规")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))