GetTranslated.AI
AI Translation & Automation

String Extraction Automation: CI/CD Pipelines That Actually Work for Mobile Localization

String Extraction Automation: CI/CD Pipelines That Actually Work for Mobile Localization

Manual string management is where localization efforts go to die. You've seen it: developers forget to extract new strings, translators work with outdated files, and product releases get delayed because someone needs to hunt down 47 hardcoded strings scattered across the codebase. The solution isn't better documentation or more Slack reminders—it's automation that makes string extraction impossible to forget.

Here's how to build CI/CD pipelines that automatically detect, extract, and manage localization strings across iOS, Android, and React Native projects without breaking your development velocity.


The String Extraction Problem

Every mobile team hits the same wall around 10-15 engineers. The manual workflows that worked fine for a small team suddenly become bottlenecks. Developers ship features with hardcoded strings, QA catches localization issues days later, and the translation team is always working with incomplete or outdated files.

This creates the exact technical debt patterns covered in our post on localization architecture. Manual string management scales poorly because it relies on perfect human behavior, which doesn't exist in fast-moving engineering teams.

The core issues:

String Detection: Knowing when new translatable strings appear in the codebase
Extraction Timing: When to extract strings without disrupting development
Change Management: Handling modifications to existing strings safely
Deletion Safety: Removing unused strings without breaking translations
TMS Synchronization: Keeping translation management systems updated automatically

Each of these requires different automation strategies depending on your platform and workflow.


iOS String Extraction Pipeline

iOS projects have the advantage of well-defined string patterns, making automation relatively straightforward. The key is hooking into Xcode's build process and your CI system to catch strings before they reach production.

Basic GitHub Actions Setup

name: iOS String Extraction
on:
  push:
    branches: [main, develop]
  pull_request:
    paths: 
      - '**/*.swift'
      - '**/*.m'
      - '**/*.strings'

jobs:
  extract-strings:
    runs-on: macos-latest
    steps:
    - uses: actions/checkout@v4

    - name: Extract strings from source
      run: |
        # Find all localizable strings
        find . -name "*.swift" -exec grep -l "NSLocalizedString\|String.localized" {} \; > swift_files.txt

        # Extract to base strings file
        genstrings -o Base.lproj $(cat swift_files.txt)

        # Check for new strings
        git diff --name-only Base.lproj/Localizable.strings

    - name: Validate string keys
      run: |
        # Check for duplicate keys
        python3 scripts/validate_strings.py Base.lproj/Localizable.strings

        # Ensure key naming conventions
        python3 scripts/check_naming.py Base.lproj/Localizable.strings

    - name: Sync with TMS
      env:
        TMS_API_KEY: ${{ secrets.TMS_API_KEY }}
      run: |
        python3 scripts/sync_tms.py --platform ios --action upload

Advanced String Detection

The basic genstrings approach misses custom localization patterns. Here's a more comprehensive solution:

# scripts/ios_string_extractor.py
import re
import os
import json
from pathlib import Path

class iOSStringExtractor:
    def __init__(self, project_path):
        self.project_path = Path(project_path)
        self.patterns = [
            r'NSLocalizedString\s*\(\s*@?"([^"]+)"',
            r'String\.localized\s*\(\s*"([^"]+)"',
            r'\.localized\s*\(\s*key:\s*"([^"]+)"',
            # Add your custom patterns
        ]

    def extract_strings(self):
        strings = {}
        swift_files = self.project_path.rglob("*.swift")

        for file_path in swift_files:
            with open(file_path, 'r', encoding='utf-8') as f:
                content = f.read()

            for pattern in self.patterns:
                matches = re.finditer(pattern, content)
                for match in matches:
                    key = match.group(1)
                    strings[key] = {
                        'file': str(file_path),
                        'line': content[:match.start()].count('\n') + 1,
                        'context': self._get_context(content, match.start())
                    }

        return strings

    def _get_context(self, content, position):
        # Extract surrounding code for translator context
        lines = content[:position].split('\n')
        return lines[-2:] if len(lines) > 1 else lines

Android String Automation

