name: Translate i18n Files with Claude Code # Note: claude-code-action doesn't support push events directly. # Push events are bridged by trigger-i18n-sync.yml via repository_dispatch. on: repository_dispatch: types: [i18n-sync] workflow_dispatch: inputs: files: description: 'Specific files to translate (space-separated, e.g., "app common"). Required for full mode; leave empty in incremental mode to use en-US files changed since HEAD~1.' required: false type: string languages: description: 'Specific languages to translate (space-separated, e.g., "zh-Hans ja-JP"). Leave empty for all supported target languages except en-US.' required: false type: string mode: description: 'Sync mode: incremental (compare with previous en-US revision) or full (sync all keys in scope)' required: false default: incremental type: choice options: - incremental - full permissions: contents: write pull-requests: write concurrency: group: translate-i18n-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: translate: if: github.repository == 'langgenius/dify' runs-on: ubuntu-latest timeout-minutes: 120 steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Configure Git run: | git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - name: Setup web environment uses: ./.github/actions/setup-web - name: Prepare sync context id: context shell: bash run: | DEFAULT_TARGET_LANGS=$(awk " /value: '/ { value=\$2 gsub(/[',]/, \"\", value) } /supported: true/ && value != \"en-US\" { printf \"%s \", value } " web/i18n-config/languages.ts | sed 's/[[:space:]]*$//') generate_changes_json() { node <<'NODE' const { execFileSync } = require('node:child_process') const fs = require('node:fs') const path = require('node:path') const repoRoot = process.cwd() const baseSha = process.env.BASE_SHA || '' const headSha = process.env.HEAD_SHA || '' const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean) const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`) const readCurrentJson = (fileStem) => { const filePath = englishPath(fileStem) if (!fs.existsSync(filePath)) return null return JSON.parse(fs.readFileSync(filePath, 'utf8')) } const readBaseJson = (fileStem) => { if (!baseSha) return null try { const relativePath = `web/i18n/en-US/${fileStem}.json` const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' }) return JSON.parse(content) } catch (error) { return null } } const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue) const changes = {} for (const fileStem of files) { const currentJson = readCurrentJson(fileStem) const beforeJson = readBaseJson(fileStem) || {} const afterJson = currentJson || {} const added = {} const updated = {} const deleted = [] for (const [key, value] of Object.entries(afterJson)) { if (!(key in beforeJson)) { added[key] = value continue } if (!compareJson(beforeJson[key], value)) { updated[key] = { before: beforeJson[key], after: value, } } } for (const key of Object.keys(beforeJson)) { if (!(key in afterJson)) deleted.push(key) } changes[fileStem] = { fileDeleted: currentJson === null, added, updated, deleted, } } fs.writeFileSync( '/tmp/i18n-changes.json', JSON.stringify({ baseSha, headSha, files, changes, }) ) NODE } if [ "${{ github.event_name }}" = "repository_dispatch" ]; then BASE_SHA="${{ github.event.client_payload.base_sha }}" HEAD_SHA="${{ github.event.client_payload.head_sha }}" CHANGED_FILES="${{ github.event.client_payload.changed_files }}" TARGET_LANGS="$DEFAULT_TARGET_LANGS" SYNC_MODE="${{ github.event.client_payload.sync_mode || 'incremental' }}" if [ -n "${{ github.event.client_payload.changes_base64 }}" ]; then printf '%s' '${{ github.event.client_payload.changes_base64 }}' | base64 -d > /tmp/i18n-changes.json CHANGES_AVAILABLE="true" CHANGES_SOURCE="embedded" elif [ -n "$BASE_SHA" ] && [ -n "$CHANGED_FILES" ]; then export BASE_SHA HEAD_SHA CHANGED_FILES generate_changes_json CHANGES_AVAILABLE="true" CHANGES_SOURCE="recomputed" else printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json CHANGES_AVAILABLE="false" CHANGES_SOURCE="unavailable" fi else BASE_SHA="" HEAD_SHA=$(git rev-parse HEAD) if [ -n "${{ github.event.inputs.languages }}" ]; then TARGET_LANGS="${{ github.event.inputs.languages }}" else TARGET_LANGS="$DEFAULT_TARGET_LANGS" fi SYNC_MODE="${{ github.event.inputs.mode || 'incremental' }}" if [ -n "${{ github.event.inputs.files }}" ]; then CHANGED_FILES="${{ github.event.inputs.files }}" elif [ "$SYNC_MODE" = "incremental" ]; then BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || true) if [ -n "$BASE_SHA" ]; then CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'web/i18n/en-US/*.json' 2>/dev/null | sed -n 's@^.*/@@p' | sed 's/\.json$//' | tr '\n' ' ' | sed 's/[[:space:]]*$//') else CHANGED_FILES=$(find web/i18n/en-US -maxdepth 1 -type f -name '*.json' -print | sed -n 's@^.*/@@p' | sed 's/\.json$//' | sort | tr '\n' ' ' | sed 's/[[:space:]]*$//') fi elif [ "$SYNC_MODE" = "full" ]; then echo "workflow_dispatch full mode requires the files input to stay within CI limits." >&2 exit 1 else CHANGED_FILES="" fi if [ "$SYNC_MODE" = "incremental" ] && [ -n "$CHANGED_FILES" ]; then export BASE_SHA HEAD_SHA CHANGED_FILES generate_changes_json CHANGES_AVAILABLE="true" CHANGES_SOURCE="local" else printf '%s' '{"baseSha":"","headSha":"","files":[],"changes":{}}' > /tmp/i18n-changes.json CHANGES_AVAILABLE="false" CHANGES_SOURCE="unavailable" fi fi FILE_ARGS="" if [ -n "$CHANGED_FILES" ]; then FILE_ARGS="--file $CHANGED_FILES" fi LANG_ARGS="" if [ -n "$TARGET_LANGS" ]; then LANG_ARGS="--lang $TARGET_LANGS" fi { echo "DEFAULT_TARGET_LANGS=$DEFAULT_TARGET_LANGS" echo "BASE_SHA=$BASE_SHA" echo "HEAD_SHA=$HEAD_SHA" echo "CHANGED_FILES=$CHANGED_FILES" echo "TARGET_LANGS=$TARGET_LANGS" echo "SYNC_MODE=$SYNC_MODE" echo "CHANGES_AVAILABLE=$CHANGES_AVAILABLE" echo "CHANGES_SOURCE=$CHANGES_SOURCE" echo "FILE_ARGS=$FILE_ARGS" echo "LANG_ARGS=$LANG_ARGS" } >> "$GITHUB_OUTPUT" echo "Files: ${CHANGED_FILES:-}" echo "Languages: ${TARGET_LANGS:-}" echo "Mode: $SYNC_MODE" - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # v1.0.82 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_bots: 'github-actions[bot]' show_full_output: ${{ github.event_name == 'workflow_dispatch' }} prompt: | You are the i18n sync agent for the Dify repository. Your job is to keep translations synchronized with the English source files under `${{ github.workspace }}/web/i18n/en-US/`. Use absolute paths at all times: - Repo root: `${{ github.workspace }}` - Web directory: `${{ github.workspace }}/web` - Language config: `${{ github.workspace }}/web/i18n-config/languages.ts` Inputs: - Files in scope: `${{ steps.context.outputs.CHANGED_FILES }}` - Target languages: `${{ steps.context.outputs.TARGET_LANGS }}` - Sync mode: `${{ steps.context.outputs.SYNC_MODE }}` - Base SHA: `${{ steps.context.outputs.BASE_SHA }}` - Head SHA: `${{ steps.context.outputs.HEAD_SHA }}` - Scoped file args: `${{ steps.context.outputs.FILE_ARGS }}` - Scoped language args: `${{ steps.context.outputs.LANG_ARGS }}` - Structured change set available: `${{ steps.context.outputs.CHANGES_AVAILABLE }}` - Structured change set source: `${{ steps.context.outputs.CHANGES_SOURCE }}` - Structured change set file: `/tmp/i18n-changes.json` Tool rules: - Use Read for repository files. - Use Edit for JSON updates. - Use Bash only for `pnpm`. - Do not use Bash for `git`, `gh`, or branch management. Required execution plan: 1. Resolve target languages. - Use the provided `Target languages` value as the source of truth. - If it is unexpectedly empty, read `${{ github.workspace }}/web/i18n-config/languages.ts` and use every language with `supported: true` except `en-US`. 2. Stay strictly in scope. - Only process the files listed in `Files in scope`. - Only process the resolved target languages, never `en-US`. - Do not touch unrelated i18n files. - Do not modify `${{ github.workspace }}/web/i18n/en-US/`. 3. Resolve source changes. - If `Structured change set available` is `true`, read `/tmp/i18n-changes.json` and use it as the source of truth for file-level and key-level changes. - For each file entry: - `added` contains new English keys that need translations. - `updated` contains stale keys whose English source changed; re-translate using the `after` value. - `deleted` contains keys that should be removed from locale files. - `fileDeleted: true` means the English file no longer exists; remove the matching locale file if present. - Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate. - If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth. 4. Run a scoped pre-check before editing: - `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - Use this command as the source of truth for missing and extra keys inside the current scope. 5. Apply translations. - For every target language and scoped file: - If `fileDeleted` is `true`, remove the locale file if it exists and skip the rest of that file. - If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed. - ADD missing keys. - UPDATE stale translations when the English value changed. - DELETE removed keys. Prefer `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope. - Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names. - Match the existing terminology and register used by each locale. - Prefer one Edit per file when stable, but prioritize correctness over batching. 6. Verify only the edited files. - Run `pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- ` - Run `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - If verification fails, fix the remaining problems before continuing. 7. Stop after the scoped locale files are updated and verification passes. - Do not create branches, commits, or pull requests. claude_args: | --max-turns 120 --allowedTools "Read,Write,Edit,Bash(pnpm *),Bash(pnpm:*),Glob,Grep" - name: Prepare branch metadata id: pr_meta if: steps.context.outputs.CHANGED_FILES != '' shell: bash run: | if [ -z "$(git -C "${{ github.workspace }}" status --porcelain -- web/i18n/)" ]; then echo "has_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi SCOPE_HASH=$(printf '%s|%s|%s' "${{ steps.context.outputs.CHANGED_FILES }}" "${{ steps.context.outputs.TARGET_LANGS }}" "${{ steps.context.outputs.SYNC_MODE }}" | sha256sum | cut -c1-8) HEAD_SHORT=$(printf '%s' "${{ steps.context.outputs.HEAD_SHA }}" | cut -c1-12) BRANCH_NAME="chore/i18n-sync-${HEAD_SHORT}-${SCOPE_HASH}" { echo "has_changes=true" echo "branch_name=$BRANCH_NAME" } >> "$GITHUB_OUTPUT" - name: Commit translation changes if: steps.pr_meta.outputs.has_changes == 'true' shell: bash run: | git -C "${{ github.workspace }}" checkout -B "${{ steps.pr_meta.outputs.branch_name }}" git -C "${{ github.workspace }}" add web/i18n/ git -C "${{ github.workspace }}" commit -m "chore(i18n): sync translations with en-US" - name: Push translation branch if: steps.pr_meta.outputs.has_changes == 'true' shell: bash run: | if git -C "${{ github.workspace }}" ls-remote --exit-code --heads origin "${{ steps.pr_meta.outputs.branch_name }}" >/dev/null 2>&1; then git -C "${{ github.workspace }}" push --force-with-lease origin "${{ steps.pr_meta.outputs.branch_name }}" else git -C "${{ github.workspace }}" push --set-upstream origin "${{ steps.pr_meta.outputs.branch_name }}" fi - name: Create or update translation PR if: steps.pr_meta.outputs.has_changes == 'true' env: BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }} FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }} TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }} SYNC_MODE: ${{ steps.context.outputs.SYNC_MODE }} CHANGES_SOURCE: ${{ steps.context.outputs.CHANGES_SOURCE }} BASE_SHA: ${{ steps.context.outputs.BASE_SHA }} HEAD_SHA: ${{ steps.context.outputs.HEAD_SHA }} REPO_NAME: ${{ github.repository }} shell: bash run: | PR_BODY_FILE=/tmp/i18n-pr-body.md LANG_COUNT=$(printf '%s\n' "$TARGET_LANGS" | wc -w | tr -d ' ') if [ "$LANG_COUNT" = "0" ]; then LANG_COUNT="0" fi export LANG_COUNT node <<'NODE' > "$PR_BODY_FILE" const fs = require('node:fs') const changesPath = '/tmp/i18n-changes.json' const changes = fs.existsSync(changesPath) ? JSON.parse(fs.readFileSync(changesPath, 'utf8')) : { changes: {} } const filesInScope = (process.env.FILES_IN_SCOPE || '').split(/\s+/).filter(Boolean) const lines = [ '## Summary', '', `- **Files synced**: \`${process.env.FILES_IN_SCOPE || ''}\``, `- **Languages updated**: ${process.env.TARGET_LANGS || ''} (${process.env.LANG_COUNT} languages)`, `- **Sync mode**: ${process.env.SYNC_MODE}${process.env.BASE_SHA ? ` (base: \`${process.env.BASE_SHA.slice(0, 10)}\`, head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)` : ` (head: \`${process.env.HEAD_SHA.slice(0, 10)}\`)`}`, '', '### Key changes', ] for (const fileName of filesInScope) { const fileChange = changes.changes?.[fileName] || { added: {}, updated: {}, deleted: [], fileDeleted: false } const addedKeys = Object.keys(fileChange.added || {}) const updatedKeys = Object.keys(fileChange.updated || {}) const deletedKeys = fileChange.deleted || [] lines.push(`- \`${fileName}\`: +${addedKeys.length} / ~${updatedKeys.length} / -${deletedKeys.length}${fileChange.fileDeleted ? ' (file deleted in en-US)' : ''}`) } lines.push( '', '## Verification', '', `- \`pnpm --dir web run i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``, `- \`pnpm --dir web lint:fix --quiet -- \``, '', '## Notes', '', '- This PR was generated from structured en-US key changes produced by `trigger-i18n-sync.yml`.', `- Structured change source: ${process.env.CHANGES_SOURCE || 'unknown'}.`, '- Branch name is deterministic for the head SHA and scope, so reruns update the same PR instead of opening duplicates.', '', '🤖 Generated with [Claude Code](https://claude.com/claude-code)' ) process.stdout.write(lines.join('\n')) NODE EXISTING_PR_NUMBER=$(gh pr list --repo "$REPO_NAME" --head "$BRANCH_NAME" --state open --json number --jq '.[0].number') if [ -n "$EXISTING_PR_NUMBER" ] && [ "$EXISTING_PR_NUMBER" != "null" ]; then gh pr edit "$EXISTING_PR_NUMBER" --repo "$REPO_NAME" --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE" else gh pr create --repo "$REPO_NAME" --head "$BRANCH_NAME" --base main --title "chore(i18n): sync translations with en-US" --body-file "$PR_BODY_FILE" fi