Claude Media
Claude Code Actionで翻訳の差分だけ同期 — 変更キーのみ訳して検査

Claude Code Actionで翻訳の差分だけ同期 — 変更キーのみ訳して検査

ja.jsonを更新したPRで、変わったキーだけをClaude Code Actionにen.jsonとko.jsonへ訳させ、プレースホルダ検査に通ってからコミットするワークフローの組み方です。

多言語アプリの翻訳ファイルは、日本語の ja.json を直した瞬間に en.json が古くなります。毎回「全部訳し直して」と頼むと、直していない訳文まで揺れてレビューが膨らみます。

解決策は、PRで変わったキーだけを取り出してClaude Code Actionに渡し、訳した結果を検査に通してからPRにコミットする構成です。本記事では、変更キーの抽出スクリプト、プレースホルダを見るゲート、ワークフロー本体、CLAUDE.md の用語集の4点を順に組みます。

全体の流れ — 訳す前と後に、決まった処理を挟む

訳すのはClaudeですが、「何を訳すか」と「訳せているか」を決めるのは普通のスクリプトです。Claudeの前後を決定的な処理で挟むと、モデルの出来不出来がそのままPRに流れ込みません。

手順

PRが開かれてから翻訳コミットまで

  1. 1

    ja.jsonだけを監視する

    paths フィルタで locales/ja.json の変更に限定します。訳文のファイルを触ってもこのワークフローは起動しません。

  2. 2

    変更キーを抽出する

    PRの分岐元と現在の ja.json を比べ、追加・変更・削除されたキーを .i18n/changed.json に書き出します。

  3. 3

    Claudeが対象キーだけ訳す

    changed.json を読み、en.json と ko.json の該当キーだけを編集させます。

  4. 4

    ゲートで検査する

    訳し忘れ、対象外キーの書き換え、プレースホルダの改変があれば、ここでジョブを失敗させます。

  5. 5

    合格したらコミットする

    検査を通った差分だけを、PRのブランチにコミットして押し戻します。

Claudeに任せるのは3番目だけです。4番目のゲートがあるので、Claudeにコミットまで任せる必要はなく、不合格の訳文は履歴に残りません。

前提 — 先に揃えるもの

Claude CodeをGitHub Actionsに組み込むときの前提は、この構成でもそのまま必要です。セットアップには、リポジトリの管理者権限が要ります。ターミナルから /install-github-app を使う方法もありますが、これはgithub.comのリポジトリにしか使えません。手作業なら、Claude GitHub Appを入れ、認証用のシークレットを登録し、ワークフローファイルを置きます。

認証用のシークレットは2種類から選びます。

  • ANTHROPIC_API_KEY: Claude Consoleで発行したAPIキー。ワークフローでは anthropic_api_key に渡します
  • CLAUDE_CODE_OAUTH_TOKEN: Claudeのサブスクリプションで認証するトークン。ローカルで claude setup-token を実行して作り、claude_code_oauth_token に渡します

本記事のYAMLはAPIキーの形で書きます。サブスクリプションで認証する場合は、anthropic_api_key の行を claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} に置き換えます。

ファイル配置は次のとおりです。翻訳ファイルは言語ごとに1つのJSONで、日本語を原文とします。

locales/
  ja.json      # 原文(人が編集する)
  en.json      # Claudeが同期する
  ko.json      # Claudeが同期する
scripts/
  lib.mjs
  changed-keys.mjs
  gate.mjs
.github/workflows/sync-translations.yml

変更キーを取り出すスクリプト

差分の判定は、モデルに任せずスクリプトで決めます。原文を git show でPRの分岐元から取り出し、現在の ja.json と値を比べるだけです。まず共通部品を scripts/lib.mjs に置きます。

import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
 
export const flatten = (obj, prefix = '') =>
  Object.entries(obj).flatMap(([k, v]) =>
    typeof v === 'object' && v !== null
      ? flatten(v, `${prefix}${k}.`)
      : [[`${prefix}${k}`, String(v)]]);
 