Android's resource system provides clear structure, but the challenge is handling string arrays, plurals, and styled strings correctly.

Gradle Integration

// build.gradle
task extractStrings {
    doLast {
        def stringsFile = file("src/main/res/values/strings.xml")
        def sourceFiles = fileTree(dir: 'src/main/java', include: '**/*.java') +
                         fileTree(dir: 'src/main/java', include: '**/*.kt')

        def extractor = new AndroidStringExtractor()
        def extractedStrings = extractor.extractFromSources(sourceFiles)

        // Compare with existing strings.xml
        def currentStrings = extractor.parseStringsXml(stringsFile)
        def newStrings = extractedStrings - currentStrings.keySet()

        if (!newStrings.empty) {
            println "New strings detected: ${newStrings.size()}"
            extractor.updateStringsXml(stringsFile, extractedStrings)
        }
    }
}

// Run before build
preBuild.dependsOn extractStrings

GitLab CI Pipeline

# .gitlab-ci.yml
stages:
  - extract
  - validate
  - sync

android-strings:
  stage: extract
  image: gradle:jdk11
  script:
    - ./gradlew extractStrings
    - git diff --exit-code src/main/res/values/strings.xml || echo "New strings found"
  artifacts:
    paths:
      - src/main/res/values/strings.xml
    expire_in: 1 hour
  only:
    changes:
      - "src/main/java/**/*.java"
      - "src/main/java/**/*.kt"

validate-android:
  stage: validate
  dependencies:
    - android-strings
  script:
    - python3 scripts/validate_android_strings.py
    - python3 scripts/check_plurals.py
    - python3 scripts/lint_string_formatting.py

Handling Complex Android Strings

# scripts/android_string_validator.py
import xml.etree.ElementTree as ET
from typing import Dict, List, Tuple

class AndroidStringValidator:
    def __init__(self, strings_file: str):
        self.tree = ET.parse(strings_file)
        self.root = self.tree.getroot()

    def validate_placeholders(self) -> List[Tuple[str, str]]:
        """Check for safe placeholder usage"""
        errors = []

        for string_elem in self.root.findall('string'):
            name = string_elem.get('name')
            text = string_elem.text or ''

            # Check for unsafe % placeholders
            if '%s' in text or '%d' in text:
                errors.append((name, 'Use numbered placeholders like %1$s'))

            # Validate numbered placeholders are sequential
            placeholders = re.findall(r'%(\d+)\$[sd]', text)
            if placeholders:
                numbers = [int(p) for p in placeholders]
                expected = list(range(1, len(numbers) + 1))
                if sorted(numbers) != expected:
                    errors.append((name, 'Placeholder numbers must be sequential'))

        return errors

    def check_missing_translations(self, locale_dirs: List[str]) -> Dict[str, List[str]]:
        """Find strings missing in locale-specific files"""
        base_strings = {elem.get('name') for elem in self.root.findall('string')}
        missing = {}

        for locale_dir in locale_dirs:
            locale_file = f"{locale_dir}/strings.xml"
            if os.path.exists(locale_file):
                locale_tree = ET.parse(locale_file)
                locale_strings = {elem.get('name') for elem in locale_tree.getroot().findall('string')}
                missing[locale_dir] = list(base_strings - locale_strings)

        return missing

React Native Cross-Platform Pipeline

React Native's flexibility creates the most complex string extraction challenge. You're dealing with JavaScript/TypeScript patterns, platform-specific resource files, and potentially different i18n libraries.

Unified Detection Strategy

// scripts/rn-string-extractor.js
const fs = require('fs');
const path = require('path');
const babel = require('@babel/core');
const traverse = require('@babel/traverse').default;

class ReactNativeStringExtractor {
  constructor(projectPath, i18nLibrary = 'react-i18next') {
    this.projectPath = projectPath;
    this.library = i18nLibrary;
    this.patterns = this.getLibraryPatterns();
  }

  getLibraryPatterns() {
    const patterns = {
      'react-i18next': {
        hookCall: 't',
        componentCall: 'Trans',
        importSource: 'react-i18next'
      },
      'react-native-localize': {
        hookCall: 'translate',
        componentCall: 'Text',
        importSource: 'react-native-localize'
      }
    };

    return patterns[this.library] || patterns['react-i18next'];
  }

