【Bug已解决】Consider adding a changelog to track version history 解决方案
一、现象长是什么样的
在迭代一个库(如 DeepSpeed 这类底层训练框架)时,用户和贡献者经常问:"0.19.0 到 0.20.0 到底改了什么?" 但项目里只有 git log,没有一份人类可读的、按版本组织的变更记录。结果是:
- 用户升级后行为变了,却不知道是哪个改动导致的,只能逐条翻 commit;
- 贡献者提 PR 后,没人把它归到"下一个版本"的变更里,release 时漏写;
- 安全/破坏性变更(breaking change)藏在某条 commit message 里,用户踩坑才知道。
现象特征:这不是运行 bug,而是可观测性/发布工程的缺口——版本历史存在 git 里,但没有"聚合、分类、按版本可读"的 changelog,导致"改了什么"对使用者不透明。
二、背景
一份好的 changelog(参考 "Keep a Changelog" 规范)应该:
- 按版本倒序组织:最新的在最上面;
- 分类条目:Added / Changed / Fixed / Removed / Deprecated / Security;
- 关联版本号与日期:每条变更对应哪个 release;
- 可读优先:给"人"看,不是给 git 看。
问题在于,手写 changelog 容易和 git 历史脱节(忘了更新、写错版本)。更好的做法是从结构化 commit / PR 标签自动生成,让 changelog 和代码变更同源。
对于 DeepSpeed 这种库,changelog 尤其重要:ZeRO 行为、并行策略的微小改动都可能影响用户训练结果,没有版本级变更说明,升级就是"盲更"。
三、根因
根因一句话:项目只有 git 历史、没有一份按版本聚合分类的 changelog,导致"每个版本改了什么"对使用者不透明,升级行为变化难追溯、破坏性变更易踩坑,且手写 changelog 易与 git 脱节漏写。
具体:
- 无聚合:git log 是线性流水,没有"按版本切开"的视图;
- 无分类:改动混在一起,无法区分 Added/Fixed/Security;
- 易脱节:手写 changelog 靠人记,常漏写或写错版本;
- 升级盲盒:用户不知道 0.19→0.20 改了什么,行为变了难定位;
- 破坏性变更隐蔽:breaking change 藏 commit 里,用户踩了才发现。
本质是"发布历史没有工程化成一个可读、可溯源的制品"。
四、最小可运行复现
下面用纯 Python 模拟"从结构化 commit 自动聚合成 changelog":
from typing import List, Dict # 模拟带 conventional-commit 前缀的提交 COMMITS = [ ("feat", "AutoEP: 自动专家并行", "0.20.0"), ("fix", "ZeRO-3 分片在 world_size=1 时正确跳过", "0.20.0"), ("sec", "修复自托管 runner token 泄露", "0.20.0"), ("feat", "Muon 优化器支持", "0.19.0"), ("fix", "ds_z3_config 解析错误", "0.19.0"), ] def build_changelog(commits: List[tuple], version: str) -> str: cats = {"feat": "Added", "fix": "Fixed", "sec": "Security", "chg": "Changed"} lines = [f"## {version}", ""] bucket: Dict[str, List[str]] = {} for kind, msg, ver in commits: if ver != version: continue bucket.setdefault(cats.get(kind, "Changed"), []).append(msg) for cat in ("Added", "Changed", "Fixed", "Security"): if cat in bucket: lines.append(f"### {cat}") for m in bucket[cat]: lines.append(f"- {m}") lines.append("") return "\n".join(lines) def demo(): print(build_changelog(COMMITS, "0.20.0")) if __name__ == "__main__": demo()输出:
## 0.20.0 ### Added - AutoEP: 自动专家并行 ### Fixed - ZeRO-3 分片在 world_size=1 时正确跳过 ### Security - 修复自托管 runner token 泄露按版本 + 分类聚合,比 raw git log 可读得多。复现了"结构化聚合 changelog"的价值。
五、解决方案(第一层):定义 changelog 结构 + 从 commit 生成
第一层落地一份规范 changelog:用 conventional-commit 前缀(feat/fix/...)标记 PR,release 时脚本聚合:
from typing import List, Dict, Tuple CATEGORY_MAP = { "feat": "Added", "add": "Added", "fix": "Fixed", "chg": "Changed", "change": "Changed", "sec": "Security", "security": "Security", "dep": "Deprecated", "remove": "Removed", } def generate_changelog(commits: List[Tuple[str, str, str]]) -> str: """从 (kind, msg, version) 列表生成按版本倒序的 changelog。""" by_ver: Dict[str, Dict[str, List[str]]] = {} for kind, msg, ver in commits: cat = CATEGORY_MAP.get(kind, "Changed") by_ver.setdefault(ver, {}).setdefault(cat, []).append(msg) lines = ["# Changelog", ""] for ver in sorted(by_ver.keys(), reverse=True): lines.append(f"## {ver}") lines.append("") for cat in ("Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"): if cat in by_ver[ver]: lines.append(f"### {cat}") for m in by_ver[ver][cat]: lines.append(f"- {m}") lines.append("") return "\n".join(lines) def demo(): cl = generate_changelog(COMMITS) print(cl.splitlines()[0], "... (生成按版本倒序的 changelog)") if __name__ == "__main__": demo()核心是generate_changelog:按version分组、按类别分类、倒序排列。贡献者在 PR 标题用feat:/fix:/sec:前缀,release 脚本自动聚合,changelog 与 git 同源,不再脱节。
六、解决方案(第二层):关联版本号 + 自动写入 CHANGELOG.md
第一层生成了文本,第二层把它持久化为CHANGELOG.md并和版本发布绑定,且只增量更新最新版本(不重写历史):
from typing import List, Tuple def prepend_version(changelog_path: str, version: str, entries: List[str]): """把新版本的变更增量拼到 CHANGELOG.md 顶部,保留历史。""" header = f"## {version}\n\n" body = "\n".join(f"- {e}" for e in entries) new_block = header + body + "\n\n" try: old = open(changelog_path, "r", encoding="utf-8").read() except FileNotFoundError: old = "# Changelog\n\n" # 在 "# Changelog" 标题后插入新版本块 if old.startswith("# Changelog"): parts = old.split("\n", 1) updated = parts[0] + "\n\n" + new_block + (parts[1] if len(parts) > 1 else "") else: updated = "# Changelog\n\n" + new_block + old with open(changelog_path, "w", encoding="utf-8") as f: f.write(updated) return updated def demo(): prepend_version("/tmp/CHANGELOG.md", "0.21.0", ["AutoEP 支持多模态专家", "修复 ZeRO-2 通信重叠竞态"]) with open("/tmp/CHANGELOG.md") as f: print(f.read()[:200]) if __name__ == "__main__": demo()prepend_version只把最新版本增量插入顶部,历史块原样保留,避免重写导致冲突。release 流程里调用它,changelog 随每次发版自动增长,与版本号强绑定。
七、解决方案(第三层):CI 校验 + 不变量测试
第三层加护栏:PR 必须有合规前缀(否则 changelog 无法归类),且 release 时 changelog 必须包含本次版本:
import re from typing import List VALID_PREFIXES = ("feat", "fix", "chg", "sec", "dep", "remove", "docs", "test") def check_pr_title(title: str) -> bool: """CI 门禁:PR 标题需有合规前缀,否则 changelog 无法归类。""" m = re.match(r"^(\w+):", title) if not m: raise ValueError(f"PR 标题需前缀如 'feat:': {title}") if m.group(1) not in VALID_PREFIXES: raise ValueError(f"未知前缀 {m.group(1)},无法归类到 changelog") return True def test_changelog_covers_version(changelog: str, version: str) -> bool: assert f"## {version}" in changelog, f"changelog 缺少版本 {version} 的条目" return True def demo(): check_pr_title("feat: 添加 AutoEP 支持") try: check_pr_title("随便改了点东西") except ValueError as e: print("CI 拦截无前缀 PR:", e) test_changelog_covers_version("# Changelog\n\n## 0.21.0\n", "0.21.0") print("OK: changelog 覆盖版本、PR 前缀合规") if __name__ == "__main__": demo()check_pr_title在 CI 拦下无前缀 PR,保证每条变更都能归类进 changelog;test_changelog_covers_version在 release 时确认本次版本已在 changelog,漏写就红。
八、落地建议
如果你想加 changelog,建议:
- 定规范:Keep a Changelog 格式,按版本倒序、分类条目。
- commit/PR 前缀:contributor 用
feat:/fix:/sec:标记。 - 自动生成:脚本从 commit 聚合,changelog 与 git 同源。
- 增量写入:
prepend_version只插最新版本,保留历史。 - CI 校验:PR 无前缀拦截,release 时版本必在 changelog。
- 关联版本:changelog 与版本号/发版流程绑定。
九、排查清单
如果"改了什么"对用户不透明,查:
- 有无 changelog:没有就按 Keep a Changelog 建。
- 是否手写脱节:改用从 commit 自动聚合。
- PR 前缀:contributor 是否用分类前缀。
- 增量写入:新版本插顶部,保留历史。
- CI 校验:无前缀 PR 拦截、release 版本必在 changelog。
- 版本绑定:changelog 与发版流程关联。
- 可读性:按版本倒序、分类清晰。
十、小结
项目缺少 changelog,根因是只有 git 线性历史、没有一份按版本聚合分类的可读变更记录,导致"每个版本改了什么"对使用者不透明,升级行为变化难追溯、破坏性/安全变更易踩坑,且手写 changelog 易与 git 脱节漏写。它不影响运行,但让升级变成"盲更"。
修复分三层:第一层定义规范 changelog 结构,用 conventional-commit 前缀(feat/fix/sec)标记 PR,generate_changelog按版本倒序、分类聚合,changelog 与 git 同源;第二层用prepend_version把最新版本增量写入CHANGELOG.md顶部、保留历史,与发版流程绑定;第三层加 CI 门禁(check_pr_title拦截无前缀 PR 保证可归类、test_changelog_covers_version确保 release 版本必在 changelog)。核心心法是:changelog 不应是 release 前靠人回忆手写的文档,而应是 commit/PR 分类前缀的自动化聚合产物——让它和代码变更同源、随每次发版增量生长,版本历史才对使用者真正透明可追溯。