Automate i18n in GitHub Actions: check every locale file on every PR
Translating locale files is the easy part to automate. What breaks is what translation does to placeholders and plurals. One workflow file catches it on every pull request, with no API key and no account.
Key takeaway
Translating locale files is the easy part to automate. What breaks is what translation does to placeholders and plurals. One workflow file catches it on every pull request, with no API key and no account.
Automating translation is easy now. Paste the file into a model, a TMS syncs it back, or a contributor sends a PR.
What none of those do is check the result. And the result breaks in boring, mechanical ways that a reviewer who doesn't read the language will never see:
- A placeholder gets translated:
{max}becomes{mix},{name}becomes{nombre}. The app prints the token literally. - A placeholder gets dropped: "Ein Artikel aus Ihrem Warenkorb ist nicht mehr verfügbar" instead of "
{names}became unavailable", so the user never learns which item. - A plural loses its separator. This week nocodb fixed 182 strings in 13 languages where machine translation had turned vue-i18n's
|into<unk>,●or+, so users saw both sentences run together: "3 filtro3 filtri".
Your tests pass through all of this, because tests run in English.
The workflow
Add .github/workflows/i18n.yml:
name: i18n check
on: [pull_request]
permissions:
contents: read
security-events: write # for the SARIF upload (PR annotations)
jobs:
i18n:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Shipi18n/shipi18n-github-action@v3
with:
locales: ./locales
source-language: en
# report only what breaks at runtime; drop this line to also check coverage
severity: 'missing-key=off,untranslated=off,orphan-key=off'
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: shipi18n.sarif
That's the whole setup. There's no API key, no account, and no model call: the check is deterministic and runs offline on the runner. It compares every translation against the source language and fails the job on errors. The severity line keeps it to things that break at runtime (dropped placeholders, broken plurals, invalid ICU), so a translation that simply hasn't arrived yet doesn't fail your PR. The SARIF upload puts each finding inline on the PR diff.
It reads flat (locales/en.json) and nested (locales/en/common.json) JSON trees, YAML (including Rails), Flutter .arb, Apple .xcstrings, Android strings.xml, gettext .po and XLIFF. The placeholder grammar is detected per tree: {name}, {{name}}, %{name}, %s, %1$s, %@ and ICU.
What a failure looks like
✗ es coverage 100.0% 1 error(s), 1 warning(s)
error upload.tooBig placeholder-missing — dropped {max}
fix: rename {mix} to {max}
warning upload.tooBig placeholder-added — unexpected {mix}
✗ fr coverage 100.0% 1 error(s), 0 warning(s)
error trash.deleted plural-forms — source has 2 plural forms ('|'), fr has 1
fix: write 2 forms separated by a plain '|', each keeping the source form's placeholders
Each finding has a one-line fix and a rule page explaining it. Errors fail the job; warnings never do unless you ask for it with fail-on: warning.
Adopting it on a catalog that already has problems
Most real catalogs fail on the first run. That's the point, but you don't want to fix 300 strings before you can merge anything. Two ways in:
Turn off what you don't care about yet. That's the severity line above. Remove a rule from it when you're ready to enforce it.
Or freeze today's state. A baseline records the current findings, and from then on only new ones fail. Write it once and commit it:
npx @shipi18n/cli check ./locales -s en --baseline .shipi18n/baseline.json --write-baseline
then point the action at it with baseline: .shipi18n/baseline.json.
Not on GitHub Actions, or no Node
The same check runs from a container in GitLab CI, Bitbucket, Jenkins or a pre-commit hook:
docker run --rm -v "$PWD:/work" ghcr.io/shipi18n/cli:latest check ./locales -s en
If your project already has a native check, extending it works too. Rails apps with i18n-tasks can add inconsistent_interpolations to their spec. 24pullrequests did exactly that after two rounds of fixes to dropped interpolations in its locale files.
If an AI agent writes your translations
Have it run the check on just the files it changed, read the JSON, fix, and repeat until the exit code is 0:
npx @shipi18n/cli check ./locales -s en --changed-only --json
--changed-only skips locales the agent didn't touch, so an old backlog doesn't block it. Every finding in the JSON has a fix field. Exit 1 means "fix and re-run"; exit 2 means the command itself is wrong.
And if you do want to translate in CI
The same action translates with your own Anthropic or OpenAI key (mode: translate, see the setup guide). Keep the check job next to it either way: whatever produced the translation, the check is what tells you whether it still works.
Next steps
- GitHub Action setup guide, the full configuration reference
- Every rule, with examples and fixes
- Broken translations in open source: what this check found in 64 real repositories, and what got fixed upstream
Does your CI check your translations?
One command, no account, no API key — the structural check runs anywhere. Apache-2.0, bring your own LLM for the semantic pass.
Star on GitHub →