4f8ee0a8c5
按项目方给的规则重做 CHANGELOG 与检查器(本轮不推、不打 tag、不部署): - 条目形状:四个自定义必填节(改了什么/兼容性与迁移影响/怎么验收/已知问题) → 固定四类 Added/Changed/Fixed/Security;分类可省略,**不许空栏目**;每个版本块 至少一个非空分类;`[Unreleased]` 段不得写日期。 - 破坏性变化:固定写在 Changed 里、以 `**破坏性**` 开头,**必须链接发布文档**; 仓内 `docs/….md` 链接**对账**(不存在 ⇒ 红)。写在别的分类里也判红。 - 结构判据补强:非法日期、重复版本、顺序必须严格递减、前导零;围栏代码块里的 `##`/`###` 不算标题;**块正文以任意二级标题为界**(「## 能力基线(未发布)」这类 不带方括号的标题原先不是边界,会把后文算成上一节的内容 ⇒ 空栏目被兜成"有内容", 夹具真的抓到了这个假绿)。 - tag 一致性:远端专属清单 `.ci-remote-tags` → `ls-remote` → 本地 refs 三级来源; **没核对远端就不许说校验成功**(CI/预检用 `--require-remote`,拿不到清单即红), 非正式 tag(如本地 `x`)不当作发布版本、不判红,没有 tag 不判红。 - `0.1.0` 定为**未发布的能力基线**(远端无 tag、无 Release):改成不带方括号的 「## 能力基线(未发布)」,不补造 tag/发布日期/发布事实。 - 过程记录迁出:审查轮次与命令输出 → `docs/archive/CI_接入留档.md`(原文保留); 未合并事项 → `docs/BACKLOG.md`;补 `docs/RELEASE_RUNBOOK.md`(破坏性变化的链接目标, 同时修掉 CHANGELOG 里指向不存在文档的悬空引用)。 - 发版预检:无版本基线时把「首个正式版本号由人指定」列成阻塞项(不再要求先有基线才能跑); 打印破坏性变化条数;远端 tag 查不到时明说未核对并阻塞;不再硬读已删除的『已知问题』节。 - traps trap-5 口径修正:不是"不用 tag",而是"构建时不要用 git describe 取版本号"。 验证:check_changelog --self-test 全过;反仓夹具 80/0(含五条真改文件的结构负例、 六条只在临时仓库里造 tag 的场景);make verify 15/15 success。
492 lines
23 KiB
Python
Executable File
492 lines
23 KiB
Python
Executable File
#!/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:])) |