Fail CI When a Flutter Locale Is Missing Translations
A PR adds checkoutButton to app_en.arb, nobody touches app_de.arb or app_fr.arb, and three days later a German user sees an English button. flutter gen-l10n knew about the gap the whole time. It printed a warning, generated the German getter with the English string as a fallback, and the build went green.
The fix is small. gen-l10n can write a JSON report of every missing key per locale. Nothing reads that report by default, so the rest of this post is about reading it and exiting 1.
Step 1: the l10n.yaml line
Add untranslated-messages-file to the l10n.yaml at your project root:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
untranslated-messages-file: untranslated.json
The Flutter docs describe the option as "the location of a file that describes the localization messages that haven't been translated yet." The path is relative to the project root. Add untranslated.json to .gitignore; it is a build artefact, not source.
Without this option, gen-l10n only prints a per-locale count to the console, something like "de": 2 untranslated message(s)., and tells you to pass --untranslated-messages-file for a detailed report. A console line is easy to miss. A file is something a script can read.
Step 2: the JSON shape it emits
Run the generator on its own, without launching the app:
flutter gen-l10n
If app_de.arb is missing two keys and app_fr.arb is missing one, untranslated.json looks like this:
{
"de": [
"checkoutButton",
"orderSummaryTitle"
],
"fr": [
"checkoutButton"
]
}
Two details matter for the check script:
- The top-level keys are locale codes as they appear in the ARB filename (
de,fr,pt_BR, and so on). The values are arrays of message keys from the template file. - Only locales with gaps appear. When every locale is complete, the generator still writes the file, and its content is exactly
{}.
The template locale never appears, since by definition it has every key. Keys that exist in a translation but not in the template are a different problem (stale keys) and are not reported here.
Step 3: a 20-line Dart check script
Save this as tool/check_untranslated.dart. It reads the report, subtracts an allowlist of keys you intentionally leave untranslated, and exits 1 if anything remains.
import 'dart:convert';
import 'dart:io';
// Keys that are deliberately identical to the template in these locales.
const allowlist = <String, Set<String>>{
'de': {'appName', 'okButton'},
'fr': {'appName'},
};
void main() {
final file = File('untranslated.json');
if (!file.existsSync()) {
stderr.writeln('untranslated.json not found. Run `flutter gen-l10n` first.');
exit(2);
}
final report = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
var failed = false;
for (final MapEntry(key: locale, value: keys) in report.entries) {
final allowed = allowlist[locale] ?? const <String>{};
final missing = (keys as List).cast<String>().where((k) => !allowed.contains(k)).toList();
if (missing.isEmpty) continue;
failed = true;
stderr.writeln('$locale is missing ${missing.length} translation(s): ${missing.join(', ')}');
}
if (failed) exit(1);
stdout.writeln('All locales fully translated.');
}
Run it locally:
flutter gen-l10n && dart run tool/check_untranslated.dart
A failing run prints one line per locale and exits non-zero:
de is missing 2 translation(s): checkoutButton, orderSummaryTitle
fr is missing 1 translation(s): checkoutButton
Exit code 2 for a missing report file is deliberate. It means the pipeline ran the script before the generator, which is a wiring bug, not a translation bug, and the distinct code makes that obvious in CI logs.
Why an allowlist and not a threshold
Some keys genuinely should not be translated: a brand name, a product SKU format, an "OK" that is the same in German. If you handle those by copying the English string into app_de.arb, the report goes quiet but you lose the signal that the key was reviewed. The allowlist keeps that decision explicit and in code review. A percentage threshold ("fail below 95% coverage") sounds pragmatic but lets exactly the kind of single-key regression this post is about slip through.
Keep the allowlist per locale. A key that needs no German translation may still need a Japanese one.
Step 4: the GitHub Actions job
This workflow runs on every pull request that touches localization files. It uses the community subosito/flutter-action, which installs the stable channel and caches the SDK between runs.
name: l10n
on:
pull_request:
paths:
- 'lib/l10n/**'
- 'l10n.yaml'
- 'tool/check_untranslated.dart'
jobs:
untranslated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- run: flutter pub get
- run: flutter gen-l10n
- run: dart run tool/check_untranslated.dart
The paths filter keeps the job off PRs that do not touch translations. If your app already has a test workflow that runs flutter pub get, you can drop the two last steps into it instead of a separate job; the generator takes a second or two.
If you also want the gaps visible in the PR without opening logs, add a step that prints the JSON on failure:
- if: failure()
run: cat untranslated.json
Common gotchas
The check passes locally but fails in CI. Usually untranslated.json is stale on your machine. The generator rewrites it on every run, so always pair the two commands, or add a flutter gen-l10n step to a pre-commit hook.
A new locale appears with every key missing. Adding app_it.arb with only @@locale inside is a valid way to start a locale, and the check will correctly fail with the full key list. If you want to land the empty file first, add the locale to the allowlist temporarily, or better, do not merge until the translator has filled it.
Plural and select messages. The report lists a key as untranslated only when it is absent from the locale file. A German plural that exists but drops the other case, or a Russian one missing many, is not a missing key and will not show up here. gen-l10n raises its own error for a missing other case, but a dropped few or many compiles fine and simply renders the wrong form at runtime.
Placeholders. Same story. A translation that exists but omits a {count} placeholder passes this check. That is a correctness problem worth a second lint, but it is out of scope for a missing-key gate.
Catch it before CI
The script above is a safety net. It fires after the PR is open, after the translator has moved on, and the fix is a second round trip. The cheaper place to catch a gap is while the key is being added.
The FlutterLocalisation ARB editor shows every key against every locale side by side, so a key added to English with nothing in German or French is visible as an empty cell before anyone commits. It also validates ICU plural syntax per locale and flags a dropped few or many for languages that need it, which is exactly the case the CI check cannot see. Keep the Dart gate in CI, and use the editor so it rarely has anything to say.
If you are shipping three or more locales, the free tier covers getting started; the pricing page has the details. Try FlutterLocalisation free and see your per-locale gaps before they reach a pipeline.