shipglot

Check .NET RESX files in CI

String.Format throws FormatException when a translation uses {1} but only one argument is passed, and the resource compiler rejects duplicate names. The gate reads the neutral .resx and each culture file next to it and checks every <data> value.

What the gate reads

Format idNameExtensionsPlaceholders and pluralsLayout
resx.NET RESX.resxString.Format items: {0}, {0:N2}, {name}One file per locale

Detected layout

For a .NET project laid out like this:

Shop/Resources/Strings.de.resx
Shop/Resources/Strings.fr.resx
Shop/Resources/Strings.resx

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

{
  "sourceLocale": "en",
  "files": [
    {
      "format": "resx",
      "path": "Shop/Resources/Strings{.locale}.resx"
    }
  ]
}

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:
      - "Shop/Resources/**"
      - "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 format item renumbered

The code passes one argument. {1} makes String.Format throw FormatException in French.

Shop/Resources/Strings.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Greeting" xml:space="preserve">
    <value>Hello {0}, welcome back</value>
  </data>
</root>

Shop/Resources/Strings.fr.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Greeting" xml:space="preserve">
    <value>Bonjour {1}, bon retour</value>
  </data>
</root>

Gate output

  • Greeting
    placeholder mismatch: missing {0}; unexpected {1}
    Shop/Resources/Strings.fr.resx:6

2. A duplicate resource name

The resource generator fails on duplicate names; which value wins depends on the tool.

Shop/Resources/Strings.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Total" xml:space="preserve">
    <value>Total amount</value>
  </data>
</root>

Shop/Resources/Strings.fr.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Total" xml:space="preserve">
    <value>Montant total</value>
  </data>
  <data name="Total" xml:space="preserve">
    <value>Total</value>
  </data>
</root>

Gate output

  • Total
    Duplicate resource name "Total"
    Shop/Resources/Strings.fr.resx:9

3. A resource missing from a culture

ResourceManager falls back to the neutral culture, so German users see English.

Shop/Resources/Strings.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Save" xml:space="preserve">
    <value>Save</value>
  </data>
  <data name="Cancel" xml:space="preserve">
    <value>Cancel</value>
  </data>
</root>

Shop/Resources/Strings.de.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Save" xml:space="preserve">
    <value>Speichern</value>
  </data>
</root>

Gate output

  • Cancel
    missing in de
    Shop/Resources/Strings.resx:9

4. Personal data pasted into a translation

A translator's or tester's email address ended up in the shipped string.

Shop/Resources/Strings.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Footer" xml:space="preserve">
    <value>Questions? Write to us any time.</value>
  </data>
</root>

Shop/Resources/Strings.fr.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <data name="Footer" xml:space="preserve">
    <value>Des questions ? Écrivez à marie.dupont@gmail.com.</value>
  </data>
</root>

Gate output

  • Footer
    email address introduced by translation: marie.dupont@gmail.com
    Shop/Resources/Strings.fr.resx:6

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: placeholder-mismatch, format-validity, missing-key, personal-data.

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.