Files
knowledge-governance/scripts/check_changelog.py
姚章浩 4f8ee0a8c5 feat(version): 变更记录改固定四类,破坏性变化带发布文档链接
按项目方给的规则重做 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。
2026-10-13 14:59:35 +08:00

492 lines
23 KiB
Python
Executable File
Raw Permalink 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 / 版本记录检查 —— 把"版本规则"变成退出码(正本 §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:]))