快轉到主要內容
  1. 教學文章/

Git Changelog 自動化:從 Commit、PR 到 Release Notes

·5 分鐘· loading · loading · ·
Git GitHub Changelog Release Notes Automation GitHub Actions Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
版本控制: Git - 本文屬於一個選集。
§ 16: 本文

featured

一. 前言:Commit Log 不是給使用者看的
#

版本要發了,你執行:

git log --oneline v1.7.0..HEAD

然後得到:

9a3c121 fix lint
4be02d8 merge main
08ec733 wip
7d9fe11 refactor helper
15cf820 add csv export (#184)

開發者看得懂,使用者卻只想知道:新增了什麼、修掉哪些問題、 有沒有不相容變更,以及是否需要遷移設定。

拍拍君今天不重新教 tag 或 SemVer。 不熟版本邊界,可以先看 Git tag 與 Release 實戰; PR metadata 還很混亂,先補 GitHub Pull Request 實戰

這篇只做一件事:把 commit 與 PR 資訊,穩定組裝成可檢查的 changelog。

流程分成三層:

  1. 用 Git 固定版本範圍,保存可重跑的原始紀錄;
  2. 用規則或 PR label 分類,並讓漏項可見;
  3. 產生 Markdown,在 CI 檢查重複、缺漏與 placeholder。

二. 先定義輸入與輸出契約
#

2.1 輸入必須有明確版本範圍
#

不要用「最近 30 天」當 release 範圍。 時區、延遲合併與補發都可能造成重複或遺漏。 比較可靠的輸入是:

previous_ref..current_ref

例如 v1.7.0..v1.8.0:左邊不包含,右邊包含。 先確認兩者都能解析成 commit:

git rev-parse --verify "v1.7.0^{commit}"
git rev-parse --verify "v1.8.0^{commit}"

2.2 輸出要能回頭稽核
#

每一項 change 至少保留類別、摘要、commit hash 或 PR 編號、 可追溯連結,以及是否為 breaking change。 呈現可以簡潔,底層資料不能只剩一段散文。

三. 用 Git 產生穩定的原始清單
#

3.1 --first-parent 適合主線工作流
#

git log \
  --first-parent \
  --format='%H%x09%h%x09%aI%x09%s' \
  v1.7.0..v1.8.0

--first-parent 沿著主線第一個 parent 走, 不會把 merge 進來的 feature branch 每顆小 commit 全部攤開。 使用前要確認團隊策略:

  • squash merge:主線通常一個 PR 一顆 commit,很適合;
  • merge commit:能保留合併邊界,也適合;
  • rebase merge:主線沒有 merge commit,仍會看到每顆 commit;
  • 直接 push:品質完全取決於 commit message。

格式中的 %x09 是 Tab。 Subject 可能有空白、冒號或括號,別用普通空格拆欄位。

3.2 保存 raw input,再產生文件
#

mkdir -p build/release
git log \
  --first-parent \
  --format='%H%x09%h%x09%aI%x09%s' \
  "$PREVIOUS_REF..$CURRENT_REF" \
  > build/release/commits.tsv

同一組 refs 應得到同一組 commit。 若結果改變,優先檢查 tag 是否移動、history 是否重寫, 而不是怪罪 Markdown 模板。

四. 用 Conventional Commit 做第一層分類
#

即使團隊沒有完整採用規範,這些 prefix 仍很實用:

feat(export): add CSV streaming mode
fix(auth): refresh expired session
docs: explain proxy settings
chore(deps): bump httpx
feat(api)!: remove legacy endpoint
Prefix Release 類別 預設公開?
feat Added
fix Fixed
perf Performance
refactor Changed 視情況
docs Documentation 視產品
chore / ci / test Maintenance 通常否

Prefix 是線索,不是事實。 一顆名為 chore 的 commit 仍可能改壞 API, breaking change 必須有額外檢查。

五. 寫一個可測試的 Markdown 產生器
#

建立 scripts/build_changelog.py

from __future__ import annotations
import argparse
import re
from dataclasses import dataclass
from pathlib import Path
PATTERN = re.compile(
    r"^(?P<kind>[a-z]+)(?:\((?P<scope>[^)]+)\))?"
    r"(?P<breaking>!)?:\s+(?P<summary>.+)$"
)
CATEGORIES = {
    "feat": "Added",
    "fix": "Fixed",
    "perf": "Performance",
    "refactor": "Changed",
    "docs": "Documentation",
}
@dataclass(frozen=True)
class Change:
    full_hash: str
    short_hash: str
    category: str
    summary: str
    breaking: bool
def parse(line: str) -> Change | None:
    parts = line.rstrip("\n").split("\t", maxsplit=3)
    if len(parts) != 4:
        raise ValueError(f"invalid TSV row: {line!r}")
    full_hash, short_hash, _date, subject = parts
    match = PATTERN.match(subject)
    if match is None or match["kind"] not in CATEGORIES:
        return None
    summary = match["summary"].strip()
    if match["scope"]:
        summary = f"**{match['scope']}:** {summary}"
    return Change(
        full_hash, short_hash, CATEGORIES[match["kind"]],
        summary, bool(match["breaking"])
    )
def render(changes: list[Change], repo_url: str) -> str:
    lines = ["# Release notes", ""]
    groups = ["Breaking changes", *CATEGORIES.values()]
    for group in groups:
        selected = [
            item for item in changes
            if (group == "Breaking changes" and item.breaking)
            or (group == item.category and not item.breaking)
        ]
        if not selected:
            continue
        lines.extend([f"## {group}", ""])
        for item in selected:
            url = f"{repo_url}/commit/{item.full_hash}"
            lines.append(f"- {item.summary} ([`{item.short_hash}`]({url}))")
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"
def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("input", type=Path)
    parser.add_argument("output", type=Path)
    parser.add_argument("--repo-url", required=True)
    args = parser.parse_args()
    changes = [
        change for line in args.input.read_text().splitlines(True)
        if (change := parse(line)) is not None
    ]
    if not changes:
        raise SystemExit("no public changes found")
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(render(changes, args.repo_url.rstrip("/")))
if __name__ == "__main__":
    main()

執行:

python scripts/build_changelog.py \
  build/release/commits.tsv \
  build/release/RELEASE_NOTES.md \
  --repo-url https://github.com/acme/widget

這個版本刻意不公開 chorecitest, 也不猜測無法分類的 commit;下一步要把它們變成可見訊號。

六. 不要讓未分類項目靜默消失
#

Release automation 最危險的 bug 不是 crash, 而是成功產生了一份漏東漏西的文件。

git log \
  --first-parent \
  --format='%s' \
  "$PREVIOUS_REF..$CURRENT_REF" \
  | grep -Ev '^(feat|fix|perf|refactor|docs|chore|ci|test)(\([^)]+\))?!?: '

可以讓 CI 直接失敗、產生 Uncategorized 交給 release owner, 或用小型 allowlist 忽略明確的 merge noise。 拍拍君偏好第二種再加人工 gate:自動化負責找齊資料, 人類負責判斷語意。

七. PR Metadata 比 Commit Subject 更完整
#

Commit 通常沒有 breaking-change 說明、issue、label 與 reviewer。 若專案在 GitHub 上,可以把 PR label 當主要分類來源:

  • type: feature
  • type: bug
  • type: breaking
  • area: api
  • skip-changelog

規則要互斥或有明確優先序。 例如 type: breaking 應先於 type: feature, 否則同一個 PR 可能出現在兩個區塊。

八. 設定 GitHub 自動產生 Release Notes
#

GitHub 會讀取 .github/release.yml, 依 PR label 與作者排除規則分類:

changelog:
  exclude:
    labels:
      - skip-changelog
  categories:
    - title: "Breaking changes"
      labels: ["type: breaking"]
    - title: "New features"
      labels: ["type: feature"]
    - title: "Bug fixes"
      labels: ["type: bug"]
    - title: "Maintenance"
      labels: ["dependencies", "documentation"]
    - title: "Other changes"
      labels: ["*"]

Catch-all "*" 很重要,能讓漏貼 label 的 PR 仍然可見。 不要急著排除所有 bot;依賴安全更新也值得進 release notes。 通常以 skip-changelog 明確排除,比按作者整批隱藏更穩。

九. 在發布前呼叫 Generate Notes API
#

GitHub Releases REST API 提供 generate-notes endpoint。 它會產生預覽,不會替你建立 Release:

gh api \
  --method POST \
  repos/{owner}/{repo}/releases/generate-notes \
  -f tag_name="$CURRENT_REF" \
  -f previous_tag_name="$PREVIOUS_REF" \
  -f target_commitish='main' \
  > build/release/generated.json

jq -r '.body' \
  build/release/generated.json \
  > build/release/github-notes.md

API 回傳包含 namebody。 保存完整 JSON,再抽出 Markdown,才能檢查 schema 與原始內容。 非預設設定檔可傳:

-f configuration_file_path='.github/custom_release_config.yml'

十. 在 CI 只做 Preview
#

第一版 workflow 先產生 artifact,不直接發布:

name: Preview release notes
on: workflow_dispatch
permissions:
  contents: read
jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with: { fetch-depth: 0 }
      - run: ./scripts/export_commits.sh "$PREVIOUS_REF" "$CURRENT_REF"
      - run: python scripts/build_changelog.py build/release/commits.tsv build/release/RELEASE_NOTES.md --repo-url "https://github.com/${GITHUB_REPOSITORY}"
      - uses: actions/upload-artifact@v4
        with: { name: release-notes-preview, path: build/release/ }

workflow_dispatch 的 inputs 可分別提供 PREVIOUS_REFCURRENT_REFexport_commits.sh 內仍要執行前面的 ref 驗證與 git log

先不自動發布,因為 breaking change 可能需要 migration 指示, 安全性問題可能要協調揭露時間,文案也需要 release owner 確認。 把「產生」「驗證」「發布」拆開,權限邊界會清楚很多。

十一. 讓 CI 對漏項大聲失敗
#

空區間與 placeholder 都應該阻擋流程:

test -s build/release/commits.tsv || {
  echo "No commits in requested range" >&2
  exit 1
}

if grep -En 'TODO|TBD|待補|待確認' build/release/RELEASE_NOTES.md; then
  echo "Release notes contain placeholders" >&2
  exit 1
fi

再檢查每個公開項目都有 commit 連結,且沒有重複 reference:

from collections import Counter
from pathlib import Path
import re

text = Path("build/release/RELEASE_NOTES.md").read_text()
items = [line for line in text.splitlines() if line.startswith("- ")]
missing = [line for line in items if "/commit/" not in line]
refs = re.findall(r"/commit/([0-9a-f]{7,40})", text)
duplicates = [ref for ref, count in Counter(refs).items() if count > 1]

if missing or duplicates:
    raise SystemExit({"missing": missing, "duplicates": duplicates})

若以 PR metadata 為主,就改成要求 /pull/<number>

十二. 發布檢查表
#

  • 舊、新 refs 都能解析成 commit
  • fetch depth 與 --first-parent 假設符合團隊策略
  • raw input 與 Markdown 都保存為 artifact
  • breaking changes 有 migration 指示
  • 每一項都能連回 commit 或 PR
  • 沒有重複 reference 或 placeholder
  • Uncategorized 已處理,tag、commit SHA、artifact 一致
  • 發布權限只存在最後一步

結語
#

Changelog 自動化不是把 git log 換成漂亮模板。 真正重要的是一條可重跑、可追溯、會對漏項大聲失敗的管線:

  1. 用 refs 固定輸入範圍;
  2. 保存 raw commit 或 PR metadata;
  3. 用明確規則分類,保留 catch-all;
  4. 檢查連結、重複與 placeholder;
  5. 先預覽,再由有權限的最後一步發布。

當 release notes 能從同一組 evidence 重建, 版本說明就不再是發版前一小時的考古活動。 拍拍君少翻幾頁 commit history,你也少漏一條 breaking change。📝

延伸閱讀
#

版本控制: Git - 本文屬於一個選集。
§ 16: 本文

相關文章

Git tag 與 Release 實戰:版本標記、SemVer 與安全發佈流程
·8 分鐘· loading · loading
Git Tag Release SemVer Versioning GitHub Developer-Tools
Git rerere 實戰:記住衝突解法,讓 Rebase 與 Merge 不再重做
·8 分鐘· loading · loading
Git Rerere Merge-Conflict Rebase Merge Version-Control Developer-Tools
Git reset、restore、revert 實戰:選對復原工具
·9 分鐘· loading · loading
Git Reset Restore Revert Undo Version-Control Developer-Tools
GitHub Pull Request 實戰:Draft、Review、Checks 與安全合併
·9 分鐘· loading · loading
GitHub Pull Request Code Review Branch Protection Ci Git
uv + GitHub Actions 實戰:Lockfile、Cache 與可重現 CI
·5 分鐘· loading · loading
Python Uv GitHub Actions Ci Lockfile Cache Developer-Tools
Python shutil 實戰:檔案複製、搬移、壓縮與安全清理
·7 分鐘· loading · loading
Python Shutil Filesystem Automation Standard-Library Developer-Tools