Auto-translating new keys in CI
Once a Messagevisor project supports more than two or three locales, keeping every locale up to date with new source-language strings becomes a chore. This guide shows how to automate the first-draft pass in CI: on every push to your main branch, a GitHub Actions workflow detects untranslated keys, runs them through a machine translation service, and opens a pull request with the result for review.
The output is still a draft. A human reviewer (often a translator) merges or edits the pull request before the new translations ship. The automation removes the boring part, not the review part.
When to use this#
- Your project supports several locales and source strings change often.
- You already use DeepL or an LLM-based AI workflow for draft translations and want to remove the manual step.
- You want every new source-language string to land in every locale within minutes of merging, even if the final wording comes later.
- You are comfortable having machine output land in a pull request, gated on human review.
If your project is small enough that a human can keep up with translations manually, you do not need this. If your translations are regulated (legal, financial, medical), do not auto-translate: route those keys through a human from the start.
The shape of the pipeline#
The workflow runs on every push to your main branch. It does the same thing you would do by hand:
1. Check out the repository2. Install dependencies3. Detect untranslated rows per locale via `messagevisor export --onlyUntranslated`4. Translate each row using DeepL (or any other service)5. Apply the result with `messagevisor import --apply`6. Run lint and tests7. Commit the changed `messages/` files to a new branch and open a pull requestA reviewer reads the diff and either merges it as-is, edits the wording, or closes it if the source string was wrong to begin with.
Prerequisites#
- A GitHub repository with the Messagevisor project at the root.
- A translation script you trust. The example below uses the script from the DeepL guide.
- Secrets configured in the repository:
DEEPL_AUTH_KEY(or whatever credential your translation service needs)- A GitHub token with permission to open pull requests. The default
GITHUB_TOKENworks when combined with thepeter-evans/create-pull-requestaction.
For projects that use an LLM API instead of DeepL, swap the script and the secret name.
Step 1: Commit a translation script#
Add the translation script from the DeepL guide at scripts/translate-with-deepl.mjs, or any equivalent script that takes a CSV path, a source locale, and a target locale and fills the target locale column.
A minimal contract for whatever script you pick:
- inputs:
csvPath,sourceLocale,targetLocale - behavior: read the CSV, fill empty target-locale cells, write the file back
- behavior: skip rows that already have a non-empty target value
- exit code: non-zero on failure
The CI workflow below assumes this contract.
Step 2: Add the GitHub Actions workflow#
Create .github/workflows/auto-translate.yml:
name: Auto-translate new keyson: push: branches: - main paths: - "messages/**" - "locales/**" - "targets/**" workflow_dispatch:jobs: translate: runs-on: ubuntu-latest env: SOURCE_LOCALE: en TARGET_LOCALES: "de fr nl-NL es" TARGET: web steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Export untranslated rows env: DEEPL_AUTH_KEY: ${{ secrets.DEEPL_AUTH_KEY }} run: | mkdir -p exports for locale in $TARGET_LOCALES; do npx messagevisor export \ --locale=$SOURCE_LOCALE \ --locale=$locale \ --target=$TARGET \ --onlyUntranslated \ --output=exports/$locale-$TARGET.csv \ --force done - name: Run machine translation env: DEEPL_AUTH_KEY: ${{ secrets.DEEPL_AUTH_KEY }} run: | for locale in $TARGET_LOCALES; do node scripts/translate-with-deepl.mjs \ exports/$locale-$TARGET.csv \ $SOURCE_LOCALE \ $locale done - name: Preview imports run: | for locale in $TARGET_LOCALES; do npx messagevisor import \ exports/$locale-$TARGET.csv \ --locale=$locale done - name: Apply imports run: | for locale in $TARGET_LOCALES; do npx messagevisor import \ exports/$locale-$TARGET.csv \ --locale=$locale \ --apply done - name: Lint and test run: | npx messagevisor lint npx messagevisor test - name: Open pull request uses: peter-evans/create-pull-request@v6 with: token: ${{ secrets.GITHUB_TOKEN }} branch: auto-translate/${{ github.run_id }} base: main commit-message: "chore(translations): auto-translate new keys" title: "Auto-translate new keys for ${{ env.TARGET_LOCALES }}" body: | Draft machine translations for the keys added in ${{ github.event.head_commit.id }}. **Review before merge.** The translations were produced by DeepL and need a native-speaker pass before they ship. Locales covered: `${{ env.TARGET_LOCALES }}`. labels: | translations machine-translated delete-branch: true add-paths: | messages/** sets/**A few details worth understanding:
- The
pathsfilter on the trigger keeps the workflow from running on unrelated commits (a code change that does not touch messages should not trigger a translation pass). --forceonexportlets the workflow overwrite previous CSV files on re-runs.- The
preview importsstep is intentionally separate fromapply imports. If the preview step ever fails, you see it before any files are written. peter-evans/create-pull-requestopens a fresh pull request and does not touchmaindirectly. If there are no changes, no pull request is opened.
Step 3: Configure secrets#
In the GitHub repository settings, under Secrets and variables ▸ Actions, add:
DEEPL_AUTH_KEY(or your translation provider's key).
The default GITHUB_TOKEN is enough for peter-evans/create-pull-request to open the pull request and add labels. No additional secret is needed for that.
Step 4: Set up branch protection#
Auto-generated translation pull requests should not auto-merge. In Settings ▸ Branches, require at least one approving review on the protected branch (typically main). The reviewer (often a translator or someone fluent in the target language) edits or approves the wording.
For projects with many target locales, consider a CODEOWNERS rule that routes machine-translated pull requests to specific reviewers per locale.
Step 5: Tune the trigger#
The example above runs on every push to main. A few alternative triggers:
- On every pull request to
main. Drafts translations for the in-flight change so reviewers can see them before merge. Trade-off: more PR noise, more API spend. - On a schedule (
schedule:trigger). Once a day or once a week, sweep for untranslated keys and open one combined pull request. Quieter, less responsive. - On
workflow_dispatchonly. Manual trigger. Cheapest, least automated.
Pick the cadence that matches how often source strings actually change.
Locale-specific variations#
Regional locales with inheritance#
For regional locales (en-GB from en, pt-BR from pt), use --onlyDirectlyUntranslated on export and --prune on import so machine output matching the parent gets dropped. Adapt the loop:
- name: Export and translate regional locale env: DEEPL_AUTH_KEY: ${{ secrets.DEEPL_AUTH_KEY }} run: | npx messagevisor export \ --locale=en \ --locale=en-GB \ --target=web \ --onlyDirectlyUntranslated \ --output=exports/en-GB-web.csv \ --force node scripts/translate-with-deepl.mjs exports/en-GB-web.csv en en-GB npx messagevisor import exports/en-GB-web.csv --locale=en-GB --prune npx messagevisor import exports/en-GB-web.csv --locale=en-GB --prune --applySee Inheritance for how --prune works.
Multi-set projects#
For projects with sets: true, pass --set to both export and import. Loop over sets and locales:
env: SETS: "staging production" TARGET_LOCALES: "de fr nl-NL" TARGET: webrun: | for set in $SETS; do for locale in $TARGET_LOCALES; do npx messagevisor export --set=$set --locale=en --locale=$locale --target=$TARGET --onlyUntranslated --output=exports/$set-$locale-$TARGET.csv --force node scripts/translate-with-deepl.mjs exports/$set-$locale-$TARGET.csv en $locale npx messagevisor import exports/$set-$locale-$TARGET.csv --set=$set --locale=$locale --apply done doneSee Sets.
Use an LLM instead of DeepL#
Replace the translation script and the secret with an LLM-based equivalent. The CI shape is identical: export, translate, preview, apply, lint, test, open pull request. The translation step is the only thing that changes.
For more on the agentic version of this workflow, see AI translations. That guide is the right starting point if a developer is in the loop. The CI workflow on this page is the unattended version.
Cost and rate limits#
Auto-translation in CI can burn API budget unexpectedly:
- a misconfigured trigger that runs on every push (instead of every push that touched
messages/) re-translates the same strings - a bug in the translation script that does not skip already-translated rows re-pays for every string on every run
- a
--createMissingimport that introduces stray rows can balloon the volume
Guardrails:
- always run with
--onlyUntranslated; never let CI sweep already-translated values - have the script skip rows that already have a non-empty target value
- never use
--createMissingin the auto-translate workflow; new messages should come from human authoring, not from machine output - set a low usage cap on the API key so a runaway loop fails fast instead of producing a surprise invoice
Guardrails worth keeping#
- Never auto-merge. Always gate on a human review.
- Label clearly. Pull requests opened by the workflow should be labelled
machine-translatedor similar so reviewers know what they are looking at. - Keep regulated copy out of the loop. Filter sensitive namespaces (
legal.,compliance.,billing.) out of the export with--excludeMessages, or scope the workflow to one target so regulated keys never enter the auto-translate path. - Watch the catalog. Skim the catalog for the new translations before approving the pull request. ICU placeholder damage and wrong-tone copy show up much faster there than in a diff.
- Treat the pull request as a draft. If the wording needs changes, edit the CSV input or the script and re-trigger the workflow. Do not hand-edit
messages/files in the auto-translate PR if you want the workflow to remain idempotent.