  async extractFromFile(filePath) {
    const content = fs.readFileSync(filePath, 'utf-8');
    const strings = new Set();

    try {
      const ast = babel.parseSync(content, {
        filename: filePath,
        presets: ['@babel/preset-react', '@babel/preset-typescript'],
        plugins: ['@babel/plugin-syntax-jsx']
      });

      traverse(ast, {
        CallExpression: (path) => {
          // Handle t('key') patterns
          if (path.node.callee.name === this.patterns.hookCall) {
            const arg = path.node.arguments[0];
            if (arg && arg.type === 'StringLiteral') {
              strings.add(arg.value);
            }
          }
        },

        JSXElement: (path) => {
          // Handle <Trans i18nKey="key" /> patterns
          if (path.node.openingElement.name.name === this.patterns.componentCall) {
            const i18nKeyAttr = path.node.openingElement.attributes.find(
              attr => attr.name && attr.name.name === 'i18nKey'
            );

            if (i18nKeyAttr && i18nKeyAttr.value.type === 'StringLiteral') {
              strings.add(i18nKeyAttr.value.value);
            }
          }
        }
      });

    } catch (error) {
      console.warn(`Failed to parse ${filePath}: ${error.message}`);
    }

    return Array.from(strings);
  }
}

GitHub Actions for React Native

name: React Native Localization
on:
  push:
    paths: ['src/**/*.tsx', 'src/**/*.ts', 'src/**/*.jsx', 'src/**/*.js']

jobs:
  extract-and-sync:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

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

    - name: Install dependencies
      run: npm ci

    - name: Extract translatable strings
      run: |
        node scripts/rn-string-extractor.js --output locales/en/common.json

        # Check for new strings
        git add locales/en/common.json
        if ! git diff --cached --exit-code; then
          echo "new-strings=true" >> $GITHUB_OUTPUT
          echo "New translatable strings detected"
        fi
      id: extract

    - name: Validate JSON structure
      if: steps.extract.outputs.new-strings == 'true'
      run: |
        # Ensure valid JSON and key structure
        node -e "
          const fs = require('fs');
          const json = JSON.parse(fs.readFileSync('locales/en/common.json'));

          // Check for nested keys that might break iOS/Android
          const flatKeys = Object.keys(json).filter(k => !k.includes('.'));
          if (flatKeys.length !== Object.keys(json).length) {
            console.error('Nested keys detected - will cause issues in native platforms');
            process.exit(1);
          }
        "

    - name: Generate platform-specific files
      if: steps.extract.outputs.new-strings == 'true'
      run: |
        # Convert to iOS strings format
        node scripts/json-to-ios.js locales/en/common.json ios/Localizable.strings

        # Convert to Android XML format
        node scripts/json-to-android.js locales/en/common.json android/app/src/main/res/values/strings.xml

    - name: Create PR with new strings
      if: steps.extract.outputs.new-strings == 'true'
      uses: peter-evans/create-pull-request@v5
      with:
        title: 'Auto-extracted localization strings'
        body: |
          Automatically detected new translatable strings.

          Platform files updated:
          - locales/en/common.json
          - ios/Localizable.strings  
          - android/app/src/main/res/values/strings.xml
        branch: auto-localization-update

Safe String Deletion and Change Management

The trickiest part of automated string management isn't extraction—it's handling deletions and changes without breaking existing translations.

Change Detection Strategy

# scripts/string_change_detector.py
import json
import difflib
from typing import Dict, List, Tuple

