id: i18n-translation-sync
namespace: company.team
description: |
When the source language file changes, find the keys missing in every other
language, translate them with an LLM in parallel, reject any translation that
breaks a placeholder, and open one translation pull request after approval.
labels:
team: localization
use-case: i18n-sync
variables:
claude_model: claude-sonnet-5-5
triggers:
- id: on_push
type: io.kestra.plugin.core.trigger.Webhook
description: Point a GitHub repository webhook (push events, application/json)
at this URL. Pushes to other branches and GitHub's ping exit right away.
key: "{{ secret('I18N_SYNC_WEBHOOK_KEY') }}"
inputs:
repository: "{{ trigger.body.repository.full_name ?? '' }}"
inputs:
- id: repository
type: STRING
displayName: Repository
description: The GitHub repository as owner/name.
defaults: your-org/your-app
- id: base_branch
type: STRING
displayName: Base branch
description: The branch whose pushes are synced and that the translation pull
request targets.
defaults: main
- id: source_file
type: STRING
displayName: Source language file
description: The language every other file follows, relative to the repository root.
defaults: locales/en.json
- id: locale_glob
type: STRING
displayName: Language files
description: Glob pattern for the language files to keep in sync. The source
file is skipped.
defaults: locales/*.json
- id: glossary
type: STRING
displayName: Glossary
description: Optional terms to keep or translate a fixed way, for example
"Kestra=Kestra; flow=flow".
defaults: ""
- id: max_keys_per_language
type: INT
displayName: Max keys per language
description: The most keys translated per language in one run, which caps the
cost. The rest are picked up by the next run.
defaults: 200
- id: dry_run
type: BOOL
displayName: Dry run
description: Translate and validate, then stop. No review request, branch or
pull request.
defaults: false
tasks:
- id: check_event
type: io.kestra.plugin.core.flow.If
description: Stop quietly for GitHub's ping and for pushes to any branch other
than the base branch.
condition: "{{ inputs.repository == '' or (trigger.body.ref ?? ('refs/heads/' ~
inputs.base_branch)) != ('refs/heads/' ~ inputs.base_branch) }}"
then:
- id: not_a_base_branch_push
type: io.kestra.plugin.core.execution.Exit
state: SUCCESS
- id: open_sync_pr
type: io.kestra.plugin.core.http.Request
description: Look for a translation pull request that is still open.
uri: "https://api.github.com/repos/{{ inputs.repository
}}/pulls?state=open&head={{ inputs.repository | split('/') | first
}}:i18n-sync/{{ inputs.base_branch }}"
headers:
Accept: application/vnd.github+json
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
User-Agent: kestra-i18n-translation-sync
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: guard
type: io.kestra.plugin.core.flow.If
description: While a translation pull request is open, stop, so keys are never
translated or proposed twice.
condition: "{{ fromJson(outputs.open_sync_pr.body) | length > 0 }}"
then:
- id: log_open_pr
type: io.kestra.plugin.core.log.Log
message: "Translation pull request {{
fromJson(outputs.open_sync_pr.body)[0].html_url }} is still open.
Merge or close it, and the next push syncs whatever is still missing."
- id: skip
type: io.kestra.plugin.core.execution.Exit
state: SUCCESS
- id: base_head
type: io.kestra.plugin.core.http.Request
description: Pin the base branch's current commit, so every step reads the same files.
uri: "https://api.github.com/repos/{{ inputs.repository }}/branches/{{
inputs.base_branch }}"
headers:
Accept: application/vnd.github+json
Authorization: "Bearer {{ secret('GITHUB_TOKEN') }}"
User-Agent: kestra-i18n-translation-sync
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: find_missing
type: io.kestra.plugin.core.flow.WorkingDirectory
description: Compare every language file with the source file. No model call
happens when nothing is missing.
tasks:
- id: clone
type: io.kestra.plugin.git.Clone
url: "https://github.com/{{ inputs.repository }}.git"
username: "{{ secret('GITHUB_TOKEN') }}"
password: "{{ secret('GITHUB_TOKEN') }}"
commit: "{{ fromJson(outputs.base_head.body).commit.sha }}"
directory: repo
- id: missing
type: io.kestra.plugin.scripts.python.Script
description: Flatten nested keys, list the keys each language lacks, and prepare
one batch per language.
dependencies:
- kestra
script: |
import base64, json
from pathlib import Path
from kestra import Kestra
source_file = {{ inputs.source_file | toJson }}
limit = int({{ inputs.max_keys_per_language }})
def flatten(tree, prefix=""):
for key, value in tree.items():
if isinstance(value, dict):
yield from flatten(value, f"{prefix}{key}.")
elif isinstance(value, str):
yield f"{prefix}{key}", value
source = dict(flatten(json.loads(Path("repo", source_file).read_text(encoding="utf-8"))))
batches, report = [], []
for path in sorted(Path("repo").glob({{ inputs.locale_glob | toJson }})):
relative = str(path.relative_to("repo"))
if relative == source_file:
continue
target = dict(flatten(json.loads(path.read_text(encoding="utf-8"))))
missing = {k: v for k, v in source.items() if k not in target}
report.append({"locale": path.stem, "path": relative, "missing": len(missing)})
if missing:
batch = dict(list(missing.items())[:limit])
batches.append({
"locale": path.stem,
"path": relative,
"count": len(batch),
# Base64 keeps the strings, and any double-brace placeholders in them, inert in templates.
"strings_b64": base64.b64encode(json.dumps(batch, ensure_ascii=False).encode()).decode(),
})
Kestra.outputs({
"source_keys": len(source),
"languages": report,
"total_missing": sum(r["missing"] for r in report),
"batches": batches,
})
- id: translate
type: io.kestra.plugin.core.flow.If
description: Translate only when at least one language is missing keys.
condition: "{{ outputs.missing.vars.batches | length > 0 }}"
then:
- id: per_language
type: io.kestra.plugin.core.flow.Loop
description: One model call per language, three languages at a time.
values: "{{ outputs.missing.vars.batches }}"
concurrencyLimit: 3
tasks:
- id: translate_batch
type: io.kestra.plugin.ai.completion.ChatCompletion
description: Translate the batch, keeping keys and placeholders untouched.
provider:
type: io.kestra.plugin.ai.provider.Anthropic
apiKey: "{{ secret('ANTHROPIC_API_KEY') }}"
modelName: "{{ vars.claude_model }}"
configuration:
maxToken: 16000
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
messages:
- type: SYSTEM
content: |
You are a professional software localizer. Translate user interface strings from English into the language given by its locale code.
Rules:
- Never translate or change the keys.
- Keep every placeholder exactly as written: {name}, {% raw %}{{count}}{% endraw %}, %s, %(n)d, HTML-like tags such as <bold> and </bold>, and ICU syntax such as {count, plural, one {...} other {...}}. Translate only the text inside ICU branches, and keep the variable name and the "other" branch.
- Match the tone and length of a typical app interface in that language.
- Apply the glossary when one is given.
Respond with one JSON object mapping each key to its translation, and nothing else.
- type: USER
content: |
Locale: {{ fromJson(item.value).locale }}
Glossary: {{ inputs.glossary == '' ? 'none' : inputs.glossary }}
Strings (JSON, key to English):
{{ fromJson(item.value).strings_b64 | base64decode }}
outputs:
- id: path
type: STRING
value: "{{ fromJson(item.value).path }}"
- id: result_b64
type: STRING
value: "{{ outputs.translate_batch.textOutput | base64encode }}"
- id: build_changes
type: io.kestra.plugin.core.flow.WorkingDirectory
description: Validate every translation and merge the accepted ones into the
language files.
tasks:
- id: clone_for_changes
type: io.kestra.plugin.git.Clone
url: "https://github.com/{{ inputs.repository }}.git"
username: "{{ secret('GITHUB_TOKEN') }}"
password: "{{ secret('GITHUB_TOKEN') }}"
commit: "{{ fromJson(outputs.base_head.body).commit.sha }}"
directory: repo
- id: merge
type: io.kestra.plugin.scripts.python.Script
description: Accept a translation only when its placeholders match the English
exactly, then write each file in the source's key order, keeping
its indentation and any keys only that language has.
dependencies:
- kestra
inputFiles:
paths.json: "{{ loopOutputs(outputs.per_language.outputs, 'path') | toJson }}"
results.json: "{{ loopOutputs(outputs.per_language.outputs, 'result_b64') |
toJson }}"
outputFiles:
- edited/**
script: |
import base64, json, re
from collections import Counter
from pathlib import Path
from kestra import Kestra
def flatten(tree, prefix=""):
for key, value in tree.items():
if isinstance(value, dict):
yield from flatten(value, f"{prefix}{key}.")
elif isinstance(value, str):
yield f"{prefix}{key}", value
ICU = r"\{\s*(\w+)\s*,\s*(?:plural|select|selectordinal)\s*,"
def placeholders(text):
found = re.findall(r"\{\{\s*[\w.]+\s*\}\}", text)
rest = re.sub(r"\{\{\s*[\w.]+\s*\}\}", "", text)
found += [f"icu:{name}" for name in re.findall(ICU, rest)]
found += re.findall(r"\{\s*[\w.]+\s*\}", re.sub(ICU, "", rest))
found += re.findall(r"%(?:\(\w+\))?[sdif]", text)
found += re.findall(r"</?[A-Za-z][\w-]*\s*/?>", text)
return Counter(found)
def problem(english, translated):
if not isinstance(translated, str) or not translated.strip():
return "empty translation"
if placeholders(english) != placeholders(translated):
return "placeholders do not match the English"
if re.search(ICU, english) and not re.search(r"\bother\s*\{", translated):
return "ICU message lost its 'other' branch"
return None
def merge(source, target, added, prefix=""):
result = {}
for key, value in source.items():
dotted = f"{prefix}{key}"
if isinstance(value, dict):
child = merge(value, target.get(key) if isinstance(target.get(key), dict) else {}, added, dotted + ".")
if child:
result[key] = child
elif key in target:
result[key] = target[key]
elif dotted in added:
result[key] = added[dotted]
for key, value in target.items(): # keys only this language has stay as they are
result.setdefault(key, value)
return result
def inert(text):
# Strings end up in Slack and in the pull request body: break template markers.
return re.sub(r"([{}])(?=[{}%#])", r"\1 ", text)
source_tree = json.loads(Path("repo", {{ inputs.source_file | toJson }}).read_text(encoding="utf-8"))
english = dict(flatten(source_tree))
rows, rejected, total_added = [], [], 0
for path, encoded in zip(json.load(open("paths.json")), json.load(open("results.json"))):
raw = base64.b64decode(encoded).decode()
locale = Path(path).stem
try:
answer = json.loads(re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.M).strip())
except ValueError:
rows.append(f"| `{locale}` | 0 | all | The model's answer was not valid JSON. |")
continue
original_text = Path("repo", path).read_text(encoding="utf-8")
target_tree = json.loads(original_text)
existing = dict(flatten(target_tree))
added = {}
for key, translated in answer.items():
if key not in english or key in existing:
continue
reason = problem(english[key], translated)
if reason:
rejected.append({"locale": locale, "key": key, "reason": reason})
else:
added[key] = translated
still_missing = len([k for k in english if k not in existing and k not in added])
if added:
lines = original_text.splitlines()
indent = (len(lines[1]) - len(lines[1].lstrip())) if len(lines) > 1 else 2
merged = merge(source_tree, target_tree, added)
out = Path("edited", path)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(merged, indent=indent or 2, ensure_ascii=False) + "\n", encoding="utf-8")
total_added += len(added)
rows.append(f"| `{locale}` | {len(added)} | {len([r for r in rejected if r['locale'] == locale])} | {still_missing} still missing |")
rejected_lines = "\n".join(f"- `{r['locale']}` `{inert(r['key'])}`: {r['reason']}" for r in rejected) or "None."
Kestra.outputs({
"added": total_added,
"rejected": len(rejected),
"languages_changed": len(list(Path("edited").rglob("*.json"))) if Path("edited").exists() else 0,
"summary": "| Language | Added | Rejected | Notes |\n|---|---|---|---|\n" + "\n".join(rows),
"rejected_details": rejected_lines,
})
- id: needs_approval
type: io.kestra.plugin.core.flow.If
description: Ask for a human decision only when something was translated and
this is not a dry run.
condition: "{{ not inputs.dry_run and outputs.merge.vars.added > 0 }}"
then:
- id: ask_reviewer
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Tell the team new translations are waiting, with a link to the
paused execution.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
messageText: |
🌍 *Translations are waiting for review* for {{ inputs.repository }}: {{ outputs.merge.vars.added }} string(s) in {{ outputs.merge.vars.languages_changed }} language(s), {{ outputs.merge.vars.rejected }} rejected by the placeholder check.
Review and decide: {{ kestra.url ?? '' }}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{ execution.id }}
- id: approval
type: io.kestra.plugin.core.flow.Pause
description: Review the merge outputs (summary and edited files), then choose
whether to open the translation pull request. Without a decision
within three days the execution is cancelled.
pauseDuration: P3D
behavior: CANCEL
onResume:
- id: decision
type: SELECT
displayName: Decision
values:
- Open the translation PR
- Discard
defaults: Open the translation PR
- id: note
type: STRING
displayName: Note for the pull request (optional)
required: false
- id: publish
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.approval.onResume.decision == 'Open the translation PR'
}}"
then:
- id: push_branch
type: io.kestra.plugin.git.PushExecutionFiles
description: Commit only the edited language files to i18n-sync/<base branch>.
delete is off, so no other file in the repository is touched.
url: "https://github.com/{{ inputs.repository }}.git"
username: "{{ secret('GITHUB_TOKEN') }}"
password: "{{ secret('GITHUB_TOKEN') }}"
branch: "i18n-sync/{{ inputs.base_branch }}"
gitDirectory: .
delete: false
authorName: Kestra translation sync
commitMessage: "i18n: add {{ outputs.merge.vars.added }} missing translations"
filesMap: "{ {% for file in outputs.merge.outputFiles %}{{ file.key |
replace({'edited/': ''}) | toJson }}: {{ file.value | toJson
}}{% if not loop.last %}, {% endif %}{% endfor %} }"
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
- id: open_pr
type: io.kestra.plugin.github.pulls.Create
description: Open the translation pull request with the per-language summary and
the keys a human still has to translate.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ inputs.repository }}"
sourceBranch: "i18n-sync/{{ inputs.base_branch }}"
targetBranch: "{{ inputs.base_branch }}"
title: "i18n: add {{ outputs.merge.vars.added }} missing translations in {{
outputs.merge.vars.languages_changed }} languages"
body: |
`{{ inputs.source_file }}` has keys that other languages were missing. These translations were generated, checked for intact placeholders and approved before opening.
{{ outputs.merge.vars.summary }}
**Rejected by the placeholder check, to translate by hand:**
{{ outputs.merge.vars.rejected_details }}
{{ outputs.approval.onResume.note ?? '' }}
retry:
type: exponential
interval: PT10S
maxInterval: PT2M
maxAttempts: 3
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert with the failed task, the error and a link. Nothing was
pushed, so the run can simply be retried.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
retry:
type: constant
interval: PT10S
maxAttempts: 3
messageText: |
❌ *Translation sync FAILED* for {{ inputs.repository }} (flow {{ flow.namespace }}.{{ flow.id }}, execution {{ execution.id }}).
Failed task: {{ (errorLogs() | length) > 0 ? errorLogs()[0].taskId : 'unknown' }}. Error: {{ (errorLogs() | length) > 0 ? errorLogs()[0].message : 'see the execution logs' }}
{{ kestra.url ?? '' }}/ui/main/executions/{{ flow.namespace }}/{{ flow.id }}/{{ execution.id }}
outputs:
- id: missing_keys
type: INT
displayName: Missing keys
description: Keys missing across all languages before this run.
value: "{{ outputs.missing.vars.total_missing ?? 0 }}"
- id: translations_added
type: INT
displayName: Translations added
description: Translations that passed the placeholder check.
value: "{{ outputs.merge.vars.added ?? 0 }}"
- id: pull_request_url
type: STRING
displayName: Translation pull request
value: "{{ outputs.open_pr.pullRequestUrl ?? 'none' }}"