【学习笔记】搭建 git-cliff + Agent + GitHub Action 的自动化 Changelog 闭环

31 min

一篇通用技术笔记:如何用 git-cliff(生成 CHANGELOG 骨架)+ Agent skill(编排发版与注入 AI 摘要)+ GitHub Action(自动创建 Release)三件套,为任意 git 项目搭建从提交到发布的全自动 changelog 闭环。

适用对象:任何采用 Conventional Commits、以 git tag 驱动发布的仓库。不限定语言、不限定框架。文中的踩坑与适配方案(非规范历史处理、多段提交截断、gitignore 逐层穿透、数据快照取舍)对所有项目通用。


0. TL;DR

体系由 3 个组件协作,输出单文件 CHANGELOG.md,每次发布前置插入一个新版本段:

组件职责落点
生成器(git-cliff)解析 git 历史,按 Conventional Commits 分组,渲染 CHANGELOG 骨架cliff.toml
编排器(Agent skill)定版本 → 预览 → 生成 → 注入 AI 摘要 → 同步版本号 → tag → pushagent skill 文件
自动化(GitHub Action)push v* tag 时从 CHANGELOG 切片,自动创建 GitHub Release.github/workflows/release.yml

闭环流程:

Conventional Commit ──┐

              Agent release skill

            ┌─────────┼─────────┐
            ▼         ▼         ▼
      git-cliff    AI 摘要   版本号同步
      生成骨架    注入占位符  (version 文件)
            └─────────┼─────────┘

              git tag + push


         GitHub Action 自动建 Release

阅读路线:§1–§2 是选型与全景,快速了解”为什么”和”怎么搭”;§3–§7 是各组件的配置与踩坑详解(每个组件自成一章,配置 + 决策 + 踩坑放在一起);§8 是从零复现的有序步骤;附录 A 是可直接复制的 skill 模板。


1. 为什么选 git-cliff

常见的 CHANGELOG / 发版方案对比:

方案依赖适用场景局限
changesets / semantic-release / standard-versionNodeJS/TS 项目强制引入 Node 运行时,非 JS 项目不友好
release-please (GitHub Action)无(独立二进制)任意项目,Google 出品配置项多,对非 package.json 的版本源支持需额外配置
手写脚本极简项目分组、链接、摘要都要自己实现,维护成本高
git-cliffRust 单二进制任意项目需单独安装(cargo/winget/brew)

选 git-cliff 的三个决定性理由

  1. 零运行时依赖——单个 Rust 二进制,winget install git-cliff / cargo install git-cliff / brew install git-cliff 即装,不污染项目的语言工具链。对 Python / Go / Rust / 纯文档项目都同样友好。
  2. 模板灵活——用 Tera(Jinja2 风格)模板,可以在 body 里放任意占位符(如 <!-- AI_SUMMARY -->),agent 后处理替换。这是「结构化骨架 + AI 摘要」混合方案的基础,纯 CI 方案做不到。
  3. 配置即文档——单个 cliff.toml 描述分组、过滤、模板、bump 规则,可读、可 review、可进版本库。

2. 体系架构

三组件各司其职,通过 git tag 串联成一个闭环:

┌─────────────────────────────────────────────────────┐
│  开发阶段                                            │
│  Conventional Commits (feat/fix/refactor/...)       │
│  → 每次提交自动归类,无需手写 changelog 条目         │
└──────────────────────┬──────────────────────────────┘
                       │ 对 agent 说「发布」

┌─────────────────────────────────────────────────────┐
│  发布阶段 (Agent release skill)                      │
│                                                      │
│  1. 读 tag → 定版本号 (PATCH/MINOR/MAJOR)           │
│  2. git cliff --prepend → 生成骨架                   │
│  3. 替换 <!-- AI_SUMMARY --> → 注入自然语言摘要       │
│  4. 同步版本号 (plugin.json / 各组件 version)        │
│  5. git tag + push                                   │
└──────────────────────┬──────────────────────────────┘
                       │ push v* tag