export const atRef = (ref, file) => {
  try {
    return new Map(flatten(JSON.parse(
      execFileSync('git', ['show', `${ref}:${file}`], { encoding: 'utf8' }))));
  } catch { return new Map(); } // そのref時点でファイルが無い
};
 
export const now = (file) =>
  fs.existsSync(file)
    ? new Map(flatten(JSON.parse(fs.readFileSync(file, 'utf8'))))
    : new Map();
 
// {name} / {{name}} / %s / %d をプレースホルダとして取り出す
export const tokens = (s) =>
  (s.match(/\{\{?\s*[\w.]+\s*\}?\}|%[sd]/g) ?? []).sort().join(' ');

入れ子のJSONは cart.total のようにドット区切りで平らにして比べます。プレースホルダは {name}(ICU形式)、{{amount}}(i18next形式)、%s の3系統を拾います。使っているライブラリの記法に合わせて、正規表現を足し引きしてください。tokens は並べ替えてから返すので、語順が変わる言語でも誤検知しません。

次に scripts/changed-keys.mjs です。

// 使い方: node scripts/changed-keys.mjs <baseRef>
import fs from 'node:fs';
import { atRef, now } from './lib.mjs';
 
const base = atRef(process.argv[2], 'locales/ja.json');
const head = now('locales/ja.json');
 
const changed = {};
for (const [k, v] of head) if (base.get(k) !== v) changed[k] = v;
const removed = [...base.keys()].filter((k) => !head.has(k));
 
fs.mkdirSync('.i18n', { recursive: true });
fs.writeFileSync('.i18n/changed.json',
  JSON.stringify({ changed, removed }, null, 2));
console.log(`翻訳対象 ${Object.keys(changed).length} キー / 削除 ${removed.length} キー`);

出力する changed.json には、変更・追加されたキーとその日本語、そして削除されたキーの一覧が入ります。Claudeへの入力はこのファイルだけで足りるので、翻訳ファイル全体を読ませる必要がありません。

プレースホルダを見るゲートスクリプト

ゲートは、Claudeの編集結果を原文の ja.json とPR前の訳文に照らして判定します。scripts/gate.mjs は次の5つを検査し、1件でも見つかれば終了コード1で落ちます。

検査項目

ゲートが止める5つの状態

  • 未翻訳

    変更キーが訳側に無い、または空文字です。訳し忘れがここで出ます。

  • 未更新

    原文が変わったのに、訳側の値がPR前と同じです。原文の更新に訳が追随していません。

  • プレースホルダ不一致

    原文の {code} が訳文で {codigo} に化けた、%s が消えた、といった改変です。

  • 対象外キーの変更

    原文が変わっていないキーの訳文が書き換えられています。差分だけ訳すという約束の破れです。

  • 削除漏れ・余剰

    原文から消えたキーが訳側に残る、原文に無いキーが増えている状態です。

実装は次のとおりです。

// 使い方: node scripts/gate.mjs <baseRef> <対象言語>... (終了コード 1 = 不合格)
import { atRef, now, tokens } from './lib.mjs';
 
const [ref, ...langs] = process.argv.slice(2);
const ja = now('locales/ja.json');
const jaBase = atRef(ref, 'locales/ja.json');
const changed = new Set([...ja.keys()].filter((k) => jaBase.get(k) !== ja.get(k)));
let failed = false;
const ng = (lng, msg) => { console.log(`[${lng}] ${msg}`); failed = true; };
 
