Why Automate Localization?#

Manual localization workflows are the bottleneck in most teams' release cycles:

text
Developer: "I pushed the update."
PM: "Did you send the new strings to the translator?"
Developer: "...I'll do it now."
PM: "When will translations be back?"
Developer: "Maybe next week?"

By integrating localization into your CI/CD pipeline, new strings are translated automatically, and your localized app is always ready to ship.

The Goal: Continuous Localization#

Before automation:

text
Code → Manual export → Email translator → Wait days → Manual import → Test → Deploy

After automation:

text
Code → Push → CI detects new strings → Auto-translate → PR with translations → Merge → Deploy

The difference: days vs. minutes.

Architecture Overview#

A typical automated localization pipeline has three components:

  1. String Detection: Identify new or changed strings in your commit
  2. Translation: Send strings to your translation service (shipglobal.dev)
  3. Integration: Commit translated files back to your repository
text
┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│  Git Push    │ →  │  CI Pipeline │ →  │  Translate   │
│  (new strings)│    │  (detect     │    │  (API call)  │
└─────────────┘    │   changes)   │    └──────┬──────┘
                   └──────────────┘           │
                                              ▼
┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│  Merge      │ ←  │  PR with     │ ←  │  Commit      │
│             │    │  translations│    │  translations│
└─────────────┘    └──────────────┘    └─────────────┘

Implementation: GitHub Actions#

Here's a practical GitHub Actions workflow that translates new strings on every push:

Basic Workflow#

yaml
# .github/workflows/localize.yml
name: Localize

on:
  push:
    branches: [main, develop]
    paths:
      - 'src/locales/en/**'      # Watch English source files
      - 'src/strings/en.json'     # Or your specific file

jobs:
  translate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Check for string changes
        id: changes
        run: |
          # Detect if source language files changed
          CHANGED=$(git diff --name-only HEAD~1 HEAD -- src/locales/en/)
          echo "changed_files=$CHANGED" >> $GITHUB_OUTPUT
          if [ -z "$CHANGED" ]; then
            echo "has_changes=false" >> $GITHUB_OUTPUT
          else
            echo "has_changes=true" >> $GITHUB_OUTPUT
          fi

      - name: Translate via shipglobal.dev API
        if: steps.changes.outputs.has_changes == 'true'
        env:
          SHIPGLOBAL_API_KEY: ${{ secrets.SHIPGLOBAL_API_KEY }}
        run: |
          # Upload source file and get translations
          for lang in de es fr ja pt; do
            curl -X POST https://api.shipglobal.dev/v1/translate \
              -H "Authorization: Bearer $SHIPGLOBAL_API_KEY" \
              -F "file=@src/locales/en/strings.json" \
              -F "target_language=$lang" \
              -o "src/locales/$lang/strings.json"
          done

      - name: Create Pull Request
        if: steps.changes.outputs.has_changes == 'true'
        uses: peter-evans/create-pull-request@v5
        with:
          title: 'chore: update translations'
          body: 'Automated translation update triggered by changes to English source strings.'
          branch: translations/auto-update
          commit-message: 'chore: update translations for latest string changes'

Advanced Workflow with Validation#

yaml
# .github/workflows/localize-advanced.yml
name: Localize (Advanced)

on:
  push:
    branches: [main]
    paths:
      - 'src/locales/en/**'

