Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .buildkite/commands/sync-translations.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/bash -eu

# Downloads the latest translations from GlotPress, AI-backfills any strings
# still untranslated, and opens/updates a single PR to trunk. Runs daily.
#
# Requires ANTHROPIC_API_KEY in the CI environment for the AI backfill.
# Part of the "Faster Releases" RFC, Phase 2 (continuous translations).

echo '--- :robot_face: Use bot for Git operations'
source use-bot-for-git

"$(dirname "${BASH_SOURCE[0]}")/shared-set-up.sh"

echo '--- :closed_lock_with_key: Access secrets'
bundle exec fastlane run configure_apply

echo '--- :globe_with_meridians: Sync translations'
bundle exec fastlane sync_translations
19 changes: 19 additions & 0 deletions .buildkite/commands/upload-strings-for-translation.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/bin/bash -eu

# Regenerates the English `Localizable.strings` from code and pushes it to trunk
# so GlotPress imports new strings promptly. Runs on each trunk merge.
#
# Part of the "Faster Releases" RFC, Phase 2 (continuous translations).

echo '--- :robot_face: Use bot for Git operations'
source use-bot-for-git

"$(dirname "${BASH_SOURCE[0]}")/shared-set-up.sh"

echo '--- :closed_lock_with_key: Access secrets'
bundle exec fastlane run configure_apply

echo '--- :globe_with_meridians: Regenerate and upload strings for translation'
# DRY_RUN=true regenerates and runs the guardrail without committing or pushing β€”
# used to exercise this flow from a PR. Defaults to a real run.
bundle exec fastlane upload_strings_for_translation dry_run:"${DRY_RUN:-false}"
32 changes: 32 additions & 0 deletions .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,38 @@ steps:
command: .buildkite/commands/lint-localized-strings-format.sh
plugins: [$CI_TOOLKIT_PLUGIN]

#################
# Continuous translations: regenerate + upload English strings on each trunk merge
#
# Part of the "Faster Releases" RFC, Phase 2. Trunk-only by necessity β€” it
# pushes the regenerated strings to trunk, which would be wrong from a PR/branch
# build. The daily download half runs from `.buildkite/translation-sync.yml`.
#################
- group: "🌐 Localization"
key: localization_group
steps:
- label: "🌐 Upload strings for translation"
command: ".buildkite/commands/upload-strings-for-translation.sh"
if: "build.branch == 'trunk'"
agents:
queue: mac
plugins: [$CI_TOOLKIT_PLUGIN]

# Dry run on PRs: regenerate + run the placeholder guardrail without
# committing or pushing, so the flow can be exercised (and guards the PR)
# before it goes live on trunk.
- label: "🌐 Upload strings (dry run)"
command: ".buildkite/commands/upload-strings-for-translation.sh"
if: "build.pull_request.id != null || build.pull_request.draft"
env:
DRY_RUN: "true"
agents:
queue: mac
plugins: [$CI_TOOLKIT_PLUGIN]
notify:
- github_commit_status:
context: "Strings Upload (dry run)"

#################
# Claude Build Analysis - dynamically uploaded so Build result conditions evaluate at runtime after the wait
#################
Expand Down
16 changes: 16 additions & 0 deletions .buildkite/translation-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json
---

# Daily translation sync β€” download the latest GlotPress translations, AI-backfill
# the gaps, and open/update a PR. Triggered on a schedule (configured in Buildkite).
# Part of the "Faster Releases" RFC, Phase 2 (continuous translations).

agents:
queue: mac
env:
IMAGE_ID: $IMAGE_ID

steps:
- label: "🌐 Sync translations"
command: ".buildkite/commands/sync-translations.sh"
plugins: [$CI_TOOLKIT_PLUGIN]
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

source 'https://rubygems.org'

# Used to AI-translate still-untranslated strings during the daily translation sync.
gem 'anthropic'
gem 'danger-dangermattic', '~> 1.3'
gem 'dotenv'
# 2.223.1 includes a fix for an ASC-interfacing issue
Expand Down
10 changes: 9 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ GEM
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
anthropic (1.49.0)
cgi
connection_pool
standardwebhooks
artifactory (3.0.17)
ast (2.4.3)
atomos (0.1.3)
Expand Down Expand Up @@ -33,6 +37,7 @@ GEM
bigdecimal (4.1.2)
buildkit (1.6.1)
sawyer (>= 0.6)
cgi (0.5.1)
chroma (0.2.0)
claide (1.1.0)
claide-plugins (0.9.2)
Expand All @@ -43,6 +48,7 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
connection_pool (3.0.2)
cork (0.3.0)
colored2 (~> 3.1)
csv (3.3.5)
Expand Down Expand Up @@ -348,6 +354,7 @@ GEM
CFPropertyList
naturally
singleton (0.3.0)
standardwebhooks (1.0.1)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
Expand Down Expand Up @@ -376,6 +383,7 @@ PLATFORMS
ruby