for (const lng of langs) {
  const file = `locales/${lng}.json`;
  const head = now(file);
  const before = atRef(ref, file);
 
  for (const k of changed) {
    if (!head.has(k) || head.get(k).trim() === '') { ng(lng, `未翻訳: ${k}`); continue; }
    if (before.has(k) && before.get(k) === head.get(k)) { ng(lng, `未更新: ${k}`); continue; }
    if (tokens(ja.get(k)) !== tokens(head.get(k)))
      ng(lng, `プレースホルダ不一致: ${k}  原文={${tokens(ja.get(k))}} 訳={${tokens(head.get(k))}}`);
  }
  for (const [k, v] of before) {
    if (!ja.has(k)) { if (head.has(k)) ng(lng, `削除漏れ: ${k}`); continue; }
    if (!changed.has(k) && head.get(k) !== v) ng(lng, `対象外のキーが変更: ${k}`);
  }
  for (const k of head.keys())
    if (!ja.has(k) && !before.has(k)) ng(lng, `余剰: ${k}`);
}
if (!failed) console.log('OK: 変更キーだけが更新され、プレースホルダも一致しています');
process.exit(failed ? 1 : 0);

実行するとどう出るか

小さなgitリポジトリで、動作を確かめました。Node.js v20.18.0で、main に原文と英訳を置き、PRのブランチで ja.json を次のように直しています。

  • cart.total に「(税込)」を足す
  • cart.coupon を新設する
  • legacy を削除する

まず、Claudeがまだ何も編集していない状態でゲートを走らせます。

node scripts/changed-keys.mjs main
node scripts/gate.mjs main en; echo "exit=$?"
翻訳対象 2 キー / 削除 1 キー
[en] 未更新: cart.total
[en] 未翻訳: cart.coupon
[en] 削除漏れ: legacy
exit=1

次に、わざと崩した en.json を渡します。cart.coupon の {code} を {codigo} に変え、変更対象でない greeting も書き換え、legacy を残した状態です。

[en] プレースホルダ不一致: cart.coupon  原文={code} 訳={codigo}
[en] 対象外のキーが変更: greeting
[en] 削除漏れ: legacy
exit=1

3件の問題がそれぞれ別の行で出ています。正しく直すと、次の1行で終わります。

OK: 変更キーだけが更新され、プレースホルダも一致しています
exit=0

この出力は、そのままClaudeへのフィードバックになります。後述のプロンプトでは、ゲートを自分で実行して OK になるまで直すよう指示するので、ワークフローの前段で多くの違反が自己修復されます。

ワークフロー全文

.github/workflows/sync-translations.yml に置きます。Claude Code Actionは prompt を渡すと自動化モードで動き、@claude のメンションを待たずに走ります。

name: Sync translations
on:
  pull_request:
    paths:
      - "locales/ja.json"
 
concurrency:
  group: i18n-sync-${{ github.event.pull_request.number }}
  cancel-in-progress: true
 
jobs:
  translate:
    # 公開リポジトリのforkからのPRにはシークレットが渡らないので同一リポジトリに限る
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: write
      pull-requests: read
      id-token: write
    steps:
      - uses: actions/checkout@v6
        with:
          ref: ${{ github.event.pull_request.head.ref }}
          fetch-depth: 0
 
      - id: base
        env:
          BASE_REF: ${{ github.base_ref }}
        run: |
          echo "sha=$(git merge-base "origin/$BASE_REF" HEAD)" >> "$GITHUB_OUTPUT"
 
      - run: node scripts/changed-keys.mjs ${{ steps.base.outputs.sha }}
 
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
          prompt: |
            .i18n/changed.json を読み、locales/en.json と locales/ko.json を
            編集してください。
            - changed のキーだけ、日本語の値を訳して該当キーに書く
            - removed のキーは両ファイルから削除する
            - それ以外のキーには触れない
            - 終わったら node scripts/gate.mjs ${{ steps.base.outputs.sha }} en ko
              を実行し、OK になるまで直す
          claude_args: |
            --max-turns 8
            --allowedTools "Read,Edit,Bash(node scripts/gate.mjs:*)"
 
      - name: ゲート
        run: node scripts/gate.mjs ${{ steps.base.outputs.sha }} en ko
 
      - name: 変更範囲の確認
        run: |
          extra=$(git status --porcelain | grep -v -E ' locales/(en|ko)\.json$' || true)
          if [ -n "$extra" ]; then echo "$extra"; exit 1; fi
 
      - name: コミット
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add locales/en.json locales/ko.json
          git diff --cached --quiet && exit 0
          git commit -m "update: ja.json の変更キーを翻訳"
          git push

