一. 前言: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。
流程分成三層:
- 用 Git 固定版本範圍,保存可重跑的原始紀錄;
- 用規則或 PR label 分類,並讓漏項可見;
- 產生 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
這個版本刻意不公開 chore、ci、test,
也不猜測無法分類的 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: featuretype: bugtype: breakingarea: apiskip-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 回傳包含 name 與 body。
保存完整 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_REF 與 CURRENT_REF;
export_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 換成漂亮模板。
真正重要的是一條可重跑、可追溯、會對漏項大聲失敗的管線:
- 用 refs 固定輸入範圍;
- 保存 raw commit 或 PR metadata;
- 用明確規則分類,保留 catch-all;
- 檢查連結、重複與 placeholder;
- 先預覽,再由有權限的最後一步發布。
當 release notes 能從同一組 evidence 重建, 版本說明就不再是發版前一小時的考古活動。 拍拍君少翻幾頁 commit history,你也少漏一條 breaking change。📝