shipglot

Check gettext .po files in CI

msgfmt leaves fuzzy entries out of the compiled .mo file, so users see the English msgid. A dropped %(name)s raises KeyError or prints the raw specifier. The gate reads each .po file, compares msgstr with msgid and follows the c-format and python-format flags.

What the gate reads

Format idNameExtensionsPlaceholders and pluralsLayout
gettext-poGNU gettext PO.po, .potprintf and Python: %s, %d, %1$s, %(name)s, {name}Every locale in one file

Detected layout

For a Django, Python, PHP and C projects using gettext project laid out like this:

locale/de/LC_MESSAGES/django.po
locale/fr/LC_MESSAGES/django.po

npx shipglot check finds the files without a config. npx shipglot init writes what it detected to shipglot.config.json:

{
  "sourceLocale": "en",
  "files": [
    {
      "format": "gettext-po",
      "path": "**/{locale}/LC_MESSAGES/{namespace}.po"
    }
  ]
}

Add "locales" to restrict target languages, "glossary" for approved terms, and "checks" to change a check's severity. The gate's README lists every option.

GitHub Actions workflow

Save as .github/workflows/translations.yml. The step fails on errors and annotates the pull request; warnings show in the job summary.

name: Translations
on:
  pull_request:
    paths:
      - "**/*.po"
      - "shipglot.config.json"
jobs:
  shipglot:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v5
      - uses: aurelien64/shipglot@v0
        with:
          fail-on: error

To see findings in GitHub code scanning as well, add sarif-file: shipglot.sarif under with: and upload it with github/codeql-action/upload-sarif@v3 (the job then needs security-events: write).

Defects the gate catches

Each example below was run through the gate when this page was built, with the config above. The findings under it are the gate's output.

1. A fuzzy entry

msgfmt skips entries marked fuzzy, so this string ships in English.

locale/fr/LC_MESSAGES/django.po

msgid ""
msgstr ""
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

#, fuzzy, python-brace-format
#| msgid "Hello {name}"
msgid "Hello {name}, welcome back!"
msgstr "Bonjour {name} !"

Gate output

  • warningunreviewedfr
    Hello {name}, welcome back!
    marked fuzzy; gettext's msgfmt drops fuzzy entries, so users see the source text
    locale/fr/LC_MESSAGES/django.po:10

2. A %(name)s argument dropped

The folder name disappears from the French message.

locale/fr/LC_MESSAGES/django.po

msgid ""
msgstr ""
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

#, python-format
msgid "%(count)d files uploaded to %(folder)s"
msgstr "%(count)d fichiers envoyés"

Gate output

  • %(count)d files uploaded to %(folder)s
    placeholder mismatch: missing %(folder)s
    locale/fr/LC_MESSAGES/django.po:9

3. An empty msgstr

gettext returns the msgid, so the string shows in English.

locale/de/LC_MESSAGES/django.po

msgid ""
msgstr ""
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

msgid "Sign in"
msgstr "Anmelden"

msgid "Delete account"
msgstr ""

Gate output

  • Delete account
    missing in de
    locale/de/LC_MESSAGES/django.po:10

4. A file that does not parse

An unterminated string stops msgfmt and every later translation in the file.

locale/de/LC_MESSAGES/django.po

msgid ""
msgstr ""
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

msgid "Sign in"
msgstr "Anmelden"

locale/fr/LC_MESSAGES/django.po

msgid ""
msgstr ""
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

msgid "Sign in"
msgstr "Se connecter

Gate output

  • Sign in
    missing in fr
    locale/de/LC_MESSAGES/django.po:7
  • Unterminated string: missing closing double quote
    locale/fr/LC_MESSAGES/django.po:8

Run it on your machine

npx shipglot check
npx shipglot check --reporter json=shipglot.json

Exit code 0 means clean, 1 means findings at or above --fail-on, 2 means a usage or config error. Checks used on this page: unreviewed, placeholder-mismatch, missing-key, parse-error.

To check a single file without installing anything, use the in-browser checker.

Translation in the same workflow

With a Shipglot account, npx shipglot translate translates new and changed keys and writes them into these files before the check runs. Hosted translation is not open yet; see the signup page.