DEPENDENCIES
anthropic
danger-dangermattic (~> 1.3)
dotenv
fastlane (~> 2.236)
Expand All @@ -388,4 +396,4 @@ DEPENDENCIES
rubocop-rake (~> 0.7)

BUNDLED WITH
2.4.22
2.6.8
3 changes: 3 additions & 0 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ fastlane_require 'dotenv'
fastlane_require 'open-uri'
fastlane_require 'git'

require_relative 'helpers/string_placeholders'
require_relative 'helpers/ai_translator'

UI.user_error!('Please run fastlane via `bundle exec`') unless FastlaneCore::Helper.bundler?

########################################################################
Expand Down
102 changes: 102 additions & 0 deletions fastlane/helpers/ai_translator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

require 'json'

# Translates UI strings with Claude. Used by the daily translation sync to
# backfill locales that GlotPress hasn't fully translated yet, so the app never
# ships an untranslated string. Human translations from GlotPress overwrite
# these on the next sync; the AI output is never pushed back to GlotPress.
#
# See the "Faster Releases" RFC, Phase 2 (continuous translations).
module AITranslator
# Matches the Claude model already used elsewhere in CI (`.buildkite/claude-analysis.yml`).
MODEL = :'claude-sonnet-4-6'
# Keep batches small so each request's JSON response stays well under the
# non-streaming token ceiling and a single failure costs little to retry.
BATCH_SIZE = 40
MAX_TOKENS = 8192

module_function

# Translates a set of strings, dropping any result whose placeholders don't
# match the source (we never ship a translation that would break a `%@`).
#
# @param strings [Hash] `{ key => english_value }` to translate.
# @param language_code [String] the `.lproj` locale code, e.g. `pt-BR`.
# @param language_name [String] a human language name for the prompt, e.g. `Brazilian Portuguese`.
# @return [Hash] `{ key => translation }` for entries that passed validation.
def translate(strings:, language_code:, language_name:)
return {} if strings.empty?

# Required late so loading the Fastfile doesn't depend on the gem being
# installed β€” only this lane needs it.
require 'anthropic'
client = Anthropic::Client.new # reads ANTHROPIC_API_KEY from the environment

result = {}
strings.each_slice(BATCH_SIZE) do |batch|
batch_hash = batch.to_h
raw = translate_batch(client: client, strings: batch_hash, language_code: language_code, language_name: language_name)
result.merge!(validated_translations(raw, batch_hash, language_code))
end
result
end

# Keeps only the translations whose placeholders match the English source.
def validated_translations(translations, english_by_key, language_code)
translations.each_with_object({}) do |(key, translation), kept|
english = english_by_key[key]
next if english.nil? || translation.to_s.empty?

if StringPlaceholders.compatible?(english, translation)
kept[key] = translation
else
UI.message("Dropping #{language_code} translation for '#{key}' β€” placeholders changed")
end
end
end

def translate_batch(client:, strings:, language_code:, language_name:)
message = client.messages.create(
model: MODEL,
max_tokens: MAX_TOKENS,
messages: [{ role: 'user', content: prompt_for(strings: strings, language_code: language_code, language_name: language_name) }]
)

text = message.content.filter_map { |block| block.text if block.type == :text }.join
parse_json_object(text)
rescue StandardError => e
# A best-effort backfill must never crash the daily job. Skip this batch
# (those strings stay untranslated for now) and move on.
UI.error("Claude translation request failed for #{language_code}: #{e.message}")
{}
end

def prompt_for(strings:, language_code:, language_name:)
<<~PROMPT
Translate these iOS app UI strings from English to #{language_name} (locale code `#{language_code}`).

Rules:
- Preserve EVERY format specifier exactly: `%@`, `%1$@`, `%2$d`, `%%`, etc. Keep the same count, the same order, and the same positional indices (the `$` numbers).
- Preserve leading and trailing whitespace and the surrounding punctuation style.
- Keep translations concise and natural for a mobile UI.
- Return ONLY a JSON object mapping each original key to its translation β€” no prose, no markdown, no code fences.

Strings to translate (JSON object, key β†’ English source):
#{JSON.pretty_generate(strings)}
PROMPT
end

