首页
/ last30days-skill:Towncrier 变更日志分片 + 自动化 Lockstep 发布 PR 的工程实践

last30days-skill:Towncrier 变更日志分片 + 自动化 Lockstep 发布 PR 的工程实践

2026-09-04 23:16:53作者:宣利权Counsellor

本文围绕 towncrier-lockstep-release.md 这一解决方案文档展开,讲解 last30days-skill 如何用 towncrier 变更日志分片(changelog fragments)加一套 GitHub Actions 工作流,彻底消除 CHANGELOG.md 的合并冲突,并把一次版本发布需要同步的十余个"lockstep 版本面"(skill 元数据、pyproject、各插件与市场 manifest)收敛成一条可复现的自动化流水线。读完本文,你能理解多 Agent 协作仓库中"谁来写 changelog、谁不能动版本号、发布 PR 如何自动生成与打标"的完整机制,并掌握可直接迁移到同类项目的配置与脚本设计。

问题背景:两条发布痛点

该文档(frontmatter 中 applies_when 列出的适用场景)描述了三类典型症状,正是本方案要解决的问题:

  • 多 PR 同改 ## [Unreleased] 造成合并冲突:早期每个功能 PR 都直接编辑 CHANGELOG.md 的 Unreleased 小节,每次"发布列车"合并时都会冲突;
  • 手工发布漏改 marketplace JSON 版本号:一次正确发布必须让同一个 semver 同时出现在 skill frontmatter + H1、pyproject.tomluv.lock、Claude/Codex/Grok/Gemini 插件 manifest、以及两个 marketplace JSON 文件中——这些一致性由 tests/test_plugin_contract.py 的 lockstep 测试强制校验;
  • Agent 自由发挥发布步骤导致漂移:该仓库的功能 PR 主要由 AI Agent 而非人类撰写,如果发布规则不明确,Agent 会"发明"出与 test_plugin_contract lockstep 约定不一致的发布步骤。

文档给出的根因分类是 missing_workflow_step(缺失工作流步骤),解决类型是 workflow_change(流程变更)。一个值得注意的权衡记录在文档中:release-please 并非不能做,但它要求维护一大片 extra-files 配置面,并依赖 conventional-commit 纪律,而 Agent 提交流量并不能可靠地提供这种纪律——因此项目选择了"自写脚本 + 强守卫"的路线。

解决方案总览:五个组件

文档给出的方案由五个组件构成,下面逐一结合仓库中的真实实现展开。

1. towncrier:PR 只加分片,CHANGELOG 只在发布时生成

PR 不再触碰 CHANGELOG.md,而是向 changelog.d/ 目录添加 <编号>.<类型>.md 分片;CHANGELOG.md 只在发布时刻由 towncrier 汇总写入。贡献者指南见 changelog.d/README.md,其核心规则:

  • 不需要安装 towncrier CLI 即可贡献:分片就是普通 Markdown 文件,towncrier 只在发布准备时运行;
  • 命名约定:优先用 PR 或 issue 编号——changelog.d/<number>.<type>.md;尚无关联 issue/PR 时用孤儿命名——changelog.d/+.<type>.mdchangelog.d/+short-slug.<type>.md
  • 类型表(Keep a Changelog)
后缀 章节
security Security
removed Removed
deprecated Deprecated
added Added
changed Changed
fixed Fixed
  • 内容要求:一到两句"使用者会在 release notes 里关心的话"(行为、文档或安装层面的影响);分片正文可链接 issue,towncrier 也会自动从文件名链接编号。
  • 跳过规则:纯杂务(注释错字、无 release notes 价值的 CI 版本 pin 更新)可以不加分片,改为在 PR 模板勾选 Skip changelog,或添加 skip-changelog 标签。

towncrier 的完整配置在 pyproject.toml[tool.towncrier] 段:

[tool.towncrier]
name = "last30days-skill"
directory = "changelog.d"
filename = "CHANGELOG.md"
start_string = "<!-- towncrier release notes start -->\n"
underlines = ["", "", ""]
title_format = "## [{version}] - {project_date}"
issue_format = "[#{issue}](https://github.com/mvanhorn/last30days-skill/issues/{issue})"

其后依次为 securityremoveddeprecatedaddedchangedfixed 六个 [[tool.towncrier.type]] 段,每个都设置 showcontent = true。towncrier 本身放在 dev 依赖组中(towncrier>=25.8.0,<26),即日常开发不引入、发布准备时由 uv sync --group dev 提供。start_string 机制意味着 CHANGELOG.md 只在 <!-- towncrier release notes start --> 标记之后被 towncrier 管理,历史内容不会被重写。

2. .github/scripts/prepare_release.py:一次 towncrier build + 全部版本面 bump

发布准备脚本 .github/scripts/prepare_release.py 的职责是"先运行 towncrier build,再把所有 lockstep 路径上的版本号统一抬升"。用法(仓库根目录执行):

