From 6755544cee82b885507bcc25b9500ea148f898f3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 13 Aug 2026 07:29:29 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=8D=20ci:=20Harden=20Locize=20Translat?= =?UTF-8?q?ion=20Sync=20(#14784)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/locize-i18n-sync.yml | 27 +++++- scripts/validate-locize-download.mjs | 125 +++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 scripts/validate-locize-download.mjs diff --git a/.github/workflows/locize-i18n-sync.yml b/.github/workflows/locize-i18n-sync.yml index 21d911bc5b..90d2e5088d 100644 --- a/.github/workflows/locize-i18n-sync.yml +++ b/.github/workflows/locize-i18n-sync.yml @@ -3,11 +3,15 @@ name: Sync Locize Translations & Create Translation PR on: push: branches: [main] - paths-ignore: - - '**.md' + paths: + - 'client/src/locales/en/**' repository_dispatch: types: [locize/versionPublished] +concurrency: + group: locize-i18n-sync + cancel-in-progress: false + permissions: contents: read @@ -29,7 +33,8 @@ jobs: - name: Install locize CLI run: npm install -g locize-cli@12.2.0 --ignore-scripts --no-audit --no-fund - # Sync translations (Push missing keys & remove deleted ones) + # Git owns English source values. Push changed values to Locize without + # allowing a stale checkout to delete keys that still exist remotely. - name: Sync Locize with Repository if: ${{ github.event_name == 'push' }} env: @@ -37,7 +42,7 @@ jobs: LOCIZE_PROJECT_ID: ${{ secrets.LOCIZE_PROJECT_ID }} run: | cd client/src/locales - locize sync --cdn-type pro --api-key "$LOCIZE_API_KEY" --project-id "$LOCIZE_PROJECT_ID" --language en + locize sync --cdn-type pro --api-key "$LOCIZE_API_KEY" --project-id "$LOCIZE_PROJECT_ID" --language en --skip-delete true --update-values true # When triggered by repository_dispatch, skip sync step. - name: Skip sync step on non-push events @@ -46,6 +51,7 @@ jobs: create-pull-request: name: Create Translation PR on Version Published + if: ${{ github.event_name == 'repository_dispatch' }} runs-on: ubuntu-latest needs: sync-translations permissions: @@ -57,12 +63,23 @@ jobs: with: persist-credentials: false - # 2. Download translation files from locize. + # Keep a baseline so generated changes can be checked before opening a PR. + - name: Snapshot Repository Locales + run: cp -R client/src/locales "$RUNNER_TEMP/locize-locale-baseline" + + # Download the latest published translation version from Locize. - name: Download Translations from locize uses: locize/download@v2 with: project-id: ${{ secrets.LOCIZE_PROJECT_ID }} path: "client/src/locales" + version: latest + cdn-type: pro + + - name: Validate Downloaded Translations + run: node scripts/validate-locize-download.mjs \ + --base-dir "$RUNNER_TEMP/locize-locale-baseline" \ + --current-dir client/src/locales # 3. Create a Pull Request using a dedicated fine-grained PAT so this # workflow does not depend on the global GITHUB_TOKEN PR-creation setting. diff --git a/scripts/validate-locize-download.mjs b/scripts/validate-locize-download.mjs new file mode 100644 index 0000000000..f41ddd8b2e --- /dev/null +++ b/scripts/validate-locize-download.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 1) { + if (!process.argv[index].startsWith('--')) continue; + args.set(process.argv[index].slice(2), process.argv[index + 1]); + index += 1; +} + +const baseDir = args.get('base-dir'); +const currentDir = args.get('current-dir'); +if (!baseDir || !currentDir) { + console.error('Usage: validate-locize-download.mjs --base-dir --current-dir '); + process.exit(2); +} + +const errors = []; +const placeholderPattern = /\{\{[^{}]+\}\}|\{[^{}]+\}|%[-+0-9.#]*[a-zA-Z]/g; + +async function jsonFiles(directory, relative = '') { + const entries = await readdir(path.join(directory, relative), { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryRelative = path.join(relative, entry.name); + if (entry.isDirectory()) files.push(...(await jsonFiles(directory, entryRelative))); + else if (entry.isFile() && entry.name.endsWith('.json')) files.push(entryRelative); + } + return files; +} + +async function loadJson(filePath, label) { + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch (error) { + errors.push(`${label}: invalid JSON (${error.message})`); + return null; + } +} + +function flatten(value, prefix = '', output = new Map()) { + if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + flatten(child, prefix ? `${prefix}.${key}` : key, output); + } + } else if (prefix) { + output.set(prefix, value); + } + return output; +} + +function placeholders(value) { + return (value.match(placeholderPattern) ?? []).sort().join('\u0000'); +} + +function compareFile(relative, base, current) { + const baseValues = flatten(base); + const currentValues = flatten(current); + for (const [key, baseValue] of baseValues) { + if (!currentValues.has(key)) { + errors.push(`${relative}: deleted key ${key}`); + continue; + } + const currentValue = currentValues.get(key); + if (typeof baseValue !== 'string' || typeof currentValue !== 'string') continue; + if (placeholders(baseValue) !== placeholders(currentValue)) { + errors.push(`${relative}: changed placeholders for ${key}`); + } + if (currentValue !== baseValue && currentValue !== currentValue.trim()) { + errors.push(`${relative}: introduced leading/trailing whitespace for ${key}`); + } + } +} + +const [baseFiles, currentFiles] = await Promise.all([jsonFiles(baseDir), jsonFiles(currentDir)]); +const baseSet = new Set(baseFiles); +const currentSet = new Set(currentFiles); +const baselineValuesByFile = new Map(); +for (const relative of baseFiles) { + if (!currentSet.has(relative)) { + errors.push(`deleted file ${relative}`); + continue; + } + const [base, current] = await Promise.all([ + loadJson(path.join(baseDir, relative), `baseline/${relative}`), + loadJson(path.join(currentDir, relative), `download/${relative}`), + ]); + if (base !== null && current !== null) { + baselineValuesByFile.set(relative, flatten(base)); + compareFile(relative, base, current); + } +} + +const englishRelative = path.join('en', 'translation.json'); +if (currentSet.has(englishRelative)) { + const english = await loadJson(path.join(currentDir, englishRelative), `download/${englishRelative}`); + if (english !== null) { + const englishValues = flatten(english); + for (const relative of currentFiles) { + if (relative === englishRelative) continue; + const locale = await loadJson(path.join(currentDir, relative), `download/${relative}`); + if (locale === null) continue; + for (const [key, value] of flatten(locale)) { + const source = englishValues.get(key); + const baseline = baselineValuesByFile.get(relative)?.get(key); + const changedSinceBaseline = baseline === undefined || baseline !== value; + if (changedSinceBaseline && typeof source === 'string' && typeof value === 'string' && placeholders(source) !== placeholders(value)) { + errors.push(`${relative}: placeholder mismatch with English for ${key}`); + } + } + } + } +} else { + errors.push(`missing required source file ${englishRelative}`); +} + +if (errors.length > 0) { + console.error(`Locize translation validation failed with ${errors.length} issue(s):`); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log(`Locize translation validation passed (${baseSet.size} baseline JSON files checked).`);