A missing translation key is the cheapest bug to detect and one of the most embarrassing to ship. Your German checkout renders checkout.button.label as literal text, a customer screenshots it, and you find out from support. Nothing in a typical stack catches this: the unit tests pass, the type checker is happy, lint has no opinion about your locale files.
The fix is mechanical. Treat translation health like test coverage: compute it on every push, and fail the build when it regresses. Here is how to wire that up in ten minutes.
The shape of the gate
A translation gate needs to answer one question per push: did any language get worse? That breaks down into checks a machine does better than a reviewer: every key in the source locale exists in every target, every placeholder that appears in the source survives translation, every ICU plural still parses, no language regressed below your floor.
Option 1: one curl call
Polylens exposes the gate as a single endpoint. POST your locale files with a project token; a failing gate returns HTTP 422, so curl -f exits non zero and the build goes red like any failing test.
curl -f -X POST https://polylens.sh/api/ci/scan \
-H "Authorization: Bearer $POLYLENS_TOKEN" \
-H "Content-Type: application/json" \
-d "$(node -e 'const fs=require("fs");console.log(JSON.stringify({files:{en:fs.readFileSync("locales/en.json","utf8"),de:fs.readFileSync("locales/de.json","utf8")},failOn:"error"}))')"Option 2: the zero-dependency helper
For more than two files, the helper script reads a whole locales directory and handles the request for you. It has no npm dependencies, so there is nothing to install in CI.
curl -fsSO https://polylens.sh/polylens-ci.mjs
POLYLENS_TOKEN=plk_... node polylens-ci.mjs ./localesOption 3: GitHub Actions
name: translations
on: [push, pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -fsSO https://polylens.sh/polylens-ci.mjs
- run: node polylens-ci.mjs ./locales
env:
POLYLENS_TOKEN: ${{ secrets.POLYLENS_TOKEN }}Tuning the gate
- ·FAIL_ON=error fails only on errors (missing keys, broken placeholders). Use warning to be stricter.
- ·MIN_SCORE=80 sets an absolute floor for any language.
- ·NO_REGRESSION=true fails when any language scores lower than its previous scan, even if still above the floor.
Start permissive and tighten. A gate that fails on day one for 200 legacy issues gets deleted by Friday. FAIL_ON=error with NO_REGRESSION=true is the setting that sticks: it freezes the status quo and refuses to let it get worse.
Why not just a key-diff script?
Plenty of teams have a 40-line script that compares key sets. It catches missing keys and nothing else: not the {{count}} a translator renamed, not the ICU plural that lost its other branch, not the English sentence pasted into the French file, not the translation that silently went stale when you reworded the source. A maintained scan engine with history catches all of it, and gives the team a number to rally around instead of a wall of diff output.
Get a health score for your locale files in under a minute. Free for one project.