┌─────────────────────────────────────────────────────┐
│  自动化阶段 (GitHub Action)                          │
│                                                      │
│  tag push 触发 → awk 切片 CHANGELOG → 建 Release    │
└─────────────────────────────────────────────────────┘

核心分工

  • 结构交给工具——git-cliff 按 commit 类型自动分组、生成链接
  • 语义交给 AI——agent 注入 2-3 句自然语言摘要,概括”本次发布干了什么”
  • 决策交给人——Step 2 预览后必须人工确认才继续

下面按组件逐章详解(配置 + 关键决策 + 踩坑在一起,无需跨节跳读)。


3. git-cliff:配置与调优

3.1 完整配置(cliff.toml

核心设计三点:HTML 注释前缀排序AI 摘要占位符兜底 parser

[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}## {{ version }} ({{ timestamp | date(format="%Y-%m-%d") }}){% else %}## [Unreleased]{% endif %}
{% if previous.version %}**[Full diff](https://github.com/<owner>/<repo>/compare/{{ previous.version }}...{{ version }})**{% endif %}
<!-- AI_SUMMARY -->
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | split(pat="\n") | first | upper_first | trim }}\
{% if commit.breaking %} (**BREAKING**){% endif %}\
 ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/<owner>/<repo>/commit/{{ commit.id }}))\
{% endfor %}
{% endfor %}
"""
trim = true
footer = """
<!-- generated by git-cliff -->
"""

[git]
conventional_commits = true
filter_unconventional = false
commit_parsers = [
  { message = "^feat", group = "<!-- 0 -->🚀 Features" },
  { message = "^fix",  group = "<!-- 1 -->🐛 Bug Fixes" },
  { message = "^refactor", group = "<!-- 2 -->🔨 Refactor" },
  { message = "^perf", group = "<!-- 5 -->⚡ Performance" },
  { message = "^doc",  group = "<!-- 6 -->📝 Documentation" },
  { message = "^chore\\(release\\)", skip = true },
  { message = "^chore|^ci", group = "<!-- 8 -->⚙️ Miscellaneous" },
  { message = "^revert", group = "<!-- 9 -->◀️ Revert" },
  { message = "^Merge", skip = true },
  # 兜底(见 §3.3):捕获非 Conventional 历史提交,避免丢失
  { body = ".*", group = "<!-- 10 -->📦 Other Changes" },
]
protect_breaking_commits = false
filter_commits = false
tag_pattern = "v\\d+"
sort_commits = "oldest"

[bump]
initial_tag = "0.0.1"

替换 <owner>/<repo> 为实际仓库地址(git remote get-url origin 可读出)。

3.2 关键设计:HTML 注释前缀排序

{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix",  group = "<!-- 1 -->🐛 Bug Fixes" },

模板里 {{ group | striptags | trim }} 会剥掉 <!-- N -->,渲染后不可见但保证排序——比依赖 emoji 字符序更可靠,分组顺序永远稳定。

3.3 踩坑:非 Conventional 历史提交消失

现象:照搬标准 cliff.toml 后,git cliff --tag v1.0.0 --unreleased 预览空空如也——历史 commit 一个都没渲染。

根因:很多项目早期提交是非 Conventional 风格(Add ... / Fix ... / Refactor ...,没有 feat: / fix: 前缀)。配置里:

  • conventional_commits = true → git-cliff 按 Conventional Commits 解析
  • filter_unconventional = false → 非规范提交保留,但不解析
  • commit_parsers 只匹配 ^feat / ^fix / … → 非规范提交匹配不到任何 parser → 没有归属 group → 模板 {% for group, commits in commits | group_by(attribute="group") %} 按 group 聚合时,无 group 的提交不渲染

解决:在 commit_parsers 末尾加兜底规则(见上方配置第 10 条):

{ body = ".*", group = "<!-- 10 -->📦 Other Changes" },

git-cliff 按顺序评估 parser,第一条匹配生效,所以放最后的无条件规则兜底所有未匹配提交。

⚠️ 坑(实测,文档未明确)

  • { group = "..." }(无条件,仅 group 字段)不生效——预览还是空。
  • { regex = ".*", group = "..." } 也不生效
  • 必须 { body = ".*", group = "..." } 才生效(匹配每个剩余 commit 的 body 字段)。

这是 git-cliff 2.13.1 的实际行为,靠逐个实测才发现。

3.4 踩坑:多段 commit message 正文泄漏

现象:兜底规则生效后,多段提交的正文也灌进了 CHANGELOG:

- Add Wechat RSS monitoring scripts and related functionality

  - Implement check_updates.py to fetch and check Wechat2RSS feeds...
  - Create fetch_articles.py to retrieve full article content...

本应是干净的单行。Conventional 项目不遇到这问题(只有 subject 行),但兜底组里的历史提交通常是多段的。

根因commit.message 字段对多段提交包含完整正文(含换行)。

失败的尝试(Rust regex crate 限制):

commit.preprocessors = [
  { pattern = '(?s)\n.*', replace = "" },   # ❌ 无效
  { pattern = '\n[\s\S]*', replace = "" },  # ❌ 无效
  { pattern = '(?m)\n.*$', replace = "" },  # ❌ 无效
]

git-cliff 的 preprocessor 用 Rust regex crate,默认 . 不匹配 \n,这些跨行写法都不生效。

解决:不用 preprocessor,改在模板层取首行——用 Tera 的 split + first(见上方 body 模板):

{{ commit.message | split(pat="\n") | first | upper_first | trim }}

split(pat="\n") 把多行 message 按换行切成数组,first 取第一行。干净、可靠、无副作用,对所有提交(单行/多段)都正确。

3.5 AI 摘要占位符机制

模板 body 里留 <!-- AI_SUMMARY -->,git-cliff 不解析它(原样输出),release skill(§4 Step 3)用真实摘要替换。这让「分组骨架」和「自然语言摘要」解耦:git-cliff 负责结构,AI 负责语义。


4. release skill:发布编排工作流

4.1 工作流总览

一个 agent skill,用自然语言触发(「发布」「release」「发版」「打 tag」),workflow 分 5 步,Step 2 后必须人工确认

Step 0  前置检查     git-cliff 已装 + 工作树干净
Step 1  定版本号     读上一个 tag → 按 PATCH/MINOR/MAJOR 递增
Step 2  预览         git cliff --unreleased + 变更分析 → 展示摘要
        ─── 等待用户确认 ───
Step 3  生成         首次 -o / 后续 --prepend → 校验历史未丢 → 替换 AI_SUMMARY → commit
Step 3.5 同步版本号  更新版本源文件(见 §5)
Step 4  tag + push   git tag -a + git push origin HEAD --tags

这个编排逻辑与具体 agent 平台无关——可以是 ZCode / Claude Code 的 skill,也可以是任何能执行 shell 命令的 agent,甚至是一个普通 shell 脚本。完整可复制的模板见附录 A

4.2 定版本号(Step 1)

采用语义化版本 v<MAJOR>.<MINOR>.<PATCH>。读上一个 tag,按变更规模递增:

类型规则适用场景
PATCHv1.0.x → v1.0.x+1修 bug、文档、chore、配置同步
MINORv1.x.* → v1.x+1.0新增功能、能力扩展
MAJORvx.*.* → v+1.0.0架构级重构、不兼容变更(罕见)
# 获取上一个版本号
git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1
# 无 tag(首次发布)时,从版本源文件读取
grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' <VERSION_FILE> | grep -o '"[0-9][^"]*"$' | tr -d '"'

4.3 生成 CHANGELOG(Step 3)—— 含两道防护

首次发版和后续发版的写入方式完全不同,混淆会丢数据:

test -f CHANGELOG.md && echo exists || echo not-exists
# 不存在(首次):全量生成
git cliff --tag <VERSION> -o CHANGELOG.md
# 已存在(后续):前置插入
git cliff --tag <VERSION> --prepend CHANGELOG.md

⚠️ 切勿对已存在的 CHANGELOG.md 使用 -o(覆盖写入)! -o 会用新版本内容整文件覆盖,丢失所有历史版本。必须用 --prepend。 这是实测踩过的坑——详见下方”根因与防护”。

防护 1:生成后校验版本数(关键)

生成后比对 CHANGELOG 版本数与 git tag 数,不等则恢复重试:

echo "CHANGELOG 版本数: $(grep -c '^## v' CHANGELOG.md)"
echo "Git tag 数:       $(git tag --list 'v*' | wc -l)"

两个数字必须相等。若 CHANGELOG 版本数 < tag 数,说明历史被覆盖,立即:

git checkout CHANGELOG.md   # 恢复
# 然后用 --prepend 重试

为什么用版本数校验而非 git diffgit diff 需要人眼判断哪些段消失了;版本数比对是纯数字,agent 和人都能一眼看出不等=出错,零判断成本。

防护 2:AI 摘要注入

git-cliff 模板中 <!-- AI_SUMMARY --> 是占位符,需要用 AI 生成的摘要替换。基于本次 commits 的分组统计 + Skill 变更分析,生成 2-3 句自然语言摘要:

> <自然语言总结,2-3 句,概括本次发布最核心的变化>
>
> 共 <N> commits,其中 🚀 Features <N> | 🐛 Fixes <N> | 📝 Docs <N> | ...
>
> **[Full diff](https://github.com/<owner>/<repo>/compare/<PREV_TAG>...<VERSION>)**

首次发布无 PREV_TAG 时,Full diff 链接为 https://github.com/<owner>/<repo>/commits/<VERSION>


5. 版本号管理

5.1 版本号来源

不同项目的版本号来源不同:

  • Node 项目 → package.json"version"
  • Rust 项目 → Cargo.tomlversion
  • Python 项目 → pyproject.toml__version__.py
  • 插件项目 → 插件清单文件(如 plugin.json
  • 纯文档 / 无清单项目 → 仅靠 git tag

5.2 模型一:三同步(单组件仓库,通用默认)

适用于绝大多数项目(单 package.json / 单 Cargo.toml)。发版时让三处保持一致:

git tag v1.0.1  ←→  CHANGELOG "## v1.1.0"  ←→  版本源文件 "version": "1.0.1"

release skill 的 Step 3.5 负责更新版本源文件(去 v 前缀),与 CHANGELOG 一起 commit。如果项目没有版本源文件(仅靠 tag),这步可跳过。

一个常见的坑:版本源文件可能是 JSON / TOML / YAML 等格式,更新时只改 version 行,保持其余结构与缩进不变。用 sed 精确替换该行:

# JSON 格式(package.json / plugin.json 等)
sed -i 's/"version":[[:space:]]*"[0-9][^"]*"/"version": "<VERSION_WITHOUT_V>"/' <VERSION_FILE>
# TOML 格式(Cargo.toml / pyproject.toml 等)——注意只改 [package] 段的 version

5.3 模型二:两层版本(多组件仓库)

适用于 monorepo(多包)或插件市场(多 skill)——仓库包含多个可独立演进的组件,每个有自己的版本号,改谁 bump 谁,互不影响。

三同步模型会让没改动的组件也被强行升版本。两层版本模型解决这个:

层级载体规则
全局版(代表整个仓库的发版)git tag + CHANGELOG + 顶层版本源(如 plugin.json每次发版必 bump
组件独立版各组件清单的 version 字段只 bump 有改动的组件,未改不动,与全局版不绑定

release skill 的 Step 3.5 需分两步:

  1. 全局版:照常更新顶层版本源(§5.2)
  2. 组件版:用 git diff 检测哪些组件有改动,只 bump 这些组件的 version 字段:
    PREV_TAG=$(git tag --sort=-creatordate | head -1)
    # 检测本次改动的组件(对比上一个 tag)
    CHANGED=$(git diff --name-only "$PREV_TAG" HEAD -- 'components/*' \
              | sed 's|components/||;s|/.*||' | sort -u)
    # 对每个有改动的组件 bump 版本号
    for c in $CHANGED; do
      sed -i 's/^version:[[:space:]]*.*/version: "<NEW_VERSION>"/' "components/$c/metadata.yaml"
    done

    注意:全新组件若仍是 untracked,git diff 看不到——以 git status 为准。

何时用哪个模型:判断标准是”发版时是否需要改谁 bump 谁”。单 package.json 项目用 §5.2 三同步即可;只有当仓库内有多个各自有版本号、各自演进的组件时才需要两层模型。


6. GitHub Action:自动创建 Release

仓库无关、逐字可复用。它不生成 changelog(已由本地 skill 完成),只做切片 + 建 Release:

name: Release
on:
  push:
    tags: ['v*']
permissions:
  contents: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # 全历史,awk 切片需要
      - name: Extract release notes
        run: |
          TAG="${GITHUB_REF_NAME}"
          awk "/^## ${TAG}/{found=1} /^## / && found && !/^## ${TAG}/{exit} found{print}" \
            CHANGELOG.md > release-notes.md
      - uses: softprops/action-gh-release@v2
        with:
          body_path: release-notes.md

awk 逻辑:找到 ## v1.0.0 行开始输出,遇到下一个 ## 行就退出——精准切出当前版本的段。

设计原则:CI 只做切片 + 建 Release,不生成内容,无状态、可复现。所有”生成”逻辑都在本地 release skill 完成,CI 失败可手动重跑,不影响 CHANGELOG 正确性。


7. 仓库卫生实践

7.1 gitignore 逐层穿透(反忽略嵌套目录)

场景:你的 agent 工作目录(如 .zcode/.claude/.idea/ 等)整体被 .gitignore 忽略,但你想把其中的某个子目录(比如 release skill)纳入版本控制。

:直觉写法不生效

.zcode/          # ❌ 忽略整个目录
!.zcode/skills/  # ❌ git 不会进入 .zcode/,这条反忽略永远不被评估

根因:git 的规则——一旦一个目录被忽略,git 不会「进入」它,里面的反忽略规则永远不会被评估

解决:逐层反忽略——先放开目录本身让 git 能进入,再放开目标子目录:

.zcode/*                    # 忽略 .zcode 下所有内容(但不忽略 .zcode 本身)
!.zcode/skills/             # 放开 skills 子目录 → git 能进入
.zcode/skills/*             # 再次忽略 skills 下所有内容
!.zcode/skills/release/     # 放开 release 子目录

验证:

git check-ignore .zcode/skills/release/SKILL.md   # exit 1 = 未忽略 ✓
git add .zcode/                                    # 只会 add release/SKILL.md 一个文件

这个技巧适用于任何「整体忽略 + 局部追踪」的场景,与具体目录名无关。

7.2 数据快照 vs 源配置的取舍

通用原则:版本库应只含「人写的、不可自动生成的」内容。能由脚本从外部数据源全量重建的文件,属于派生数据/缓存,提交会让仓库膨胀且持续产生噪音。

判断标准——问自己两个问题:

  1. 这个文件是手写的吗? → 是 → 提交(它是源配置)
  2. 删掉它,脚本能从原始来源重新生成吗? → 是 → 考虑 ignore(它是派生数据)

典型该 ignore 的派生数据(带 fetch_time/时间戳的抓取快照、构建产物、渲染输出):

  • 抓取的 feed 列表 / 排行榜数据(带 metadata.fetch_time
  • 编译产物(dist/build/*.min.js
  • 数据文件的格式转换副本(如 JSON 的 Markdown 渲染版)

典型该提交的源配置

  • 手写的 curated 配置(编辑它 = 改变行为)
  • 不可自动生成的 seed 数据

实操要点:如果一个派生文件已经被 tracked,用 git rm --cached <file> 从索引移除(本地保留),再加入 .gitignore。不要直接 git rm(会删本地文件)。


8. 复现指南:给 agent 照着搭建

本节是为「agent 自助搭建」设计的有序流程。每步有明确的动作 + 验证,验证不过不进下一步。把本节 + §3.1(cliff.toml)+ §6(workflow)+ 附录 A(skill 模板)一起给 agent,它就能为新项目完整搭出闭环。

前置确认(开工前问清 4 件事)

确认项怎么查不过怎么办
是 git 仓库git rev-parse --is-inside-work-treegit init
有 GitHub 远程git remote get-url origingit remote add origin <url>
git-cliff 已装git cliff --versionwinget install git-cliff / cargo install git-cliff / brew install git-cliff
版本源文件见下方「检测版本源」无则跳过版本同步(仅用 tag)

检测版本源(按优先级试,命中即止):

# Node
[ -f package.json ] && grep '"version"' package.json
# Rust
[ -f Cargo.toml ] && grep '^version' Cargo.toml
# Python (PEP 621)
[ -f pyproject.toml ] && grep 'version' pyproject.toml | head -1
# 插件清单(通用名)
ls */.claude-plugin/plugin.json .claude-plugin/plugin.json 2>/dev/null

记录命中的文件路径 + 字段格式(JSON 的 "version": "x" vs TOML 的 version = "x"),后续 Step 3.5 和附录 A 的 <VERSION_FILE> 占位符要用。

Step 1:创建 cliff.toml

  1. 复制 §3.1 的完整配置到仓库根 cliff.toml
  2. 替换两处占位符 <owner>/<repo> 为实际仓库(git remote get-url origin 可读出)
  3. 验证git cliff --unreleased 能跑通、无报错(有提交则能看到分组输出;空仓库则只输出 header,也正常)

Step 2:创建 GitHub Action

  1. 新建 .github/workflows/release.yml,原样复制 §6(仓库无关,无需改)
  2. 验证:YAML 语法检查
    python -c "import yaml; yaml.safe_load(open('.github/workflows/release.yml'))" && echo OK

Step 3:创建 release skill

  1. 复制附录 A 的完整 skill 模板,落到 agent skill 目录(如 .zcode/skills/release/SKILL.md
  2. 填入附录 A 顶部标注的 3 个占位符(仓库 URL、版本源文件路径、skill 监控的业务目录)
  3. 如该目录被 .gitignore 忽略,按 §7.1 逐层反忽略
  4. 验证:对 agent 说「发布」或「release」,确认 skill 被触发并进入 Step 0 前置检查

Step 4:提交搭建产物

git add cliff.toml .github/workflows/release.yml <skill路> .gitignore
git commit -m "chore: 引入 git-cliff changelog 闭环体系"

验证git status --porcelain 干净;git cliff --tag v0.1.0 --unreleased 预览正常。

Step 5:首次发版(端到端验证)

工作树干净后,对 agent 说「发布」。走完整个 §4 流程,确认 4 件事全绿:

  1. CHANGELOG.md 生成,含 AI 摘要 + 分组
  2. 版本源文件已同步(如有)
  3. git tag 列出新 tag
  4. push 后 GitHub Actions 出现 Release workflow 运行 → GitHub Releases 页出现新 Release

9. 设计要点总结

  1. 混合生成:git-cliff 出结构骨架(分组/链接),agent 后处理注入 AI 摘要——纯 CI 方案做不到,纯手写太累
  2. --prepend 策略 + 生成后校验:CHANGELOG 永不全量重生成,每次只前置插入新段;生成后用版本数校验防覆盖(§4.3)
  3. 版本号同步模型随仓库结构而定:单组件仓库用三同步(§5.2),多组件仓库用两层模型——全局版每次 bump,组件版改谁 bump 谁(§5.3)
  4. 兜底优先body = ".*" 兜底 parser + split(pat="\n") | first 取首行,保证任何历史提交都不丢且渲染干净(§3.3–§3.4)
  5. CI 最小化:GitHub Action 只做切片 + 建 Release,不生成内容,无状态、可复现(§6)

这套体系的核心哲学:结构交给工具(git-cliff),语义交给 AI(摘要),决策交给人(确认)。三者各司其职,发版从「手工拼凑」变成「一句话 + 一次确认」。


附录 A:release skill 完整模板(可直接复制)

以下是完整的、可复制的 release skill 文件。复制后只需替换顶部 3 个 <...> 占位符即可使用。本模板面向 ZCode / Claude Code 的 skill 格式(YAML frontmatter + Markdown body);其他 agent 平台可按 §4.1 的流程等价实现,或直接用 shell 脚本包装下面的命令。

使用前替换这 3 处占位符:

占位符含义示例
<OWNER>/<REPO>GitHub 仓库(compare/commit 链接用)BingqiangZhou/Skills
<VERSION_FILE>版本源文件路径(无则删 Step 3.5)plugins/daily-digest/.claude-plugin/plugin.json
<WATCH_DIR>release 预览时要分析的「业务变更目录」plugins/daily-digest/skills/
---
name: release
version: "1.0"
description: 项目发布工具。分析 git 历史、通过 git-cliff 生成 CHANGELOG.md、同步版本号、创建 git tag 并推送。推送后 GitHub Action 自动创建 Release。基于语义化版本号。**触发场景**:用户提到"发布""release""发版""打 tag""生成 changelog""更新版本""创建 release",或需要将当前项目状态发布为新版本时使用。
---

# Release — 项目发布

一键发布流程:分析变更 → 生成 CHANGELOG → 同步版本号 → 创建 tag → 推送 → GitHub Action 自动创建 Release。

## 前置工具

| 工具 | 用途 | 安装 |
|------|------|------|
| `git-cliff` | 生成 CHANGELOG | `winget install git-cliff` |

GitHub Release 由 `.github/workflows/release.yml` 自动创建,无需本地 `gh` CLI。

## 工作流

按顺序执行。**Step 1 和 Step 2 完成后展示摘要,等待用户确认再继续。**

### Step 0: 前置检查

逐项验证,失败则中止并提示用户处理:

1. **git-cliff**: `git cliff --version`,未安装则提示安装命令,中止
2. **工作树干净**: `git status --porcelain`,有输出则提示先 commit 或 stash,中止

### Step 1: 确定版本号

采用语义化版本 `v<MAJOR>.<MINOR>.<PATCH>`

1. **获取上一个版本号**
   ```bash
   git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1

如果无 tag(首次发布),从版本源文件读取(去 v 前缀):

grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' <VERSION_FILE> | grep -o '"[0-9][^"]*"$' | tr -d '"'

若无版本源文件,询问用户首版号(默认 v0.1.0)。

  1. 按变更规模递增(参考 Step 2 分析):

    • PATCH: 仅修 bug、文档、chore、配置同步
    • MINOR: 新增功能、能力扩展
    • MAJOR: 架构级重构、不兼容变更(罕见)
  2. 检查 tag 是否已存在,冲突则 PATCH 末位 +1:

    git tag -l "v<新版本号>"

Step 2: 预览变更

  1. 找到上一个 tag(无 tag 则用初始 commit):

    git tag --sort=-creatordate | head -1 || git rev-list --max-parents=0 HEAD
  2. 生成 changelog 预览(终端输出,不写文件):

    git cliff --tag <VERSION> --unreleased
  3. 统计 commits

    git rev-list <PREV>..HEAD --count

    首次发布(PREV 是初始 commit)则 git rev-list <INIT>..HEAD --count

  4. 分析业务变更(替换 <WATCH_DIR> 为实际目录,无则跳过):

    git ls-tree -d --name-only <PREV> <WATCH_DIR> 2>/dev/null
    git diff <PREV>..HEAD --stat -- '<WATCH_DIR>'
  5. 展示发布摘要,格式:

    📦 Release <VERSION> Summary
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━
    Previous: <PREV_TAG>
    Commits:  <N>
    
    Changes:
      <变更摘要>
    
    Output:
      [x] CHANGELOG.md
      [x] 版本源文件 → <VERSION> (如有)
      [x] Git tag <VERSION>
      [x] Push to origin → GitHub Action auto-creates Release
    
    确认发布?(y/n)
  6. 等待用户确认(可确认 / 改版本号 / 取消)

Step 3: 生成 CHANGELOG.md

  1. 检查是否已存在:

    test -f CHANGELOG.md && echo exists || echo not-exists
  2. 不存在(首次):git cliff --tag <VERSION> -o CHANGELOG.md

  3. 已存在(后续):git cliff --tag <VERSION> --prepend CHANGELOG.md

    ⚠️ 切勿对已存在的 CHANGELOG.md 使用 -o(覆盖写入)! -o 会用新版本内容整文件覆盖,丢失所有历史版本。必须用 --prepend。 详见 §4.3。

  4. 校验历史版本未丢失(生成后必须执行):

    echo "CHANGELOG 版本数: $(grep -c '^## v' CHANGELOG.md)"
    echo "Git tag 数:       $(git tag --list 'v*' | wc -l)"

    两个数字必须相等。若 CHANGELOG 版本数 < tag 数,说明历史被覆盖, 立即 git checkout CHANGELOG.md 恢复后用 --prepend 重试。

  5. 生成 AI 摘要,替换 <!-- AI_SUMMARY --> 占位符。格式:

    > <2-3 句自然语言总结,概括本次发布最核心的变化>
    >
    > 共 <N> commits,其中 🚀 Features <N> | 🐛 Fixes <N> | 📝 Docs <N> | ...
    >
    > **[Full diff](https://github.com/<OWNER>/<REPO>/compare/<PREV>...<VERSION>)**

    首次发布无 PREV 时,链接用 https://github.com/<OWNER>/<REPO>/commits/<VERSION>

  6. 暂存:git add CHANGELOG.md

Step 3.5: 同步版本源文件(如有)

把 <VERSION_FILE> 的 version 更新为(去 v 前缀):

# JSON 格式(package.json / plugin.json 等)
sed -i 's/"version":[[:space:]]*"[0-9][^"]*"/"version": "<VERSION_WITHOUT_V>"/' <VERSION_FILE>
# TOML 格式(Cargo.toml / pyproject.toml 等)——注意只改 [package] 段的 version

校验只动了 version 行:git diff <VERSION_FILE> 暂存:git add <VERSION_FILE>

若项目无版本源文件(仅用 tag),跳过本步。

多组件仓库(monorepo / 多 skill):除顶层版本源外,还需检测并 bump 有改动的组件, 只 bump 变动的、不动未改的。详见 §5.3。

Step 3.6: 提交

git commit -m "docs: release <VERSION>"

Step 4: 创建 Tag 并推送

git tag -a <VERSION> -m "Release <VERSION>"
git push origin HEAD --tags

push 失败则提示用户稍后手动推送:git push origin HEAD --tags

完成

✅ Release <VERSION> 发布完成
   CHANGELOG.md: 已更新
   版本源文件:   version → <VERSION_WITHOUT_V> (如有)
   Git tag:      <VERSION>
   GitHub Release: 等待 GitHub Action 自动创建
   查看: https://github.com/<OWNER>/<REPO>/actions

错误处理

场景处理
git-cliff 未安装中止,提示安装命令
工作树有未提交变更中止,提示 commit 或 stash
tag 已存在PATCH 末位 +1 直到不冲突
push 失败提示手动推送 git push origin HEAD --tags
无上一个 tag从版本源文件读首版 / 询问用户,用初始 commit 全量生成
0 commits since last tag中止:“No new commits since. Nothing to release.”

> **agent 搭建时的提示**:复制本附录到一个新 `.md` 文件后,用编辑器的全局替换功能把 3 个占位符换掉即可。占位符在 frontmatter 里没有(保持通用),在 body 的命令和链接里出现——逐个确认替换完整,别遗漏 `compare/` 和 `commit/` 两类链接里的 `<OWNER>/<REPO>`。