Claude Codeでsemantic-releaseを回す — コミットの型がバージョンを決める
semantic-releaseはコミット文面から次のバージョンを決めます。Claude Codeに書かせるコミットの型をCLAUDE.mdとhookで揃え、dry-runで確かめ、公開はCIに任せる手順をまとめます。
semantic-releaseは、前回のリリース以降のコミットメッセージを読み、次のバージョン番号を自動で決めます。Claude Codeにコミットを任せると、その1行の書き方がそのままバージョンの上がり方になります。fix:と書くかfeat:と書くかで、patchにもminorにもなるわけです。
この記事では、Claudeが書くコミットの型を揃える方法を扱います。CLAUDE.mdの規約、コミット前にメッセージを検査するhook、--dry-runで次のバージョンを読ませる権限設定、公開をCIに任せる構成の順で進めます。途中、手元の検証環境で見つけた「既定の設定ではfeat!:が無視される」挙動も実測の出力つきで載せます。
コミットの1行が次のバージョンを決める
semantic-releaseは、Gitタグを手がかりに「前回のリリース以降に増えたコミット」を割り出します。そのコミットをプラグインが解析し、リリースの種類(major / minor / patch / なし)を返す流れです。既定のプラグインは@semantic-release/commit-analyzer、@semantic-release/release-notes-generator、@semantic-release/npm、@semantic-release/githubの4つで、順番に実行されます。
人がコミットを書く場合は、規約を覚えていれば足ります。Claude Codeが書く場合は事情が変わります。
- 修正のつもりで書いた
update:は、既定ルールに載っておらずリリースにならない - 内部整理の
refactor:も同様にリリースにならない - 新機能のつもりの
feat:は、中身が小さくても必ずminorになる - 破壊的変更の印を付け忘れると、majorであるべき変更がminorやpatchで出る
どれも「Claudeが間違えた」というより、規約が伝わっていないだけで起きます。コミット規約をClaudeに渡す価値が、ここにあります。
Claudeに任せる部分と任せない部分
任せる
コミットメッセージの型の選択、
BREAKING CHANGE:フッターの記述、--dry-runでの次バージョンの確認。人が持つ
branchesとプラグインの設定、CIの認証情報、実際のリリース実行。
CIが持つ
テスト通過後の
npx semantic-release。タグ付け、リリースノート、公開。
changesetsのようにバージョンの意図をファイルに残す方式との違いは、意図の置き場所です。semantic-releaseではコミット履歴そのものが入力になります。changesets側の運用はClaude CodeでChangesetsを回す手順にあります。
既定のルールで何が上がるか
@semantic-release/commit-analyzerの既定プリセットはangularです。コミットはそれぞれルールと突き合わされ、複数のルールに当たったときはmajor > minor > patchの最も高いものが採用されます。既定ルールのうち、Angular系の部分は次のとおりです。
| コミット | 既定の判定 |
|---|---|
| 破壊的変更を含むコミット | 既定の判定major |
feat | 既定の判定minor |
fix | 既定の判定patch |
perf | 既定の判定patch |
| revertのコミット | 既定の判定patch |
スコープがno-release | 既定の判定リリースなし(featでもfixでも) |
| どのルールにも当たらないもの | 既定の判定リリースなし |
refactor、docs、chore、testは、既定ルールの一覧にAngular系として載っていません。そのため何も指定しなければ、これらだけのコミットではリリースが起きません。
破壊的変更の書き方には注意が要ります。コミットは選んだ規約に正確に従う必要があり、Angularのコミット規約ではBREAKING CHANGEの後ろにコロンを付け、フッターに置きます。BREAKING CHANGE: 出力形式を変更のような1行を、本文の末尾に独立した段落として書くのが安全です。
実際に試すと、feat!は既定では無視される
破壊的変更を示す書き方には、フッターのほかにfeat!:のように型の後ろへ!を付ける形もあります。コミット規約でよく見かける書き方なので、既定の設定でどう扱われるかを検証用リポジトリで試しました。
条件は次のとおりです。semantic-release 24.2.9、Node.js v20.18.0、プラグインは@semantic-release/commit-analyzerのみ、リリースブランチはmainで、直前のタグはv1.0.0です。
git commit -m "feat!: drop v1 flag"
npx semantic-release --dry-run --no-ci出力は次のとおりです(関係する行だけ抜粋)。
Analyzing commit: feat!: drop v1 flag
The commit should not trigger a release
Analysis of 1 commits complete: no release
There are no relevant changes, so no new version is released.feat!:のコミットが、リリースを起こさないと判定されました。既定のangularプリセットは、型の後ろの!を破壊的変更の印として扱いません。一方、フッターでBREAKING CHANGE:を書いたfeat:は、同じ環境でmajorと判定されました。
プリセットをconventionalcommitsに切り替えると、同じコミットの判定が変わります。
npm install -D conventional-changelog-conventionalcommits{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }]
]
}Analyzing commit: feat!: drop v1 flag
Analysis of 1 commits complete: major release
The next release version is 2.0.0プリセットの候補はangular、atom、codemirror、ember、eslint、express、jquery、jshint、conventionalcommitsです。このうち、ここで比べているのはangular(既定)とconventionalcommitsの2つです。
設定ファイルで規約を固定する
規約はClaudeに頼む前に、リポジトリ側で決めておきます。設定は.releaserc系のファイル、release.config.(js|cjs|mjs)、package.jsonのreleaseキーのいずれかに置けます。package.jsonに書くときだけreleaseの下に入れ、それ以外のファイルではreleaseキーなしで書きます。
{
"branches": ["main"],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits",
"releaseRules": [
{ "type": "refactor", "release": "patch" },
{ "type": "docs", "scope": "README", "release": "patch" },
{ "scope": "no-release", "release": false }
]
}
],
"@semantic-release/release-notes-generator",
"@semantic-release/github"
]
}この例のreleaseRulesは、refactorをpatchに、スコープがREADMEのdocsをpatchに引き上げます。release: falseのルールでno-releaseスコープは何があってもリリースなしです。独自ルールに当たらなかったコミットは、既定ルールで評価されます。
branchesの既定はmaster、main、next、next-major、beta、alphaと、N.N.x形式の保守ブランチです。リリース用のブランチが1本も無いとERELEASEBRANCHESで失敗します。既定の構成なら、mainかmasterをpushすれば解消します。
CLAUDE.mdに書くコミット規約
設定が固まったら、同じ内容をClaudeの言葉に直してCLAUDE.mdへ置きます。AGENTS.mdと併用している場合の統合はAGENTS.mdとCLAUDE.mdの統合パターンが参考になります。
## コミット規約(semantic-releaseが読む)
コミットの1行目は `type(scope): subject` の形にする。
type は次の中から選ぶ。
- feat: 利用者に見える新機能(minor になる)
- fix: 不具合の修正(patch になる)
- perf: 性能改善(patch になる)
- refactor / docs / test / chore / ci / build: 内部変更(既定ではリリースにならない)
破壊的変更は、型に `!` を付けず、本文の末尾に独立した段落で
`BREAKING CHANGE: 何がどう変わるか` と書く。
リリースを起こしたくない変更には、スコープに `no-release` を付ける。
`npx semantic-release` を実行しない。確認するときは
`npx semantic-release --dry-run --no-ci` だけを使う。
リリースブランチでは rebase / amend / force push をしない。ポイントは3つあります。
- 型の一覧を閉じます。一覧に無い
update:やwip:を使わせません - 破壊的変更の書式を1つに絞ります。プリセットが読める書き方だけを書きます
- 実行してはいけないコマンドも書きます。リリースはCIの仕事だと明示します
最後の行のrebase禁止には理由があります。semantic-releaseはGitタグから「前回のリリースに含まれる最後のコミット」を探します。そのコミットがリリースブランチの履歴に無いと、前回のリリースを見失います。rebaseでコミットのSHAが書き換わると、まさにこの状態になります。
hookでコミット文面を事前に止める
CLAUDE.mdは守られるのが前提のお願いです。型を外したコミットを確実に止めたいなら、PreToolUse hookでメッセージを検査します。ifフィールドでgit commitのときだけスクリプトを起動できます。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-commit-message.sh",
"args": []
}
]
}
]
}
}スクリプトは標準入力のJSONからtool_input.commandを取り出し、コミットの1行目が規約に合うかを調べます。合わなければpermissionDecisionをdenyにして理由を返します。
#!/bin/bash
# .claude/hooks/check-commit-message.sh
CMD=$(jq -r '.tool_input.command')
SUBJECT=$(printf '%s' "$CMD" \
| grep -oE '(feat|fix|perf|refactor|docs|test|build|ci|chore|revert)(\([a-z0-9-]+\))?!?: [^"'\'']+' \
| head -1)
if [ -z "$SUBJECT" ] || printf '%s' "$SUBJECT" | grep -qE '^[a-z]+(\([^)]*\))?!:'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "type(scope): subject の形で書く。破壊的変更は ! でなく本文の BREAKING CHANGE: で示す"
}
}'
fi実行権限はchmod +xで付けます。jqが必要です。このスクリプトに4種類のコマンドを流すと、次のように判定されました。
| コマンド | 結果 |
|---|---|
git commit -m "fix: handle empty input" | 結果通す |
git commit -m "feat!: drop v1 flag" | 結果拒否 |
git commit -m "update stuff" | 結果拒否 |
ヒアドキュメントでfeat(cli): add exportとBREAKING CHANGE:を渡す | 結果通す |
!の拒否は、プリセットがangularのときの設定です。conventionalcommitsを選ぶなら、!?の判定行を外します。また、この検査はコマンド文字列からの抽出なので、-mの指定方法が凝ったコミットには取りこぼしがあり得ます。型の抜けを見つける網であって、文面の正しさまでは保証しません。
hookがdenyを返すと、そのコミットのツール呼び出しはブロックされます。メッセージを直してコミットし直せば通ります。
dry-runで次のバージョンを読ませる
--dry-runは、リリースの準備、公開、チャンネル追加、成功通知、失敗通知の各ステップを飛ばし、次のバージョンとリリースノートを標準出力に出します。ローカルではdryRunの既定がtrueで、CI環境ではfalseです。ただしCIの検証を通すには--no-ciも要ります。リポジトリへのpush権限の検証だけは、dry-runでも行われます。
Claudeに確認作業を任せるなら、許可はこの形に絞ります。
{
"permissions": {
"allow": [
"Bash(npx semantic-release --dry-run *)",
"Bash(git log *)",
"Bash(git tag --list *)"
],
"deny": [
"Bash(git push --force *)",
"Bash(git rebase *)"
]
}
}Bash(npx semantic-release --dry-run *)は、末尾の*の前に空白があるので、--dry-run単体にも--dry-run --no-ciにも当たります。それ以外のnpx semantic-releaseは許可に当たらないので、通常の権限判定に回ります。
denyとallowの関係には注意してください。広いdenyは、より狭いallowがあっても優先されます。そのためnpx semantic-release全体をdenyにすると、dry-runまで巻き込んで止まります。ここでは「allowしない」ことで本番実行を止めています。複合コマンドは、区切られた各部分が個別にルールに当たる必要があります。
次のように頼むと、Claudeは変更の種類を確かめてから動けます。
今のブランチを main にマージしたら次のバージョンはいくつになるか、
npx semantic-release --dry-run --no-ci で確かめて。
patch のつもりの変更が minor になっていたら、該当コミットを教えて。dry-runの出力には「Analyzing commit」の行が並ぶので、Claudeはどのコミットがどの判定になったかを読み取れます。期待より高いバージョンが出たときは、feat:が混ざっていないかを探せます。
公開はCIに任せる
実際のリリースは、テストが全部通った後にCIでnpx semantic-releaseを実行する構成にします。複数のジョブでテストする場合は、全ジョブの成功後に動くようにします。
GitHub Actionsの場合、リリースジョブの要点は次のとおりです。
release:
needs: verify
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: "lts/*"
- run: npm clean-install
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}fetch-depth: 0で全履歴を取得するのが肝心です。浅いcloneだと、前回のタグ以降のコミットを辿れません。id-token: writeはnpmのtrusted publishing用で、NPM_TOKENを使う場合は不要です。どちらの認証にするかは、リリースジョブごとに1つ選びます。
ブランチ保護を有効にしている場合は、自動で入るGITHUB_TOKENが使えません。その場合はGitHub Appのトークンを使う構成が向いています。個人用アクセストークンは漏えい時の影響が大きく、避けたほうが無難です。
認証情報はCIサービスの環境変数に置きます。GH_TOKENやNPM_TOKENをClaudeが触れる開発環境に置かなければ、Claudeがうっかり公開まで進める経路も残りません。
実行環境のNodeにも条件があります。semantic-release 24.2.9は手元のNode.js v20.18.0で動きましたが、最新の25.0.9はNodeの要件が^22.14.0 || >= 24.10.0に上がっており、v20では動きません。
リリースジョブのNodeは、検証ジョブとは別のバージョンでも構いません。プロジェクト側のNodeの扱いはClaude CodeにNode.jsは必要かで整理しています。
つまずきやすい点
次のバージョンが期待と違うとき
- Qdry-runで「Analyzing commit」にそのコミットが出ているか?答えが出ていない答えがはい
前回のタグがリリースブランチの履歴に含まれているかを
git tag --contains <SHA>で調べます。無ければgit tag v1.1.0 <SHA>で付け直します。 - Qコミットの型は規約どおりか?答えが違う答えがはい
update:のように一覧外の型は、どのルールにも当たらずリリースになりません。 - Q破壊的変更の書式がプリセットに合っているか?答えが合っていない
angularでは
feat!:が無視されます。本文末尾のBREAKING CHANGE:(コロン付き、フッター)にします。--dry-runをもう一度回し、The next release versionの行で確かめます。
初めて導入するリポジトリで見落としやすいのが、既存のタグです。すでにリリース済みの場合、最後のリリースに含まれるコミットがリリースブランチの履歴にあり、vx.y.z形式のタグが付いている必要があります。既定のタグ形式はv${version}で、tagFormatで変えられます。ただしversion変数をちょうど1回含める必要があります。
もう1つは、スカッシュマージとの組み合わせです。リリースブランチの履歴に残るのはスカッシュ後のコミットなので、そのメッセージが規約を満たさなければ、PRの中で型をいくら整えても判定に反映されません。PRタイトルをコミットの1行目にする運用なら、PRタイトルもClaudeに書かせるときに同じ規約を渡します。
また、依存更新のPRは別の流れで入ります。RenovateやDependabotのコミットメッセージの型を揃える運用は、RenovateとDependabotのPRを検証する運用と合わせて決めると、依存更新だけが漏れる状況を避けられます。
まとめ
semantic-releaseでは、コミットの型が仕様書の役割を持ちます。Claude Codeに書かせるなら、規約をCLAUDE.mdに書き、hookで型の抜けを機械的に止め、--dry-runの結果を読ませて、公開そのものはCIだけに持たせる構成が噛み合います。
最初に決めるのは、破壊的変更の書式です。angularプリセットならBREAKING CHANGE:フッター、!記法を使いたいならconventionalcommitsプリセット。この選択を飛ばすと、Claudeが善意で書いたfeat!:が静かにリリースから落ちます。