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.
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 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'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.