python3 .github/scripts/prepare_release.py --bump patch
python3 .github/scripts/prepare_release.py --version 3.19.0
python3 .github/scripts/prepare_release.py --bump minor --dry-run

从源码可以确认它的行为细节:

  • 参数--bump major|minor|patch--version X.Y.Z 二选一(互斥组,必选其一);--dry-run 只打印目标版本和 towncrier 草稿(加 --draft),不写任何文件;--skip-towncrier 表示 changelog 已准备好、只 bump 版本面(见 prepare_release.py#L168-L183);
  • 安全闸:拒绝把版本降级(Refusing to downgrade),拒绝在非 dry-run 下"同版本重发"(Refusing to re-release)(见 prepare_release.py#L188-L197);
  • 版本面清单JSON_VERSION_FILES 覆盖 .claude-plugin/plugin.json.codex-plugin/plugin.json.grok-plugin/plugin.jsongemini-extension.jsonMARKETPLACE_FILES 覆盖 .claude-plugin/marketplace.json.grok-plugin/marketplace.json;加上 pyproject.tomlskills/last30days/SKILL.md(frontmatter version:# last30days vX.Y.Z: H1 两处)、uv.lockname = "last30days-skill" 的 package 段,共 9 个文件(见 prepare_release.py#L28-L38bump_all#L151-L165);
  • 精确替换:SKILL.md 的 frontmatter 版本与 H1 版本各要求恰好一次匹配,uv.lock 要求恰好一个 last30days-skill package stanza,任何"多于一次或零次"匹配都会 SystemExit 失败——这种 fail-closed 设计避免正则误伤(见 bump_skill_md#L97-L108)。

3. GitHub Actions 三段式:Prepare release → Tag release → Release

发布链路由三个工作流串成,对应文档中"opens the release PR → creates vX.Y.Z on merge → existing Release workflow attaches artifacts":

(a)Prepare release.github/workflows/prepare-release.yml):由 workflow_dispatch 手动触发,输入为 bump(choice:patch/minor/major,默认 patch)与可选的显式 version。流程为:

  1. uv python install 3.12 + uv sync --group dev 装好 towncrier;
  2. 运行 prepare_release.py(显式 version 优先于 bump),并从 pyproject.toml 读回新版本号;
  3. 创建 release/vX.Y.Z 分支(若远端已存在同名分支则中止,防止覆盖),git add 白名单内的 12 个发布相关文件(CHANGELOG.md、changelog.d、pyproject.toml、uv.lock、SKILL.md、各 plugin/marketplace JSON、gemini-extension.json);暂存区为空则报错退出(提示"changelog.d 可能是空的");
  4. chore(release): bump version to X.Y.Z 提交并推送,用 gh pr create 建 PR,打 release 标签,PR 描述中自带测试计划(含 uv run pytesttests/test_plugin_contract.py::test_versions_match_across_manifests 检查项)。

(b)Tag release.github/workflows/tag-release.yml):监听 main 分支 push,但只处理提交信息含 chore(release): bump version to 的 commit(注意 if: 表达式必须整体加引号并用 contains 而非 startsWith——裸冒号会让 YAML 解析失败,且 merge commit 把 PR 标题放在 body 里,需要逐行扫描)。其内部校验链值得注意:

  • 从提交信息中提取 VERSION 后,再与 pyproject.toml 实际版本比对,不一致则拒打 tag;
  • 反查该 commit 对应的 PR,要求 PR 携带仓库管控的 release 标签——"仅有匹配的标题不能铸出 tag";
  • 打 annotated tag vX.Y.Z 并推送,随后显式 gh workflow run release.yml -f tag=vX.Y.Z 派发 Release 工作流(因为 GITHUB_TOKEN 触发的 tag push 不会自动启动其他 workflow)。

(c)Release.github/workflows/release.yml):现有工作流,在 tag 就位后附加 .skill / .mcpb 等发布产物。

4. changelog-guard:CI 层面的双向守卫

.github/workflows/changelog-guard.yml 在每个 PR 的 opened/synchronize/reopened/labeled/unlabeled 事件上执行,落实"非发布 PR 不许动 CHANGELOG 和版本串"这条规则。从 workflow 脚本可确认四条逻辑:

  1. release 标签的 PR 直接放行(版本与 CHANGELOG 编辑均允许);
  2. 非 release PR 修改 CHANGELOG.md → 失败,报错提示"Add changelog.d/<n>.<type>.md instead"。有一个历史豁免:一次性 towncrier 迁移(用 start marker 替换旧的 ## [Unreleased] 且未新增 +### 小节)被允许;
  3. 版本串比对:对 9 个版本面文件(pyproject.toml、uv.lock、SKILL.md、4 个 plugin JSON、2 个 marketplace JSON、gemini-extension.json),用辅助脚本 .github/scripts/read_manifest_version.py 分别解析 base 与 head 两侧的版本,任何差异即报 Non-release PRs must not bump lockstep version strings。脚本注释里记录了一个真实教训:此前内联的 python3 -c 版本解析块因缩进到 0 列,导致 Actions 拒绝解析整个 workflow(每次运行都是空 jobs 失败),解析逻辑因此被抽到独立脚本;
  4. 引擎改动必须有分片:当改动触及 skills/last30days/scripts/*skills/last30days/SKILL.mdmcp/*(引擎/技能代码),而 PR 既没有 changelog.d/*.md 分片(README.md 除外)也没有 skip-changelog 标签时,守卫失败。

5. PR 模板:changelog 检查单 + Agent 披露

.github/PULL_REQUEST_TEMPLATE.md 把上述规则固化进每个 PR 的表单:

  • Changelog 小节:明确"要出现在下次 release notes 就在 changelog.d/ 加分片,不要编辑 CHANGELOG.md 或在功能 PR 中 bump 版本/manifest",并提供两个勾选项——加 changelog.d/<pr-or-issue>.<type>.md(列出全部 6 种类型),或勾选 Skip changelog(纯杂务,同时加 skip-changelog 标签);
  • Agent disclosure 小节:要求总结编码 Agent 做了哪次 review(查了哪些风险、标记了什么、据此改了什么),以及安全审查项(输入处理、命令执行、路径处理、认证、密钥、依赖风险,无则写 N/A);
  • Relationship to this change:要求披露雇佣/合同/股权等与被集成厂商或产品的付费关联(例如"你在被集成的 API 厂商任职")。

Agent 规则(文档原文核心,逐条继承)

文档给出的"Agent rules (short)"是整套方案对 Agent 的契约,原文三条必须原样执行:

  • 写分片,不写 CHANGELOG.md(Write fragments, not CHANGELOG.md);
  • 功能 PR 中不许 bump 版本(Do not bump versions in feature PRs);
  • 通过 Prepare release 发布,而不是手工编辑十个文件(Cut releases via Prepare release, not by editing ten files)。

AGENTS.md 的 "Changelog and releases (agents)" 一节把这三条扩展为可操作的五步规范:功能/修复 PR 在变更属于下次 release notes 时加 changelog.d/<pr-or-issue>.<type>.md永不在功能 PR 中编辑 CHANGELOG.md 或在 pyproject.tomlSKILL.md、plugin/marketplace JSON、uv.lock 中 bump 版本(CI 的 changelog-guard 会拦截);无 release notes 内容时加分片豁免 + skip-changelog 标签;发布走 Actions → Prepare release(patch/minor/major),合并后 Tag release 推送 vX.Y.Z、既有 Release 工作流发布产物,"不要手编十个版本文件";lockstep 闸门是 tests/test_plugin_contract.py::test_versions_match_across_manifests,工作流契约由 tests/test_changelog_workflow.py 锁定。本地等价命令为 uv run python .github/scripts/prepare_release.py --bump patch(需 Python 3.12+,环境用 uv 管理,venv 在 .venv/)。

契约测试:把发布流程本身当成被测对象

tests/test_changelog_workflow.py 是这套工作流的"契约测试",它证明了上述组件不是文档声明而是被持续验证的事实:

  • test_towncrier_config_present 校验 [tool.towncrier] 段、分片目录、输出文件与 6 种分片类型齐备;
  • test_changelog_has_towncrier_start_marker 要求 CHANGELOG.md 含 start marker 且不再包含 ## [Unreleased](旧冲突源被彻底移除);
  • test_release_workflows_exist 断言三个 workflow 文件存在;
  • test_tag_release_workflow_yaml_parses / test_tag_release_workflow_version_extraction 直接回放 tag-release.yml 里的 sed 表达式,验证它能同时解析"直接 push"与"merge commit(标题在 body)"两种提交信息形态;
  • test_changelog_guard_run_blocks_stay_indented_assert_run_blocks_indented 断言所有 run: | 块内不存在 0 列(或欠缩进的)行——这正是 workflow 脚本注释中提到的那次"空 jobs 失败"事故的回归防护;
  • test_next_version_bumps(3.18.1 + patch/minor/major → 3.18.2/3.19.0/4.0.0)、test_main_refuses_equal_version_outside_dry_runtest_bump_all_updates_lockstep_surfaces(在临时目录里搭出完整的 lockstep 布局,bump 到 9.9.9 后逐一断言 9 个文件全部到位)。

小结与参考

这套方案的设计要点可以概括为:把 changelog 写入权收敛到单一发布时刻(towncrier),把版本号写入权收敛到单一自动化 PR(prepare_release.py + release 标签),再用 CI 守卫(changelog-guard)与契约测试(test_changelog_workflow.py)把两条收敛线变成不可绕过的规则——在多 Agent 贡献的仓库里,这比依赖 Agent 自觉遵守纪律可靠得多。

延伸阅读(文档 "See also" 一节指向的仓库内资源,均为仓库根目录相对路径):

登录后查看全文
热门项目推荐
相关项目推荐