5f3a71cade
build / build (push) Successful in 26s
背景:本仓此前没有任何版本/构建元信息 —— 没有 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 是本次的测试证据。
168 lines
5.5 KiB
Python
Executable File
168 lines
5.5 KiB
Python
Executable File
#!/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:])) |