Claude Media
Claude Codeでsemantic-releaseを回す — コミットの型がバージョンを決める

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つあります。

  1. 型の一覧を閉じます。一覧に無いupdate:やwip:を使わせません
  2. 破壊的変更の書式を1つに絞ります。プリセットが読める書き方だけを書きます
  3. 実行してはいけないコマンドも書きます。リリースは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は必要かで整理しています。

つまずきやすい点

切り分け

次のバージョンが期待と違うとき

  1. Qdry-runで「Analyzing commit」にそのコミットが出ているか?
    答えが出ていない

    前回のタグがリリースブランチの履歴に含まれているかをgit tag --contains <SHA>で調べます。無ければgit tag v1.1.0 <SHA>で付け直します。

    答えがはい
  2. Qコミットの型は規約どおりか?
    答えが違う

    update:のように一覧外の型は、どのルールにも当たらずリリースになりません。

    答えがはい
  3. 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!:が静かにリリースから落ちます。

この記事を共有:XはてブLinkedIn