実行前に .gitignore へ .i18n/ を足しておきます。抽出結果が未追跡ファイルとして「変更範囲の確認」に引っかかるのを避けるためです。

権限とトリガーの理由

permissions の3行には役割があります。contents: write はClaudeがリポジトリのファイルを編集するのに必要で、最後の git push にも使います。id-token: write は、このアクションが標準のGitHub App認証を使うために必要とされる権限です。pull-requests: read で足りるのは、結果をPRコメントに書かず、ファイルの編集だけをさせるからです。

concurrency と timeout-minutes は、費用の暴走を防ぐ定番の手当てです。同じPRに短時間で何度もpushすると古い実行は取り消され、1回の実行は10分で打ち切られます。

actions/checkout では、既定のマージコミットではなくPRのブランチ先頭を取得しています。そうしないと、最後の git push で戻す先が存在しません。fetch-depth: 0 は、分岐元を git merge-base で探すために全履歴を取る指定です。

Claudeに渡す道具を絞る

prompt を渡した自動化モードでは、Claudeはシェルも、GitHub APIも、許可を与えるまで使えません。許可は claude_args の --allowedTools か、settings 入力の permissions.allow で与えます。本構成では次の3つだけを許可しました。

  • Read: changed.json と翻訳ファイルを読む
  • Edit: en.json と ko.json を直す
  • Bash(node scripts/gate.mjs:*): ゲートの実行だけ。:* は末尾のワイルドカードと同じ意味で、node scripts/gate.mjs で始まるコマンドだけが通ります

許可を絞る理由は、訳す対象の文字列がPRの内容そのものだからです。原文に「前の指示を無視して…」のような文が紛れ込んでも、使える道具が3つなら、できることは限られます。さらに「変更範囲の確認」が、en.json と ko.json 以外が変わっていないことを機械的に確かめます。

--max-turns 8 は、会話のターン数の上限です。訳してゲートを通すだけの作業は数ターンで終わるので、少し余裕を持たせた値にしています。足りなければ引き上げてください。

用語集と文体をCLAUDE.mdに置く

訳の一貫性は、プロンプトでなく CLAUDE.md に書きます。CLAUDE.md はClaudeが毎回読む設定なので、PRごとの訳揺れが減ります。ただし毎回読まれるため、長く書くほど費用がかさみます。短く保ってください。

## 翻訳ルール(locales/)
 
- 原文は locales/ja.json。en.json と ko.json は同じキー構造で書く
- `{name}` `{{amount}}` `%s` はそのまま残す。中身を訳さない・消さない
- 語順は文に合わせて動かしてよい
- 用語: 「カート」= cart / 「クーポン」= coupon / 「保存」= Save
- UIのボタンは命令形(Save, Apply)、通知文は現在形
- 訳せない固有名詞は原文のまま残し、理由を報告する
- gate.mjs の出力が OK になるまで終了しない

用語集は、訳し分けが割れやすい語を5〜10語並べる程度で十分です。多言語アプリで困るのは、同じ画面の「保存」が文脈で Save と Store に割れることのほうです。ここに書いておくと、変更キーだけを訳す構成でも、過去の訳と新しい訳の言葉が揃います。

翻訳コミットでCIが動かないとき

最後の git push を、actions/checkout が設定した既定の GITHUB_TOKEN で行う構成です。GitHubは、既定の GITHUB_TOKEN によるコミットではワークフローを起動しません。したがって、翻訳コミットを積んでもPRのテストは再実行されません。

