Building an i18n CI/CD pipeline with GitHub Actions
2026-05-25 · Localingos team
If your translation workflow currently looks like "engineer writes English strings, opens a PR, downloads CSV, emails translator, waits a week, uploads result, opens another PR" — this article is for you. By the end you'll have a GitHub Actions pipeline that validates strings on every PR, catches placeholder breakage before merge, auto-translates on push to main, and deploys per-locale builds without manual intervention.
The patterns here apply to any JSON-based i18n setup (react-i18next, vue-i18n, Next.js dictionaries, Angular i18n) and any translation backend — your in-house pipeline, a TMS like Lokalise, or an automated service like Localingos. We'll structure the workflow so the translation provider is a single replaceable step.
What "i18n CI/CD" actually means
A complete i18n pipeline has four jobs, each with a clear trigger:
- PR validation — when a developer opens a PR that touches translation files, validate that placeholders match, no JSON is broken, and no required keys are missing.
- Translation sync — when changes land on main, send new English strings to your translation provider and commit the resulting translations back to the repo.
- Build verification — ensure every locale builds without errors, even if no translation provider step ran (the strings file might already be complete).
- Deploy — push the built artifacts to your hosting target, ideally with a per-locale cache invalidation.
The first two are the high-value ones. Skip them and you'll ship the same translation bugs (missing keys at runtime, broken placeholders crashing prod, English fallback showing for a launched language) that every team without i18n CI ships. Set them up once and you stop thinking about translation infrastructure.
File layout assumed
locales/
en.json ← source of truth, edited by humans
es.json
de.json
fr.json
ja.json
pt-BR.json
scripts/
validate-locales.js
diff-keys.js
.github/
workflows/
validate.yml
translate.yml
deploy.yml
en.json is the source. Engineers edit it directly. All other files are derived — they should not be hand-edited because the next translation sync will overwrite them.
Step 1: PR validation workflow
The validation workflow runs on every PR that touches locales/. It does three checks:
- JSON validity — every file parses cleanly.
- Placeholder integrity — every
{{var}}inen.jsonexists in every other locale file (for keys that exist there), and no extra placeholders sneak in. - Required-key coverage — every key in
en.jsonexists in every other locale (warn or fail depending on policy).
The script:
// scripts/validate-locales.js
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = path.join(__dirname, '..', 'locales');
const SOURCE = 'en';
function extractPlaceholders(value) {
if (typeof value !== 'string') return [];
return [
...(value.match(/\{\{[^}]+\}\}/g) || []),
...(value.match(/\$\{[^}]+\}/g) || []),
...(value.match(/%[sd]/g) || []),
];
}
function flatten(obj, prefix = '') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
Object.assign(out, flatten(v, key));
} else {
out[key] = v;
}
}
return out;
}
const errors = [];
const warnings = [];
const sourcePath = path.join(LOCALES_DIR, `${SOURCE}.json`);
const source = flatten(JSON.parse(fs.readFileSync(sourcePath, 'utf8')));
const files = fs
.readdirSync(LOCALES_DIR)
.filter((f) => f.endsWith('.json') && f !== `${SOURCE}.json`);
for (const file of files) {
const locale = file.replace('.json', '');
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(path.join(LOCALES_DIR, file), 'utf8'));
} catch (e) {
errors.push(`${file}: invalid JSON — ${e.message}`);
continue;
}
const target = flatten(parsed);
for (const [key, srcValue] of Object.entries(source)) {
const tgtValue = target[key];
if (tgtValue === undefined) {
warnings.push(`${locale}: missing key "${key}"`);
continue;
}
const srcPlaceholders = new Set(extractPlaceholders(srcValue));
const tgtPlaceholders = new Set(extractPlaceholders(tgtValue));
for (const ph of srcPlaceholders) {
if (!tgtPlaceholders.has(ph)) {
errors.push(`${locale}.${key}: missing placeholder ${ph}`);
}
}
for (const ph of tgtPlaceholders) {
if (!srcPlaceholders.has(ph)) {
errors.push(`${locale}.${key}: extra placeholder ${ph} not in source`);
}
}
}
}
if (warnings.length) {
console.warn(`\n[warn] ${warnings.length} missing keys:`);
warnings.slice(0, 20).forEach((w) => console.warn(' ' + w));
if (warnings.length > 20) console.warn(` …and ${warnings.length - 20} more`);
}
if (errors.length) {
console.error(`\n[fail] ${errors.length} placeholder errors:`);
errors.forEach((e) => console.error(' ' + e));
process.exit(1);
}
console.log('All locale files valid.');
The workflow:
# .github/workflows/validate.yml
name: validate-i18n
on:
pull_request:
paths:
- 'locales/**'
- 'scripts/validate-locales.js'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node scripts/validate-locales.js
That's the entire PR gate. A missing placeholder in any locale now blocks merge. Missing keys produce a warning (you may want to fail on those too once your pipeline is stable — make it strict and never look back).
Step 2: Translation sync workflow
When PRs land on main with changes to en.json, the sync workflow pushes the new keys to your translation provider and commits the result back.
This is where you choose your provider. Three common patterns:
Pattern A — Localingos / automated AI translation:
# .github/workflows/translate.yml
name: translate-i18n
on:
push:
branches: [main]
paths:
- 'locales/en.json'
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install -g @localingos/cli
- name: Sync to Localingos
env:
LOCALINGOS_API_KEY: ${{ secrets.LOCALINGOS_API_KEY }}
run: localingos sync --project myapp --locales-dir ./locales
- name: Open PR with translations
uses: peter-evans/create-pull-request@v6
with:
commit-message: "chore(i18n): sync translations"
branch: i18n/auto-sync
title: "chore(i18n): sync translations"
body: "Automated translation sync from English source."
delete-branch: true
Pattern B — Lokalise / TMS-based:
- name: Push source to Lokalise
env:
LOKALISE_API_TOKEN: ${{ secrets.LOKALISE_API_TOKEN }}
run: |
npx @lokalise/cli upload \
--project-id $LOKALISE_PROJECT_ID \
--token $LOKALISE_API_TOKEN \
--file locales/en.json \
--lang-iso en
- name: Pull translations from Lokalise
run: |
npx @lokalise/cli download \
--project-id $LOKALISE_PROJECT_ID \
--token $LOKALISE_API_TOKEN \
--format json \
--dest ./locales
This is slower (translators are humans on a queue) and the PR-back step usually has manual review. But if you have a translation team you trust, this works.
Pattern C — Roll your own with the OpenAI/Anthropic SDK:
- run: npm install @anthropic-ai/sdk
- run: node scripts/translate-via-claude.js
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
Where translate-via-claude.js is your own script that loads en.json, prompts Claude per locale, validates placeholder preservation, and writes the results. This gives you maximum control and is genuinely viable for small projects. For anything past ~50 keys and ~10 locales, you'll quickly want batching, retries, glossary management, and per-locale validation — at which point you've built a translation platform and may want to use one instead.
Whichever pattern you pick, the sync workflow's job is the same: take new English strings, produce translated ones, and commit them back via PR.
Step 3: Build verification
Every PR should also verify the app builds in every locale, not just English. This catches "missing key" errors that the validation script warned about but didn't fail on.
# .github/workflows/validate.yml (additional job)
build:
runs-on: ubuntu-latest
strategy:
matrix:
locale: [en, es, de, fr, ja, pt-BR]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: LOCALE=${{ matrix.locale }} npm run build
If your app uses runtime locale loading (the common case), one build covers all locales — skip the matrix and run npm run build once. The matrix is for setups that produce per-locale bundles.
Step 4: Deploy
Deploy is mostly orthogonal to i18n — but there's one subtlety. When you deploy a new translation, cache invalidation matters. A user whose browser cached es.json from yesterday won't see today's new strings until the cache expires.
Two patterns:
Filename hashing — your build tool emits es.abc123.json and references it from the bundled code. New translation = new hash = browsers fetch automatically. This is the default for most modern build tools (Webpack, Vite, Next.js).
Explicit invalidation — if you're serving /locales/es.json from a CDN with a long max-age, invalidate on deploy:
- name: Deploy
run: ./scripts/deploy.sh
- name: Invalidate CDN
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CDN_ID }} \
--paths "/locales/*"
End-to-end picture
Putting it together, your repository has this i18n lifecycle:
- Developer edits
locales/en.jsonin a PR. validate-i18nruns: JSON valid, placeholders match.- Build verification runs: every locale builds cleanly.
- PR is reviewed and merged.
translate-i18nruns on main: new keys sent to provider, translations returned.- Translation PR opens automatically; reviewer approves (often just-merge for AI pipelines).
- Deploy runs: app ships in every locale.
End-to-end time from "engineer commits a new string" to "string is live in 60 languages": ~5 minutes for an AI-translated pipeline, ~5 days for a TMS pipeline. Pick based on your latency requirements and quality bar.
Common pitfalls
A few things that catch teams setting this up for the first time:
Don't auto-merge translation PRs to main. Even if your translation provider is reliable, you want one PR per translation sync so you can revert cleanly if something goes wrong. Auto-merge from a bot's PR is fine; making the translation step push directly to main is not.
Don't run translation sync on every PR. Run it on push-to-main only. PRs that touch en.json should validate but not trigger a real translation call — otherwise you'll burn API quota on draft work and have churn in PRs.
Reserve a "do not translate" mechanism. Brand names, product names, code snippets in error messages should not be translated. Most providers honor a syntax like <x>Localingos</x> or a glossary file. Set this up day one or you'll be cleaning up "translated brand names" forever.
Cache the translation provider's output. If en.json hasn't changed, don't re-translate. The cheapest API call is the one you don't make.
Pin your validation script. As your translation file grows, the validation logic will be tweaked. Make sure CI runs the same version that engineers run locally — drift here causes "passes on my machine, fails in CI" frustration.
Where Localingos fits
If you don't want to operate the translation step yourself, Localingos is the drop-in: it handles the provider role in Pattern A above, with placeholder validation, glossary, plural-form correctness across CLDR rules, and batched cost-efficient AI translation built in. The free tier covers small apps end-to-end. See pricing if you want to evaluate it against the build-it-yourself path.
Wrap up
A working i18n CI/CD pipeline is two GitHub Actions workflows and one validation script — maybe 200 lines of YAML and JavaScript total. The teams that have this set up never debate translation quality in PR reviews, never hit "missing key" errors in production, and ship new languages as a same-day decision rather than a quarter-long project.
The teams that don't have this end up arguing about translation files in every release and treating "add a language" as a P1 engineering project. Pick the side you want to be on.