mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 05:09:19 +08:00
427 lines
19 KiB
YAML
427 lines
19 KiB
YAML
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:-<none>}"
|
|
echo "Languages: ${TARGET_LANGS:-<none>}"
|
|
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 -- <relative edited i18n file paths>`
|
|
- 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 || '<none>'}\``,
|
|
`- **Languages updated**: ${process.env.TARGET_LANGS || '<none>'} (${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 -- <edited i18n files>\``,
|
|
'',
|
|
'## 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
|