← Back to Blog

Fail Flutter CI on Missing Translations (Fix en_US Trap)

flutteri18nci-cdlocalizationgithub-actions

Fail Flutter CI on Missing Translations (Fix en_US Trap)

A missing translation should never reach production silently. Flutter's gen-l10n tool already knows which ARB keys are untranslated per locale — you just have to wire it into CI so a merge turns red when someone forgets to translate a new string. This guide gives you a copy-paste GitHub Actions gate, and then fixes the sharp edge that bites teams the moment they add a region locale: the en → en_US inherited-key false positive tracked in flutter/flutter #176020.

Step 1: Emit the untranslated-messages-file

flutter gen-l10n can write a report of every message that is missing from each locale. Turn it on in l10n.yaml:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
untranslated-messages-file: build/untranslated.json

The key line is untranslated-messages-file. When you run flutter gen-l10n, the tool writes a JSON map of locale → [message keys] for everything still untranslated. Two important facts most posts get wrong:

  • The output is JSON, not a plain list — a map of locale names to arrays of message keys.
  • When everything is translated, the tool writes an empty object {} (not a zero-byte file). So a naive "is the file non-empty?" check always fails, because {} is two bytes. You have to parse it.

A typical report looks like this:

{
  "de": ["newFeatureTitle", "newFeatureBody"],
  "ja": ["newFeatureTitle"]
}

Step 2: The CI gate that actually works

Because the file is JSON, the correct gate counts the total number of untranslated keys across all locales and fails only when that count is greater than zero. Here is the whole GitHub Actions job:

# .github/workflows/l10n.yml
name: l10n-gate
on: [pull_request]

jobs:
  translations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
      - run: flutter pub get
      - run: flutter gen-l10n

      - name: Fail on untranslated messages
        run: |
          if [ ! -f build/untranslated.json ]; then
            echo "No report generated — nothing untranslated."; exit 0
          fi
          count=$(jq '[.[] | length] | add // 0' build/untranslated.json)
          if [ "$count" -ne 0 ]; then
            echo "::error::$count untranslated message(s) found:"
            jq -r 'to_entries[] | "  " + .key + ": " + (.value | join(", "))' \
              build/untranslated.json
            exit 1
          fi
          echo "All locales fully translated."

That jq '[.[] | length] | add // 0' expression sums the array lengths across every locale, defaulting to 0 when the map is empty. flutter gen-l10n may skip writing the file entirely if there is nothing to report, so the existence guard keeps the job green in that case too.

This is the whole flutter l10n github actions check — no third-party linter required. Any PR that adds a key to app_en.arb without translating it everywhere fails before merge.

Step 3: The en_US inherited-key trap (flutter #176020)

Here is where teams get burned. The moment you add a region-specific ARB file — say app_en_US.arb alongside your app_en.arb template — your green CI turns red for no real reason.

Why? Flutter's localization inheritance means en_US intentionally inherits every key from its base language en. You only override the handful of strings that differ ("color", date formats, currency phrasing). At runtime this is 100% correct: any key absent from app_en_US.arb resolves from app_en.arb.

But untranslated-messages-file doesn't understand inheritance. It compares each locale against the template and lists every key missing from app_en_US.arb as "untranslated" — even though those keys are deliberately inherited. This is the flutter untranslated.txt false positive en_US problem, filed as flutter/flutter #176020. Your report fills up with dozens of phantom entries:

{
  "en_US": ["hello", "goodbye", "settingsTitle", "...every base key you didn't override..."]
}

None of those are real problems, but the naive gate from Step 2 will still fail the build.

Step 4: Silence the false positives without weakening the gate

The fix is to treat an inherited key as translated. A region locale like xx_YY inherits from its base language xx. So a key reported as missing in en_US is only a genuine problem if it is also missing from en. If the base language has it, the region correctly inherits it — drop it from the report.

This jq filter does exactly that: for every region locale (name contains _), it keeps only the keys that are also untranslated in the base language. Base languages are left untouched.

# filter-inherited.sh — removes inherited-key false positives
jq '
  . as $all
  | to_entries
  | map(
      (.key | split("_")) as $parts
      | if ($parts | length) > 1
        then .value |= [ .[] | select(
               . as $k | (($all[$parts[0]]) // []) | index($k)
             ) ]
        else .
        end
    )
  | from_entries
  | with_entries(select(.value | length > 0))
' build/untranslated.json > build/untranslated.filtered.json

The logic, line by line: capture the whole map as $all; for each region locale, keep a key only if it also appears in the base language's untranslated list (index returns non-null when found); then prune any locale whose list is now empty. A truly-missing string — absent from both en and en_US — survives and still fails the build. Only genuinely-inherited keys are removed.

Swap the count in your workflow to read the filtered file:

      - name: Fail on untranslated messages
        run: |
          bash filter-inherited.sh
          count=$(jq '[.[] | length] | add // 0' build/untranslated.filtered.json)
          if [ "$count" -ne 0 ]; then
            echo "::error::$count real untranslated message(s):"
            jq -r 'to_entries[] | "  " + .key + ": " + (.value | join(", "))' \
              build/untranslated.filtered.json
            exit 1
          fi

Now en_US inheriting from en produces zero findings, while de forgetting newFeatureTitle still stops the merge. That's the behavior #176020 proposes to make native (via a future exclude-inherited-keys-from-untranslated flag) — until it ships, this filter is the correct workaround.

Keep the ARB files clean at the source

A CI gate catches mistakes, but the fewer false alarms you generate the better. Two habits help: only put overridden strings in region ARB files (never copy the whole base file), and keep your ARB keys and metadata consistent so gen-l10n behaves predictably. If you're still tuning your setup, our complete l10n.yaml configuration guide covers every option, and the common localization errors guide walks through the failures you'll hit before the CI gate ever runs.

Managing dozens of keys across locales by hand is where drift creeps in. The FlutterLocalisation ARB editor shows missing translations per locale in real time — so the gate above almost never has to fire, and when it does, the fix is one click away.

Try FlutterLocalisation free and stop shipping half-translated releases.