jobs:
  translate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Detect new strings
        id: detect
        run: |
          # Compare current and previous English strings
          git show HEAD~1:src/locales/en/strings.json > /tmp/old_strings.json 2>/dev/null || echo '{}' > /tmp/old_strings.json
          NEW_KEYS=$(node -e "
            const old = require('/tmp/old_strings.json');
            const curr = require('./src/locales/en/strings.json');
            const newKeys = Object.keys(curr).filter(k => !old[k] || old[k] !== curr[k]);
            console.log(newKeys.length);
          ")
          echo "new_key_count=$NEW_KEYS" >> $GITHUB_OUTPUT

      - name: Translate new strings
        if: steps.detect.outputs.new_key_count > 0
        env:
          SHIPGLOBAL_API_KEY: ${{ secrets.SHIPGLOBAL_API_KEY }}
        run: |
          TARGET_LANGUAGES="de es fr ja pt ko it"
          for lang in $TARGET_LANGUAGES; do
            curl -X POST https://api.shipglobal.dev/v1/translate \
              -H "Authorization: Bearer $SHIPGLOBAL_API_KEY" \
              -F "file=@src/locales/en/strings.json" \
              -F "target_language=$lang" \
              -o "src/locales/$lang/strings.json"
          done

      - name: Validate translations
        run: |
          # Check all translation files are valid JSON
          for file in src/locales/*/strings.json; do
            python3 -m json.tool "$file" > /dev/null || {
              echo "Invalid JSON: $file"
              exit 1
            }
          done

          # Check all files have the same keys
          node -e "
            const fs = require('fs');
            const en = Object.keys(require('./src/locales/en/strings.json')).sort();
            const dirs = fs.readdirSync('./src/locales').filter(d => d !== 'en');
            for (const dir of dirs) {
              const keys = Object.keys(require('./src/locales/' + dir + '/strings.json')).sort();
              const missing = en.filter(k => !keys.includes(k));
              if (missing.length) {
                console.error(dir + ' missing keys:', missing);
                process.exit(1);
              }
            }
            console.log('All translations have matching keys');
          "

      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v5
        with:
          title: 'chore: update translations (${{ steps.detect.outputs.new_key_count }} new strings)'
          body: |
            Automated translation update.
            - **New/changed strings**: ${{ steps.detect.outputs.new_key_count }}
            - **Languages updated**: de, es, fr, ja, pt, ko, it
            - **Validation**: Passed
          branch: translations/auto-update

Implementation: GitLab CI#

yaml
# .gitlab-ci.yml
localize:
  stage: post-build
  rules:
    - changes:
        - src/locales/en/**
  script:
    - |
      for lang in de es fr ja pt; do
        curl -X POST https://api.shipglobal.dev/v1/translate \
          -H "Authorization: Bearer $SHIPGLOBAL_API_KEY" \
          -F "file=@src/locales/en/strings.json" \
          -F "target_language=$lang" \
          -o "src/locales/$lang/strings.json"
      done
    - git config user.email "ci@yourapp.com"
    - git config user.name "CI Bot"
    - git add src/locales/
    - git commit -m "chore: update translations" || true
    - git push origin HEAD:translations/auto-update

Best Practices#

1. Only Translate What Changed#

Don't re-translate your entire file on every push. Use Translation Memory to avoid re-translating unchanged strings and to reduce costs.

2. Create PRs, Don't Auto-Merge#

Always create a pull request for translation changes. This gives your team visibility and a chance to review.

3. Validate Translations#

Add validation steps:

  • JSON/XML syntax validation
  • Key completeness check (all keys present in all languages)
  • Placeholder consistency (e.g., {{name}} exists in translations)
  • String length warnings (translations significantly longer than source)

4. Use Branch Protection#

Don't let translation PRs bypass your normal review process. Treat them like code changes.

5. Cache Translation Memory#

If you're using shipglobal.dev, Translation Memory handles this automatically — you only pay for new or changed strings.

6. Handle Merge Conflicts#

Translation files are JSON/XML, and merge conflicts are common. Use a strategy:

  • Always regenerate from source English file
  • Or use a merge tool that understands your file format

Platform-Specific Tips#

iOS (Localizable.strings)#

bash
# Convert .strings to JSON for translation, then back
# Or upload .strings directly to shipglobal.dev
curl -X POST https://api.shipglobal.dev/v1/translate \
  -F "file=@en.lproj/Localizable.strings" \
  -F "target_language=de" \
  -o "de.lproj/Localizable.strings"

Android (strings.xml)#

bash
# Upload strings.xml directly
curl -X POST https://api.shipglobal.dev/v1/translate \
  -F "file=@app/src/main/res/values/strings.xml" \
  -F "target_language=de" \
  -o "app/src/main/res/values-de/strings.xml"

React / Next.js (JSON)#

bash
# Upload your i18n JSON file
curl -X POST https://api.shipglobal.dev/v1/translate \
  -F "file=@public/locales/en/common.json" \
  -F "target_language=de" \
  -o "public/locales/de/common.json"

Monitoring and Alerts#

Set up alerts for your localization pipeline:

  • Translation failures: Notify the team if the API is unavailable
  • Key mismatches: Alert if translations are missing keys
  • Budget alerts: Track translation costs per month
  • PR age: Alert if translation PRs sit unmerged for more than 24 hours

Conclusion#

Automating localization in your CI/CD pipeline removes the biggest friction from going global. Instead of localization being a manual, error-prone process that delays releases, it becomes an automated step that happens in the background.

Start simple — a basic workflow that translates on push and creates a PR. Then iterate: add validation, monitoring, and optimization as your needs grow.

The result: your app is always ready to ship in every language, with zero manual translation management.