きっかけ
新しいプロジェクトを始めるたびに同じ分岐に立つ。standard-version? semantic-release? changesets?どれも動く。どれも自分が必要としている以上のことをやる。タグを打って、バージョンを上げて、npm に publish して、PR を開いて、CI にフックして、そのために依存ツリーを引き込む。
実際に必要なのは、90% のケースでこれだけだ:
「最後のタグから HEAD までのマークダウンをくれ。GitHub Release に貼るから。」
バージョン計算は不要。publish も不要。PR ボットも不要。箇条書きだけほしい。
そこで changelog-gen を作った。1 バイナリ、npm ランタイム依存ゼロ、TypeScript 約 400 行。この記事はその設計解説と、もっと早く理解しておきたかったこと——git log --pretty=format: は立派な小さな DSL であり、git バイナリに shell out するのがほぼ常に正解だということ——について書く。
📦 GitHub: https://github.com/sen-ltd/changelog-gen
作ったもの
Conventional Commits(conventionalcommits.org)形式のコミットメッセージを解析し、CHANGELOG のマークダウン断片を生成する CLI。出力は markdown、json、plain の 3 形式に対応。ランタイム依存は git バイナリだけ。
問題の形
Conventional Commits のコミットサブジェクトは小さな文法を定義している:
<type>(<optional scope>)<optional !>: <description>
例:
feat: add greeting command
fix(parser): handle empty input
feat(api)!: rename v2 endpoints
docs: clarify --since default
CHANGELOG 生成器の仕事は正確に 3 つ:
- Read — 指定範囲のコミットを git から取得
- Parse — 各サブジェクトの Conventional 文法を解析
- Group — セクション(Features, Bug Fixes, Breaking Changes, ...)にまとめて描画
技術的なポイント
git log --pretty を DSL として使う
git log --pretty=format: が受け付けるフォーマット文字列は、ちゃんとした小さな DSL だ。% プレフィックスのトークンを書くと、git がコミットごとにその値を指定順で出力してくれる。
| トークン | 意味 |
|---|---|
%H |
完全ハッシュ |
%h |
短縮ハッシュ |
%an |
著者名 |
%s |
サブジェクト(1 行目) |
%b |
ボディ(2 行目以降) |
%b が改行を含みうるため、単純な \n 分割ではレコード境界が壊れる。解決策として、ASCII 制御文字の Unit Separator(\x1f)と Record Separator(\x1e)を区切りに使う:
const FIELD_SEP = '\x1f';
const RECORD_SEP = '\x1e';
const PRETTY_FORMAT =
`%H${FIELD_SEP}%h${FIELD_SEP}%an${FIELD_SEP}%s${FIELD_SEP}%b${RECORD_SEP}`;
パースは .split(RECORD_SEP) → .split(FIELD_SEP) で完了。ステートマシンもストリームパーサーも不要:
export function parseLog(stdout: string): RawCommit[] {
const trimmed = stdout.replace(new RegExp(`${RECORD_SEP}\\n?$`), '');
if (trimmed.length === 0) return [];
const records = trimmed.split(new RegExp(`${RECORD_SEP}\\n?`));
return records
.filter((r) => r.length > 0)
.map((r) => {
const [hash, shortHash, author, subject, body] = r.split(FIELD_SEP);
return { hash, shortHash, author, subject, body };
});
}
唯一の落とし穴は git log が各レコード末尾に \n を付加すること。分割前にこれを除去しないと空のレコードが生まれる。
Conventional Commits のパース
パーサーは単一の正規表現でサブジェクトを解析し、ボディを走査して BREAKING CHANGE: とイシュー参照を拾う:
const HEADER_RE =
/^(?<type>[a-zA-Z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?: (?<desc>.+)$/;
const BREAKING_RE =
/(?:^|\n)BREAKING[- ]CHANGE:\s*(.+?)(?:\n\n|$)/s;
const REF_RE =
/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|ref[s]?)\s+([^\s,.]+#\d+|#\d+|GH-\d+)/gi;
設計上のポイント:
- ヘッダー正規表現は description に対して greedy。
feat: add colon: to outputが正しくパースされる - 未知の type は
otherにフォールバック。wip: half doneのようなコミットでもクラッシュしない - Breaking 検出は
!マーカーとBREAKING CHANGE:フッターの両方に対応 - イシュー参照は重複排除される
グルーピングの判断
const DROPPED_TYPES = new Set(['chore', 'ci', 'build', 'style', 'test', 'refactor']);
リリースを読む人にとって、GitHub Action のバンプや Prettier でのフォーマットや 30 個のテスト追加は関係ない。CHANGELOG には載せない。もう一つの方針として、breaking コミットは Breaking Changes セクションとネイティブセクションの両方に掲載する。feat!: rename v2 endpoints は breaking でもあり feature でもある。
マークダウン描画
function formatMarkdown(sections: Section[], opts: FormatOptions): string {
if (sections.length === 0) {
return '## Changelog\n\n_No notable changes._\n';
}
const out: string[] = ['## Changelog\n'];
for (const section of sections) {
out.push(`### ${section.title}\n`);
for (const c of section.commits) {
const scope = c.scope ? `**${c.scope}:** ` : '';
const refs = c.refs.length > 0 ? ' (' + c.refs.join(', ') + ')' : '';
out.push(`- ${scope}${c.description} (${c.shortHash})${refs}`);
}
out.push('');
}
return out.join('\n').replace(/\n+$/, '\n');
}
短縮ハッシュを括弧で、スコープを太字で、イシュー参照を末尾に。手で書くリリースノートの書式に合わせている。
やらないこと
-
バージョンバンプしない。
package.jsonは読み書きしない - タグを作らない。タグのタイミングは人間が決めるもの
- Co-Authored-By トレーラー解析しない。著者はコミット著者から取る
- 巨大範囲のストリーミングなし。全部メモリ上で処理する
試してみる
docker build -t changelog-gen https://github.com/sen-ltd/changelog-gen.git
docker run --rm -v "$PWD":/work -w /work changelog-gen --since v1.0.0
Docker なしの場合:
git clone https://github.com/sen-ltd/changelog-gen
cd changelog-gen && npm install && npm run build
node dist/main.js --help
おわりに
最近小さな CLI を作るたびに同じパターンが勝つ:下にあるバイナリに shell out して、構造化出力をパースして、面白い仕事を自分の言語でやる。git log --pretty はそのための特に良い DSL で、トークンはバージョン間で安定し、ドキュメントもポータブル。
TypeScript 約 400 行、テスト 38 件、150 MB の Docker イメージ、npm ランタイム依存ゼロ。npm run changelog にエイリアスして、出力をリリースに貼って、次へ進む。