class StringChangeDetector:
    def __init__(self, old_strings: Dict[str, str], new_strings: Dict[str, str]):
        self.old = old_strings
        self.new = new_strings

    def detect_changes(self) -> Dict[str, List[str]]:
        changes = {
            'added': [],
            'deleted': [],
            'modified': [],
            'suspected_renames': []
        }

        old_keys = set(self.old.keys())
        new_keys = set(self.new.keys())

        changes['added'] = list(new_keys - old_keys)
        changes['deleted'] = list(old_keys - new_keys)

        # Check for modifications
        common_keys = old_keys & new_keys
        for key in common_keys:
            if self.old[key] != self.new[key]:
                changes['modified'].append({
                    'key': key,
                    'old_value': self.old[key],
                    'new_value': self.new[key],
                    'similarity': difflib.SequenceMatcher(None, self.old[key], self.new[key]).ratio()
                })

        # Detect potential renames (deleted key with similar new key)
        changes['suspected_renames'] = self._detect_renames(
            changes['deleted'], changes['added']
        )

        return changes

    def _detect_renames(self, deleted: List[str], added: List[str]) -> List[Tuple[str, str, float]]:
        renames = []

        for deleted_key in deleted:
            deleted_value = self.old[deleted_key]

            for added_key in added:
                added_value = self.new[added_key]

                # Check value similarity
                value_similarity = difflib.SequenceMatcher(None, deleted_value, added_value).ratio()
                key_similarity = difflib.SequenceMatcher(None, deleted_key, added_key).ratio()

                # High value similarity suggests rename
                if value_similarity > 0.8 and key_similarity > 0.3:
                    renames.append((deleted_key, added_key, value_similarity))

        return renames

Safe Deletion Pipeline

# GitHub Actions workflow snippet
    - name: Handle string changes safely
      run: |
        python3 scripts/string_change_detector.py \
          --old-file "previous_strings.json" \
          --new-file "current_strings.json" \
          --output "changes.json"

        # Never auto-delete strings - always require manual review
        if python3 -c "
        import json
        changes = json.load(open('changes.json'))
        exit(1 if changes['deleted'] or changes['modified'] else 0)
        "; then
          echo "String deletions or modifications detected - manual review required"

          # Create detailed change report
          python3 scripts/generate_change_report.py changes.json > string_changes.md

          # Block CI if critical changes without approval
          exit 1
        fi

Translation Management System Integration

Automation only works if your CI pipeline can seamlessly sync with your translation management system (TMS). This is where many teams get stuck—the integration becomes a maintenance nightmare.

Generic TMS Sync Pattern

# scripts/tms_sync.py
import requests
import json
from typing import Dict, Any
from abc import ABC, abstractmethod

class TMSAdapter(ABC):
    @abstractmethod
    def upload_strings(self, strings: Dict[str, str], project_id: str) -> bool:
        pass

    @abstractmethod
    def download_translations(self, project_id: str, locale: str) -> Dict[str, str]:
        pass

    @abstractmethod
    def get_translation_status(self, project_id: str) -> Dict[str, float]:
        pass

class CrowdinAdapter(TMSAdapter):
    def __init__(self, api_token: str, organization: str):
        self.api_token = api_token
        self.organization = organization
        self.base_url = f"https://{organization}.api.crowdin.com/api/v2"

    def upload_strings(self, strings: Dict[str, str], project_id: str) -> bool:
        headers = {'Authorization': f'Bearer {self.api_token}'}

        # Convert to Crowdin's expected format
        payload = {
            'storageId': self._upload_file_content(strings),
            'name': 'mobile_strings.json',
            'updateOption': 'update_as_unapproved'
        }

        response = requests.post(
            f"{self.base_url}/projects/{project_id}/files",
            headers=headers,
            json=payload
        )

        return response.status_code == 201

    def _upload_file_content(self, strings: Dict[str, str]) -> str:
        # Upload file content to Crowdin storage first
        # Returns storage ID for use in file creation
        pass

class TMSSyncOrchestrator:
    def __init__(self, adapter: TMSAdapter, config: Dict[str, Any]):
        self.adapter = adapter
        self.config = config

    def sync_project(self, platform: str, strings_file: str) -> bool:
        """Main sync orchestration"""
        try:
            # Load current strings
            with open(strings_file, 'r') as f:
                if strings_file.endswith('.json'):
                    strings = json.load(f)
                else:
                    strings = self._parse_platform_strings(strings_file, platform)

            # Upload to TMS
            project_id = self.config[f'{platform}_project_id']
            success = self.adapter.upload_strings(strings, project_id)

            if success:
                print(f"Successfully synced {len(strings)} strings for {platform}")

                # Trigger webhook for translation team notification
                self._notify_translators(platform, len(strings))

            return success

        except Exception as e:
            print(f"TMS sync failed: {e}")
            return False