# Extracts the JSON object from the model's response, tolerating any stray
# prose or code fences despite the prompt asking for raw JSON.
def parse_json_object(text)
json = text[/\{.*\}/m]
return {} if json.nil?

parsed = JSON.parse(json)
parsed.is_a?(Hash) ? parsed : {}
rescue JSON::ParserError => e
UI.error("Could not parse Claude response as JSON: #{e.message}")
{}
end
end
95 changes: 95 additions & 0 deletions fastlane/helpers/string_placeholders.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# frozen_string_literal: true

require 'json'
require 'open3'

# Compares the placeholder "shape" β€” the count, position, and argument type of
# the format specifiers β€” of localized strings.
#
# Used in two places in the "Faster Releases" RFC, Phase 2 (continuous
# translations):
# 1. The localization guardrail: an existing key's English value must not
# change its placeholders without getting a new key, or existing
# translations would silently break.
# 2. Validating AI-backfilled translations: a machine translation that drops
# or reorders a `%@` / `%1$d` must be rejected rather than shipped.
module StringPlaceholders
# printf / NSString conversion characters grouped by the argument type a
# translator must preserve. `%d` <-> `%i` is fine (same int arg); `%d` <-> `%@`
# is not (int vs object).
CONVERSION_CLASSES = {
'@' => 'object',
'd' => 'int', 'i' => 'int', 'u' => 'int', 'o' => 'int', 'x' => 'int', 'X' => 'int',
'f' => 'float', 'e' => 'float', 'E' => 'float', 'g' => 'float', 'G' => 'float', 'a' => 'float', 'A' => 'float',
'c' => 'char', 'C' => 'char',
's' => 'cstring', 'S' => 'cstring',
'p' => 'pointer'
}.freeze

# A single format specifier: optional positional arg (`1$`), flags, width,
# precision, length modifier, then the conversion character. `%%` (literal
# percent) is matched too, so it can be explicitly skipped.
SPECIFIER = /%(?<position>\d+\$)?[-+ 0#]*(?:\d+|\*)?(?:\.(?:\d+|\*))?(?:hh|h|ll|l|q|L|z|t|j)?(?<conversion>[@diouxXeEfgGaAcCsSpn%])/

module_function

# Parses a `.strings` file into a `{ key => value }` hash using `plutil`
# (`.strings` is an old-style property list, and `plutil` is the most reliable
# parser for it β€” handling escapes, comments, and Unicode).
def parse_file(path)
raise "File not found: #{path}" unless File.exist?(path)

json, stderr, status = Open3.capture3('plutil', '-convert', 'json', '-o', '-', path)
raise "Failed to parse #{path} with plutil:\n#{stderr}" unless status.success?

JSON.parse(json)
end

# A canonical signature of the placeholders in a string value, or '' if there
# are none. Two values with the same signature are placeholder-compatible.
def signature(value)
# Key each specifier by its position β€” explicit for `%1$@`, otherwise its
# appearance order β€” then sort by it, so reordering equivalent positional args
# (`%1$@ %2$@` vs `%2$@ %1$@`) yields the same signature while a changed count
# or argument type does not.
specifiers(value)
.each_with_index
.map { |spec, index| [spec[:position] || (index + 1), spec[:klass]] }
.sort_by(&:first)
.map { |position, klass| "#{position}:#{klass}" }
.join(',')
end

# The format specifiers in a value as `[{ position:, klass: }]`, excluding the
# literal `%%`. `position` is nil for non-positional specifiers.
def specifiers(value)
found = []
value.to_s.scan(SPECIFIER) do
match = Regexp.last_match
conversion = match[:conversion]
next if conversion == '%' # literal percent, not a placeholder

found << { position: match[:position]&.delete('$')&.to_i, klass: CONVERSION_CLASSES.fetch(conversion, conversion) }
end
found
end

# Whether two string values share the same placeholder shape.
def compatible?(old_value, new_value)
signature(old_value) == signature(new_value)
end

# Given two `{ key => value }` hashes, returns the keys present in BOTH whose
# placeholder signature changed, as an array of detail hashes. New and removed
# keys are ignored on purpose β€” copy that needs a fresh translation is expected
# to land under a new key (which shows up as remove-old + add-new).
def incompatible_changes(old_strings, new_strings)
(old_strings.keys & new_strings.keys).sort.filter_map do |key|
old_signature = signature(old_strings[key])
new_signature = signature(new_strings[key])
next if old_signature == new_signature

{ key: key, old: old_strings[key], new: new_strings[key], old_signature: old_signature, new_signature: new_signature }
end
end
end
Loading