#!/usr/bin/env python3 """CHANGELOG / 版本记录检查 —— 把"版本规则"变成退出码(正本 §6)。 判据(每条都能被 --self-test 证明会红): A. 结构(默认就跑) 1. `[Unreleased]` 段若有,必须在最上面;**只有 `[Unreleased]`、还没有任何正式版本**是合法的 (开发期常态,不许因此判红); 2. 已发布版本标题 `## [x.y.z] - YYYY-MM-DD`:三段十进制、**不许前导零**、**日期必备且必须是真日期**; 3. 小节**只认四类** `Added` / `Changed` / `Fixed` / `Security`: **分类可省略**(不要求每个提交都写),但**不保留空栏目** —— 写了就得有内容; 每个版本块至少有一个非空分类(整块空白 ⇒ 红); 4. **破坏性变化**写在 `### Changed` 里、以 `**破坏性**` 开头,且必须**链接发布文档**; 仓内相对链接(`docs/….md`)会被**对账**:文件不存在 ⇒ 红(防"链接到不存在的 runbook"); 5. 版本号不重复、按出现顺序**严格递减**(最新在最上面);已发布版本块本身不为空。 B. tag 一致性(`--tags` / CI 里跑) 6. 正式 tag 形如 `v<版本号>`,且必须是**注释 tag**(轻量 tag 不合格); 7. 每个正式 tag **指到的那个提交里**的 CHANGELOG 顶部已发布版本 == 该 tag 的版本 (防止"tag 指着的提交从没被记进变更记录");**用那个提交的内容核对,不用当前 HEAD**; 8. **没有 tag 是正常的**(开发版本不打 tag 绝不能让日常 CI 变红); 9. **没核对远端就不许说"校验成功"**:本地模式明确打印「未核对远端」; CI 用 `--require-remote`(或设 `CI=true`)⇒ 拿不到远端 tag 清单**直接判红**。 用法: python3 scripts/check_changelog.py # 只查结构 python3 scripts/check_changelog.py --tags # 结构 + tag 一致性 python3 scripts/check_changelog.py --tags --require-remote # CI 用:远端清单缺失即红 python3 scripts/check_changelog.py --self-test # 反向自检(含造真仓库的 tag 夹具) """ from __future__ import annotations import datetime as dt import os import re import subprocess 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*$") SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") # 不许前导零(01.2.3 不合格) TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$") SEC_H = re.compile(r"^###\s+(.+?)\s*$") CATEGORIES = ("Added", "Changed", "Fixed", "Security") BREAKING = "**破坏性**" # 破坏性变化条目:允许列表项前缀(`- **破坏性**:…` / `1. **破坏性**:…`) BREAK_LINE = re.compile(r"^\s*(?:[-*+]|\d+[.)])?\s*\*\*破坏性\*\*") REMOTE_TAGS = ROOT / ".ci-remote-tags" LINK = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)") # ── 结构解析(**围栏代码块内的 `##`/`###` 不算标题**)───────────────────── def _blocks(text: str): """版本块:每块的正文到**下一个二级标题**为止(`### x` 不算)。 ⚠️ 边界必须是**任意** `## ` 标题,不只是 `## [x.y.z]`:像「## 能力基线(未发布)」这种 不带方括号的二级标题若不当边界,它的正文会被算进上一个版本块 ⇒ 让"空栏目"看起来有内容 (夹具真的抓到过这个假绿)。 """ lines = text.splitlines() fence = None heads: list[tuple[int, bool, str, str | None]] = [] for i, line in enumerate(lines): s = line.strip() if fence is None and s.startswith("```"): fence = "```" continue if fence is not None: if s.startswith("```"): fence = None continue if not re.match(r"^##\s", line): # `### x` 不匹配:`##` 后面必须是空白 continue m = HEADING.match(line) heads.append((i, bool(m), m.group(1) if m else "", m.group(3) if m else None)) out = [] for k, (i, is_version, label, date) in enumerate(heads): end = heads[k + 1][0] if k + 1 < len(heads) else len(lines) if is_version: # 不带版本号的二级标题只当边界,本身不校验 out.append((lines[i], label, date, lines[i + 1:end])) return out def _sections(body_lines: list[str]): """返回 {小节名: 内容行} 与出现顺序。围栏内的行归入当前小节(不算标题)。""" out: dict[str, list[str]] = {} order: list[str] = [] cur = "" fence = False for line in body_lines: s = line.strip() if s.startswith("```"): fence = not fence if cur: out[cur].append(line) continue m = None if fence else SEC_H.match(line) if m: cur = m.group(1).strip() out.setdefault(cur, []) order.append(cur) continue if cur: out[cur].append(line) return out, order def _nonempty(lines: list[str]) -> bool: return any(l.strip() and not l.strip().startswith("```") for l in lines) def _date_ok(s: str) -> bool: try: dt.date.fromisoformat(s) except ValueError: return False return True def _doc_link_problem(line: str, root: Path) -> str | None: """破坏性变化那行必须带一个能用的发布文档链接;返回问题描述(None = 没问题)。""" targets = LINK.findall(line) if not targets: return "没有链接(要指向发布文档,写清升级/迁移步骤)" for t in targets: clean = t.split("#", 1)[0].split("?", 1)[0] if clean.startswith(("http://", "https://")): return None if not clean.endswith(".md"): continue if (root / clean).is_file(): return None return f"链接的发布文档不存在:{clean}" return f"链接目标不是文档(要 `docs/….md` 或 https 链接):{targets[0]}" def check(path: Path | None = None, root: Path | None = None) -> list[str]: path = path or (ROOT / "CHANGELOG.md") root = root or ROOT if not path.is_file(): return [f"找不到 {path}"] blocks = _blocks(path.read_text(encoding="utf-8")) released = [b for b in blocks if b[1] != "Unreleased"] if not released and not any(b[1] == "Unreleased" for b in blocks): return ["没有版本记录:既没有已发布版本条目(`## [0.1.0] - 2026-10-10`),也没有 `[Unreleased]` 段"] problems: list[str] = [] if any(b[1] == "Unreleased" for b in blocks) and blocks[0][1] != "Unreleased": problems.append("[Unreleased] 段必须在最上面(日常改动先记这里,发版时把它改成版本号)") seen: list[str] = [] for heading, label, date, body in blocks: secs, order = _sections(body) unknown = [s for s in order if s not in CATEGORIES] if unknown: problems.append(f"{heading} 有小节不属于允许的四类({'/'.join(CATEGORIES)}):{unknown}" f"(分类可省略,但不能自创栏目)") empty = [s for s in order if s in CATEGORIES and not _nonempty(secs[s])] if empty: problems.append(f"{heading} 的 {empty} 是空栏目(写了就得有内容;不需要就整节删掉)") if not [s for s in order if s in CATEGORIES and _nonempty(secs[s])]: problems.append(f"{heading} 没有任何有内容的分类(至少写一条使用者可见的变化)") for name in order: if name != "Changed" and any(BREAK_LINE.match(l) for l in secs.get(name, [])): problems.append(f"{heading} 的 `{name}` 里有破坏性变化条目 —— " f"破坏性变化必须写在 `### Changed` 里(位置固定,便于发版前扫出来)") for line in secs.get("Changed", []): if BREAK_LINE.match(line): bad = _doc_link_problem(line, root) if bad: problems.append(f"{heading} 的破坏性变化不合规:{bad}") if label == "Unreleased": if date: problems.append(f"{heading} 是未发布段,不该写日期") continue if not SEMVER.match(label): problems.append(f"{heading} 的版本号不合法(要 `主.次.修订` 三段十进制、不许前导零):{label!r}") continue seen.append(label) if not date: problems.append(f"{heading} 缺少日期(要写成 `## [{label}] - YYYY-MM-DD`)") elif not _date_ok(date): problems.append(f"{heading} 的日期不是合法日期:{date!r}") 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 # ── tag 一致性 ─────────────────────────────────────────────────────────── def _git(*args, cwd=None) -> tuple[int, str]: p = subprocess.run(["git", *args], cwd=cwd or ROOT, capture_output=True, text=True) return p.returncode, (p.stdout if p.returncode == 0 else p.stderr) def remote_tag_lines(cwd: Path | None = None) -> tuple[list[str], str, bool]: """tag 清单,每行「对象 sha + refs/tags/<名>」。 返回 (行, 来源说明, 是否核对过远端)。顺序: ① 检出步骤写的 `.ci-remote-tags`(CI,能证明远端有哪些 tag); ② `git ls-remote --tags origin`(真查远端 → 算核对过); ③ 本地 `for-each-ref refs/tags`(**没核对远端**)。 """ cwd = cwd or ROOT f = cwd / ".ci-remote-tags" if f.is_file(): return [l for l in f.read_text(encoding="utf-8").splitlines() if l.strip()], \ f"远端清单 {f.name}(检出步骤写入)", True p = subprocess.run(["git", "ls-remote", "--tags", "origin"], cwd=cwd, capture_output=True, text=True) if p.returncode == 0: return [l for l in p.stdout.splitlines() if l.strip()], "git ls-remote --tags origin(本机实时查远端)", True q = subprocess.run(["git", "for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"], cwd=cwd, capture_output=True, text=True) if q.returncode == 0: return [l for l in q.stdout.splitlines() if l.strip()], "本地 refs/tags(**未核对远端**)", False return [], "拿不到任何 tag 信息(本机没有 tag,也查不了远端)", False def check_tags(changelog: Path | None = None, *, require_remote: bool = False, cwd: Path | None = None) -> list[str]: """正式 tag 的版本必须与**它指到的那个提交里**的版本记录一致。没有 tag ⇒ 不是问题。""" cwd = cwd or ROOT changelog = changelog or (cwd / "CHANGELOG.md") problems: list[str] = [] lines, source, consulted = remote_tag_lines(cwd) formal, notes = [], [] for line in lines: parts = line.split() if len(parts) < 2: continue sha, ref = parts[0], parts[-1] name = ref.split("refs/tags/", 1)[-1] if name.endswith("^{}"): # annotated tag 的解引用行,跳过 continue m = TAG_RE.match(name) (formal if m else notes).append((name, sha, m.group(1) if m else "")) print(f"-- tag 核对:来源 {source}|{'已核对远端' if consulted else '⚠️ 未核对远端'}" f"|正式 tag {len(formal)} 个" f"{'|非正式(不当作发布版本、不判版本一致性):' + ', '.join(n for n, _, _ in notes) if notes else ''}") if not consulted: msg = ("没核对远端 tag 信息 ⇒ 不能声称 tag 校验成功" "(CI 应传入 `.ci-remote-tags`,或让 `git ls-remote --tags origin` 可用)") if require_remote: problems.append(msg) else: print(f" ⚠️ {msg}(本机模式:只报事实,不判红)") for name, sha, ver in formal: rc, commit = _git("rev-parse", sha + "^{commit}", cwd=cwd) if rc != 0 or not commit.strip(): problems.append(f"tag {name}({sha[:12]})指向的提交在本机取不到 —— " f"没法核对它的版本记录(检出步骤应当把 tag 取下来)") continue commit = commit.strip() rc, kind = _git("cat-file", "-t", sha, cwd=cwd) if rc == 0 and kind.strip() == "commit": problems.append(f"tag {name} 是**轻量 tag**(不是注释 tag)—— 正式发布必须用注释 tag") rc, blob = _git("show", commit + ":CHANGELOG.md", cwd=cwd) if rc != 0: problems.append(f"tag {name} 指到的提交里没有 CHANGELOG.md") continue tops = [b[1] for b in _blocks(blob) if b[1] != "Unreleased" and SEMVER.match(b[1])] if not tops: problems.append(f"tag {name} 指到的提交里 CHANGELOG.md 没有已发布版本条目") elif tops[0] != ver: problems.append(f"tag {name} 的版本 {ver} != 该提交里 CHANGELOG 顶部的版本 {tops[0]}" f"(tag 指着的提交没有把这次发版记进变更记录)") else: print(f" ✅ {name} → {commit[:12]}:提交内顶部版本 {tops[0]} 与 tag 一致") if not formal: print(" (没有正式 tag —— 开发版本不打 tag 是正常的,不判红)") return problems # ── 自检夹具 ───────────────────────────────────────────────────────────── GOOD = """# Changelog ## [Unreleased] ### Added - 新东西 ## [0.2.0] - 2026-02-01 ### Changed - 调整了 X 的行为 - **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md) ### Fixed - 修了 Y ## [0.1.0] - 2026-01-01 ### Added - 初版 """ BAD = """# Changelog ## [0.1.0] - 2026-02-30 ### Added - 日期不存在,还引了不存在的文档 - **破坏性**:删掉了旧接口,见 [说明](docs/NO_SUCH_DOC.md)(这里破坏性写在 Added 里,也要报) ### 已知问题 - 自创栏目 ## [0.2.0] - 2026-02-01 ### Changed - 顺序倒置(这条比上面那条大却排在下面) - **破坏性**:改了配置格式(没链接) ## [0.2.0] - 2026-03-01 ### Changed - **破坏性**:删掉了 `/old` 接口,升级步骤见 [说明](docs/NO_SUCH_DOC.md) ### Fixed ## [01.0.0] - 2026-03-02 ### Fixed - 前导零 """ def _seed_repo(d: Path, changelog_text: str, tag: str, annotated: bool) -> str: """造一个最小 git 仓库:一个提交 + 一个 tag(指向该提交)。""" 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 / "no-hooks").mkdir(exist_ok=True) subprocess.run(["git", "init", "-q", "-b", "main"], cwd=d, env=env, check=True) (d / "CHANGELOG.md").write_text(changelog_text, encoding="utf-8") (d / "docs").mkdir(exist_ok=True) (d / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8") for args in (["add", "-A"], ["commit", "-q", "-m", "chore: 夹具提交"], ["tag", "-a", tag, "-m", f"release {tag}"] if annotated else ["tag", tag]): subprocess.run(["git", *args], cwd=d, env=env, check=True) return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True, text=True).stdout.strip() def self_test() -> int: problems: list[str] = [] with tempfile.TemporaryDirectory() as d: dd = Path(d) (dd / "docs").mkdir() (dd / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8") g, b = dd / "good.md", dd / "bad.md" g.write_text(GOOD, encoding="utf-8") b.write_text(BAD, encoding="utf-8") got = check(g, root=dd) if got: problems.append(f"合规夹具竟然报红:{got}") # 开发期:只有 Unreleased(还没有任何正式版本)必须放行 (dd / "new.md").write_text("# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 还没发布\n", encoding="utf-8") if check(dd / "new.md", root=dd): problems.append(f"只有 Unreleased 的新工程竟然报红:{check(dd / 'new.md', root=dd)}") # 完全没有版本记录 ⇒ 红 (dd / "none.md").write_text("# Changelog\n\n(什么都没有)\n", encoding="utf-8") if not check(dd / "none.md", root=dd): problems.append("完全没有版本记录的文件竟然没报红") # 破坏性变化写在非 Changed 分类里 ⇒ 也要红(位置固定) (dd / "wrongsec.md").write_text( "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- **破坏性**:删了接口(见 " "[说明](docs/RELEASE_RUNBOOK.md))\n", encoding="utf-8") if not check(dd / "wrongsec.md", root=dd): problems.append("破坏性变化写在 Added 里竟然没报红(位置必须固定在 Changed)") # 末尾跟一个"不带版本号的二级标题":上一节是空的就必须判红(不能被那段文字兜成"有内容") (dd / "tailh2.md").write_text( "# Changelog\n\n## [Unreleased]\n\n### Fixed\n\n### Security\n\n" "## 能力基线(未发布)\n\n- 这段文字不属于 Security 节\n", encoding="utf-8") tail_got = " / ".join(check(dd / "tailh2.md", root=dd)) if "是空栏目" not in tail_got: problems.append(f"空的最后一节被后面的二级标题兜成了『有内容』:{tail_got[:200]}") # 围栏代码块里的 `###`/`##` 不算标题 (dd / "fence.md").write_text( "# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 命令示例:\n\n```bash\n## [9.9.9] - 1999-01-01\n### Changed\n```\n", encoding="utf-8") if check(dd / "fence.md", root=dd): problems.append(f"围栏里的假标题被当成了真结构:{check(dd / 'fence.md', root=dd)}") joined = " / ".join(check(b, root=dd)) for want in ["日期不是合法日期", "不属于允许的四类", "是空栏目", "版本号重复", "严格递减", "版本号不合法", "破坏性变化不合规"]: if want not in joined: problems.append(f"违规夹具没有报出 `{want}` —— 这条判据是死的(实际:{joined[:200]})") for want in ["链接的发布文档不存在", "没有链接", "位置固定"]: if want not in joined: problems.append(f"破坏性变化的判据没有报出 `{want}`(实际:{joined[:300]})") # ── tag 一致性(造真仓库)── with tempfile.TemporaryDirectory() as d: dd = Path(d) ok = dd / "ok" ok.mkdir() _seed_repo(ok, GOOD, "v0.2.0", annotated=True) if check_tags(ok / "CHANGELOG.md", cwd=ok): problems.append(f"tag 一致时竟然报红:{check_tags(ok / 'CHANGELOG.md', cwd=ok)}") bad = dd / "bad" bad.mkdir() _seed_repo(bad, GOOD, "v9.9.9", annotated=True) if not any("!=" in p for p in check_tags(bad / "CHANGELOG.md", cwd=bad)): problems.append("tag 版本与提交内版本不一致时没有判红") light = dd / "light" light.mkdir() _seed_repo(light, GOOD, "v0.2.0", annotated=False) if not any("轻量 tag" in p for p in check_tags(light / "CHANGELOG.md", cwd=light)): problems.append("轻量 tag 没有判红") none = dd / "none" none.mkdir() _seed_repo(none, GOOD, "v0.2.0", annotated=True) subprocess.run(["git", "tag", "-d", "v0.2.0"], cwd=none, capture_output=True) if check_tags(none / "CHANGELOG.md", cwd=none): problems.append("没有 tag 时竟然报红(开发版本不打 tag 是正常的)") # 非正式 tag(如本地 `x`)不当作发布版本、不判红 note = dd / "note" note.mkdir() _seed_repo(note, GOOD, "x", annotated=False) if check_tags(note / "CHANGELOG.md", cwd=note): problems.append("非正式 tag 竟然报红(它不该被当成发布版本)") # 没核对远端 ⇒ 本地模式只提示;`--require-remote` 模式必须判红 local = dd / "local" local.mkdir() _seed_repo(local, GOOD, "v0.2.0", annotated=True) subprocess.run(["git", "remote", "add", "origin", str(dd / "nowhere.git")], cwd=local, capture_output=True) if check_tags(local / "CHANGELOG.md", cwd=local): problems.append("本地模式(未核对远端)不该判红,只该提示") if not any("没核对远端" in p for p in check_tags(local / "CHANGELOG.md", cwd=local, require_remote=True)): problems.append("--require-remote 时没核对远端竟然没判红(不许声称校验成功)") if problems: print("❌ 反向自检失败:", file=sys.stderr) for p in problems: print(f" · {p}", file=sys.stderr) return 1 print("✅ 反向自检通过:合规夹具全绿;非法日期/自创栏目/空栏目/重复/顺序倒置/前导零/" "破坏性无链接或链接不存在/轻量 tag/tag 版本不符/未核对远端(--require-remote) 逐条报红;" "只有 Unreleased、无 tag、非正式 tag 不报红") 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) path = Path(target) if target else None require_remote = "--require-remote" in argv or os.environ.get("CI") == "true" problems = check(path) if "--tags" in argv or "--tag-check" in argv: problems += check_tags(path, require_remote=require_remote) if problems: print("❌ CHANGELOG / 版本记录不合规:", file=sys.stderr) for p in problems: print(f" · {p}", file=sys.stderr) print("\n小节只认四类(分类可省略,但不要空栏目):\n" " ### Added / ### Changed / ### Fixed / ### Security\n" "破坏性变化写在 `### Changed` 里,以 `**破坏性**` 开头并链接发布文档:\n" " - **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md)\n" "正式版本条目:`## [x.y.z] - YYYY-MM-DD`\n", file=sys.stderr) return 1 print("✅ CHANGELOG / 版本记录合规" + ("(含 tag 一致性)" if ("--tags" in argv or "--tag-check" in argv) else "")) return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))