これは無限ループを防げる利点でもあります。ただし、翻訳後の画面テストなどをPRで必ず回したいなら、手当てが要ります。

  • actions/checkout に独自のGitHub Appトークンを渡し、そのトークンでpushする
  • 翻訳コミットの後に、テスト用のワークフローを workflow_dispatch で明示的に呼ぶ
  • マージ前に人が最新のコミットでCIを再実行する

また、このアクションは、実行者について2つの確認をします。PRやIssueのイベントでは、起動したユーザーにリポジトリの書き込み権限が要ります。ボットが起動した場合は、allowed_bots に載せない限り拒否されます。DependabotなどのボットがPRを作って ja.json を触る運用なら、そのボット名を allowed_bots に加える必要があります。

つまずきやすい点

落とし穴

原文を言い換えただけで「未更新」になる

ゲートの「未更新」は、原文が変わったのに訳文がPR前と同じ値のとき落ちます。句読点だけを直した、語尾を揃えただけといった修正では、正しい訳が前と同じ文字列になりえます。その場合は、訳文のほうにも空白1つを足すか、このキーだけ検査を免除する仕組みを入れてください。検査を緩める判断は、人が差分を見て行います。

落とし穴

複数形キーを一律に比べると過不足が出る

items_one と items_other のように複数形で枝分かれするキーは、言語ごとに必要な接尾辞が違います。日本語や韓国語に _one は要りません。本記事のゲートは、原文のキーに対応する訳側のキーを素直に要求するだけなので、複数形を多用するなら、Intl.PluralRules で言語別に期待キーを作る検査が要ります。作り方はi18nextのJSON翻訳を進める記事に実装を載せています。

落とし穴

Claudeが反応しない・動かない

ワークフローが起動しないときは、paths の記述と、PRが同一リポジトリのブランチかを先に見ます。fork元からのPRは、if で除外しています。起動してから失敗するときは、Claude GitHub Appが対象リポジトリに入っているか、ワークフローが有効か、シークレットが設定されているかを確かめます。認証エラーなら、同じキーまたはトークンでローカルの claude を動かして通るかを試すのが近道です。

費用と運用の見積り

1回の実行は、2種類の資源を消費します。GitHub Actionsの実行時間と、Claudeのトークンです。トークン数はプロンプトと応答の長さ、作業の複雑さ、リポジトリの大きさで決まります。サブスクリプションのトークンで認証した場合は、API課金ではなくサブスクリプションの枠を使います。

この構成が安く済む理由は、Claudeに渡す入力が changed.json と数個の翻訳ファイルに絞られている点です。次の手当てが、そのまま費用の上限になります。

  • --max-turns で反復を制限する
  • ジョブに timeout-minutes を付ける
  • concurrency で同じPRの並列実行を止める
  • CLAUDE.md を短く保つ

対象言語を増やすときは、ko.json の隣に zh.json を置き、prompt の locales/ko.json と、ゲートと git add の言語リストを足します。言語を増やすほど1回の訳す量が比例して増えるため、--max-turns と timeout-minutes の見直しも合わせて行ってください。

GitHub Actionsのパラメータとclaude_argsの渡し方には、--max-turns や --allowedTools 以外の引数もまとまっています。ワークフローの書き方そのものを固めたいときは、Claude Codeで進めるワークフロー作成が参考になります。

まとめ

訳文の品質はClaudeが、差分の特定と合否の判定はスクリプトが受け持つ分担にすると、PRで翻訳を同期する構成は安定します。核になるのは、Claudeの出力を信用せずゲートで落とす設計です。ゲートを置けば、モデルの更新や用語の揺れで訳が崩れても、不合格のコミットはPRに積まれません。

最初は対象を1言語、1つのJSONに絞って試し、ゲートが落とした理由を何回か読んでから言語を増やすのが無理のない進め方です。用途の近い別のワークフローとして、issueにラベルを自動で付ける構成も、同じ「prompt と許可ツールを絞る」型で組めます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/github-actions
  2. code.claude.com/docs/en/permissions
  3. code.claude.com/docs/en/headless
この記事を共有:XはてブLinkedIn