Preventing Translation Drift

The biggest risk with automated string extraction is translation drift—when source strings change but translations don't get updated. This breaks the user experience in subtle ways that are hard to catch.

Translation Validation Pipeline

# scripts/translation_validator.py
class TranslationDriftDetector:
    def __init__(self, base_locale: str = 'en'):
        self.base_locale = base_locale

    def detect_drift(self, translations: Dict[str, Dict[str, str]]) -> Dict[str, Any]:
        """Detect potential translation drift issues"""
        base_strings = translations.get(self.base_locale, {})
        drift_report = {
            'missing_translations': {},
            'placeholder_mismatches': {},
            'length_outliers': {},
            'suspicious_changes': {}
        }

        for locale, strings in translations.items():
            if locale == self.base_locale:
                continue

            # Missing translations
            missing = set(base_strings.keys()) - set(strings.keys())
            if missing:
                drift_report['missing_translations'][locale] = list(missing)

            # Placeholder validation
            for key in base_strings:
                if key in strings:
                    base_placeholders = self._extract_placeholders(base_strings[key])
                    locale_placeholders = self._extract_placeholders(strings[key])

                    if base_placeholders != locale_placeholders:
                        drift_report['placeholder_mismatches'][f"{locale}.{key}"] = {
                            'base': base_placeholders,
                            'translation': locale_placeholders
                        }

            # Length analysis (detect truncated translations)
            for key in base_strings:
                if key in strings:
                    base_len = len(base_strings[key])
                    translation_len = len(strings[key])
                    ratio = translation_len / base_len if base_len > 0 else 0

                    # Flag suspiciously short or long translations
                    if ratio < 0.3 or ratio > 3.0:
                        drift_report['length_outliers'][f"{locale}.{key}"] = {
                            'base_length': base_len,
                            'translation_length': translation_len,
                            'ratio': ratio
                        }

        return drift_report

CI Integration for Drift Prevention

    - name: Validate translations
      run: |
        # Download latest translations from TMS
        python3 scripts/download_translations.py --all-locales

        # Check for drift
        python3 scripts/translation_validator.py \
          --translations-dir locales/ \
          --output validation_report.json

        # Fail CI if critical issues found
        if python3 -c "
        import json
        report = json.load(open('validation_report.json'))
        critical = any([
          report['placeholder_mismatches'],
          len(report['missing_translations'].get('es', [])) > 5,  # More than 5 missing Spanish strings
          len(report['missing_translations'].get('fr', [])) > 5   # More than 5 missing French strings
        ])
        exit(1 if critical else 0)
        "; then
          echo "Critical translation issues detected"
          cat validation_report.json
          exit 1
        fi

Takeaways

Automated string extraction transforms localization from a development bottleneck into a background process. The key is building pipelines that handle the complexity of mobile platforms while preventing the drift and quality issues that kill user experience.

Focus on these priorities:

Start Simple: Basic extraction pipelines provide immediate value. You can enhance them iteratively without disrupting development.

Platform-Specific Patterns: Each platform (iOS, Android, React Native) needs different detection strategies. Don't try to force a one-size-fits-all approach.

Change Management: Automate additions, but require manual review for deletions and modifications. This prevents accidentally breaking existing translations.

TMS Integration: Your pipeline is only as good as its integration with your translation workflow. Build adapters that can switch between different translation management systems.

Validation Gates: Catch translation drift, placeholder mismatches, and missing translations before they reach users.

The automation patterns covered here prevent the velocity issues that teams face as they scale. When string extraction becomes automatic, developers can focus on features instead of localization maintenance, and translators always work with current, complete source material.

This automation foundation becomes even more critical when dealing with the platform-specific quirks covered in our React Native vs iOS vs Android localization post. Manual processes can't handle the complexity—automation can.

Ready to localize your app?

Get started free — no credit card required.

Start Translating →