GitHub Actions i18n CI Gates
A GitHub Actions i18n CI gate is a required status check that re-extracts translation keys from source, validates ICU MessageFormat syntax, measures per-locale coverage, and blocks the merge when a pull request introduces missing or orphaned keys — failing with the message every reviewer recognizes: Error: 14 keys present in en.json are missing in de.json. Without this gate, untranslated strings, dangling keys, and malformed plural patterns ship silently to production and only surface as raw welcome.header.title placeholders in the running UI.
This page builds the gate end to end: an extraction step that diffs the source catalog against committed locales, an ICU parse step, a coverage-threshold step with a configurable floor, a step that detects orphaned keys, and a matrix that fans the checks out one job per locale so reviewers see exactly which language failed. It sits inside Translation Workflows & CI/CD Pipeline Sync as the merge-time enforcement boundary between your repository and your translation platform.
Prerequisites
Concept & spec — what the gate actually enforces
A coverage gate compares the set of keys in the source locale against each target locale and asserts four invariants. Missing keys exist in source but not in a target — the string is untranslated. Orphaned keys (also called stale or dead keys) exist in a target but no longer in source — the string was deleted from code but its translation lingers. Empty values are present-but-blank entries that pass a naive key check yet render nothing. Malformed ICU is any value whose ICU MessageFormat plural, select, or selectordinal syntax fails to parse.
The plural arms your validator must accept are defined by Unicode CLDR; the language tags you iterate over in the matrix follow BCP 47 / RFC 5646, and ICU itself is specified in Unicode Technical Standard TR35. Treating these as a build contract — not a runtime concern — is the core idea: the gate is the enforcement point where this contract is checked, upstream of the translation platforms covered in the parent Translation Workflows & CI/CD Pipeline Sync area. Keys flow out to a platform like Crowdin or Weblate, and translated values flow back through this same gate before they merge.
Step-by-step implementation
1. Re-extract keys in CI and diff against the committed catalog
Never trust the committed en.json — re-run the extractor on every pull request and fail if the catalog is stale. A committed source file that drifts from the code is the root of most “missing key” surprises. Run the extractor, then assert the working tree is clean.
name: i18n-gate
on:
pull_request:
paths: ['src/**', 'locales/**']
permissions:
contents: read
pull-requests: write
jobs:
extract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: npx i18next-parser 'src/**/*.{ts,tsx}' -o 'locales/$LOCALE/$NAMESPACE.json'
- name: Fail if source catalog is stale
run: git diff --exit-code locales/en || (echo "::error::Run i18next-parser and commit locales/en"; exit 1)
2. Validate ICU syntax for every value
Parse each value to an AST before any coverage math. A single broken {count, plural, ...} clause should fail loudly here rather than at render time. The parser throws on a missing other arm, unbalanced braces, or an unescaped apostrophe.
// scripts/validate-icu.mjs
import { parse } from '@formatjs/icu-messageformat-parser';
import { readFileSync } from 'node:fs';
const file = process.argv[2];
const msgs = JSON.parse(readFileSync(file, 'utf8'));
let failed = 0;
for (const [key, value] of Object.entries(msgs)) {
if (typeof value !== 'string') continue;
try { parse(value, { requiresOtherClause: true }); }
catch (e) { console.error(`::error file=${file}::ICU parse error in "${key}": ${e.message}`); failed++; }
}
process.exit(failed ? 1 : 0);
3. Compute per-locale coverage against a threshold
Coverage is translated_keys / source_keys, where a key counts as translated only if it exists and its value is non-empty after trimming. Compare against a floor passed via env so different locales can carry different bars (a launch language at 100%, a low-traffic locale at 80%).
// scripts/coverage.mjs
import { readFileSync } from 'node:fs';
const [src, target, floorRaw] = process.argv.slice(2);
const floor = Number(floorRaw ?? 100);
const source = JSON.parse(readFileSync(src, 'utf8'));
const dest = JSON.parse(readFileSync(target, 'utf8'));
const keys = Object.keys(source);
const done = keys.filter((k) => typeof dest[k] === 'string' && dest[k].trim() !== '');
const pct = (done.length / keys.length) * 100;
console.log(`${target}: ${pct.toFixed(1)}% (${done.length}/${keys.length}), floor ${floor}%`);
if (pct < floor) { console.error(`::error::${target} below ${floor}% coverage`); process.exit(1); }
4. Detect missing and orphaned keys
Surface the actual offending keys, not just a percentage — reviewers fix faster when the comment lists checkout.cta.label. Missing keys are source − target; orphaned keys are target − source. Emit both as annotations.
// scripts/keydiff.mjs
import { readFileSync } from 'node:fs';
const [src, target] = process.argv.slice(2);
const s = new Set(Object.keys(JSON.parse(readFileSync(src, 'utf8'))));
const t = new Set(Object.keys(JSON.parse(readFileSync(target, 'utf8'))));
const missing = [...s].filter((k) => !t.has(k));
const orphaned = [...t].filter((k) => !s.has(k));
if (missing.length) console.error(`::error::${target} missing: ${missing.join(', ')}`);
if (orphaned.length) console.error(`::warning::${target} orphaned: ${orphaned.join(', ')}`);
process.exit(missing.length ? 1 : 0);
5. Fan out one job per locale with a matrix
Run the validation as a matrix so each language reports an independent check. A failure in ar does not mask a failure in de, and reviewers see a per-locale red X in the checks list.
validate:
needs: extract
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
locale: [de, fr, ja, ar, es, pt-BR]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
- run: node scripts/validate-icu.mjs locales/${{ matrix.locale }}.json
- run: node scripts/keydiff.mjs locales/en.json locales/${{ matrix.locale }}.json
- run: node scripts/coverage.mjs locales/en.json locales/${{ matrix.locale }}.json 90
6. Comment the diff back onto the pull request
Aggregate each locale’s result into one sticky comment so the failure is visible without opening logs. Capture the script output into a step, then post it with the GitHub Script action.
- id: report
if: always()
run: node scripts/coverage.mjs locales/en.json locales/${{ matrix.locale }}.json 90 >> report.txt 2>&1 || true
- uses: actions/github-script@v7
if: always()
with:
script: |
const fs = require('fs');
const body = '### i18n `${{ matrix.locale }}`\n```\n' + fs.readFileSync('report.txt','utf8') + '\n```';
await github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number, body });
Configuration reference
| Option | Type | Description / default |
|---|---|---|
COVERAGE_FLOOR |
integer (0–100) | Minimum translated-key percentage per locale. Default 100; relax per low-traffic locale. |
requiresOtherClause |
boolean | Passed to the ICU parser so a missing other arm fails validation. Default true. |
matrix.locale |
string[] | BCP 47 tags fanned out, one job each. No default — list every shipped locale explicitly. |
fail-fast |
boolean | false so one locale’s failure does not cancel sibling jobs. Default in matrices is true. |
treat_empty_as_missing |
boolean | Count blank-string values as untranslated. Default true; set false to allow intentional blanks. |
orphan_severity |
error | warning |
Whether stale keys block or merely annotate. Default warning; raise to error before release freezes. |
paths filter |
glob[] | Restricts the workflow trigger to src/** and locales/** so unrelated PRs skip the gate. |
permissions.pull-requests |
read | write |
Must be write for the diff comment step to post. |
Framework variants
React / Next.js (react-i18next). Extract with i18next-parser and point the ICU validator at the flat namespace JSON. Pair it with the patterns in React i18next component patterns so the keys the parser finds match the t() calls your components emit; a <Trans> component with nested markup needs keepRemoved: false to avoid false orphans.
Vue / Nuxt (vue-i18n). vue-i18n SFC <i18n> blocks and $t() calls extract via @intlify/unplugin-vue-i18n. Run the same coverage and ICU scripts against the generated locale JSON; the gate is framework-agnostic once keys are flat JSON.
Angular (@angular/localize). Angular extracts to XLIFF, not JSON. Convert with xliffmerge or parse XLIFF directly, then feed the <source>/<target> pairs into the coverage script. A missing <target> is a missing key; a state="new" target is effectively untranslated.
Node.js backend (formatjs). For server-rendered strings, extract with @formatjs/cli extract and compile --ast. The compile step doubles as ICU validation — a malformed message fails compile and therefore the job, no separate parser needed.
Verification
Run the full gate locally before pushing to confirm the scripts agree with CI:
node scripts/validate-icu.mjs locales/de.json
node scripts/keydiff.mjs locales/en.json locales/de.json
node scripts/coverage.mjs locales/en.json locales/de.json 90
Expected output on a healthy locale, and the exact failure a missing key produces:
# healthy
locales/de.json: 100.0% (240/240), floor 90%
# regression — exit code 1, gate blocks
::error::locales/de.json missing: checkout.cta.label, account.delete.confirm
locales/de.json: 99.2% (238/240), floor 90%
::error::locales/de.json below 90% coverage
Confirm the gate is enforced by opening a draft pull request that deletes one de key — the matrix job for de must turn red while fr and ja stay green, and the merge button must be disabled.
Common pitfalls
- Trusting the committed source catalog. Always re-extract in CI and
git diff --exit-code; a staleen.jsonhides keys the gate should have caught. See extracting translation keys with i18next-parser. - Counting empty strings as translated. A key with
""passes a naivekey in objcheck but renders nothing — trim and reject blanks. The dedicated guide to failing build on untranslated keys walks the exact threshold logic. - Validating JSON but not ICU. Valid JSON can still hold a broken
{count, plural, one {#}}with nootherarm; parse every value, as covered in the ICU Message Format deep dive. fail-fast: trueon the matrix. The first failing locale cancels the rest, hiding other languages’ problems — set itfalse.- Letting the platform overwrite in-flight keys. When Crowdin or Weblate pushes a sync branch, run the same gate on it so machine round-trips can’t bypass coverage.
- Treating orphaned keys as fatal too early. Mid-sprint, stale keys are normal; keep them a
warningand only escalate toerrorbefore a release freeze.
FAQ
Should the CI gate block the merge or just warn?
Block, for missing keys and ICU parse errors — those are correctness failures that render broken UI. Make the gate a required status check in branch protection so the merge button is disabled until it is green. Orphaned (stale) keys are best left as warnings during active development and only promoted to blocking failures ahead of a release freeze, when dead keys signal an incomplete cleanup.
How do I set different coverage thresholds per locale?
Pass the floor as a per-matrix value instead of a single constant. Replace the flat locale matrix with an include list of { locale, floor } objects — for example { locale: de, floor: 100 } and { locale: pt-BR, floor: 80 } — then read ${{ matrix.floor }} in the coverage step. Launch languages carry a 100% bar while low-traffic locales get a lower, still-rising floor.
Why re-extract keys in CI instead of trusting the committed source file?
Because the committed en.json drifts. A developer adds a t('new.key') call but forgets to run the extractor, so the source catalog never gains the key, and every target locale looks 100% complete while the new string is invisible to translators. Re-running the extractor and asserting a clean git diff turns that omission into a hard build failure at the point it is introduced.
Can this gate validate XLIFF and PO files, not just JSON?
Yes — the coverage and orphan logic is format-agnostic once you load keys into a map. For XLIFF, treat a missing or state="new" <target> as untranslated; for gettext PO, treat an empty msgstr as missing. The ICU parser only applies to ICU-syntax values, so PO files using printf-style placeholders skip that step and rely on a placeholder-equality check instead.
How do I post the failing keys as a pull-request comment?
Grant the workflow pull-requests: write, capture each script’s output to a file, and use actions/github-script with github.rest.issues.createComment to post a single sticky comment per locale. Listing the exact offending keys — checkout.cta.label rather than “coverage dropped” — lets reviewers fix the regression without opening the Actions logs.
Related
- Failing build on untranslated keys — the focused threshold-and-exit-code logic this gate depends on.
- Crowdin Integration for Dev Teams — push keys out and pull translations back through the same gate.
- Weblate Self-Hosted Setup — the self-hosted platform alternative that feeds this CI boundary.
- ICU Message Format Deep Dive — the plural/select syntax your validation step parses.
- Extracting translation keys with i18next-parser — the extraction step the diff check relies on.