diff --git a/.github/actions/check-locale-changes/action.yml b/.github/actions/check-locale-changes/action.yml index f343d3e9..96302e7b 100644 --- a/.github/actions/check-locale-changes/action.yml +++ b/.github/actions/check-locale-changes/action.yml @@ -11,73 +11,17 @@ inputs: outputs: matrix-include: description: 'JSON array of locales to deploy with their config' - value: ${{ steps.generate-matrix.outputs.include }} + value: ${{ steps.check-locales.outputs.matrix-include }} has-changes: description: 'Whether any enabled locales have changes' - value: ${{ steps.generate-matrix.outputs.has-changes }} + value: ${{ steps.check-locales.outputs.has-changes }} runs: using: 'composite' steps: - - name: Generate dynamic files config (for auto/docs-pr triggers) - if: inputs.trigger-type == 'auto' || inputs.trigger-type == 'docs-pr' - id: generate-files-config + - name: Check locale changes and generate matrix + id: check-locales shell: bash run: | - # Use the dedicated script to generate files config - files_yaml=$(.github/scripts/generate-files-config.sh) - - echo "Generated files_yaml:" - echo "$files_yaml" - - # Save to output for next step - { - echo "files_yaml<> $GITHUB_OUTPUT - - - name: Get changed files (for auto/docs-pr triggers) - if: inputs.trigger-type == 'auto' || inputs.trigger-type == 'docs-pr' - id: changes - uses: tj-actions/changed-files@v41 - with: - files_yaml: ${{ steps.generate-files-config.outputs.files_yaml }} - - - name: Generate deployment matrix - id: generate-matrix - shell: bash - run: | - # Prepare arguments for the matrix generation script - trigger_type="${{ inputs.trigger-type }}" - manual_locales="${{ inputs.manual-locales }}" - - if [ "$trigger_type" == "manual" ]; then - # For manual trigger, we don't need changes JSON - output=$(.github/scripts/generate-locale-matrix.sh "$trigger_type" "$manual_locales") - else - # For auto/docs-pr triggers, create a minimal JSON with only the boolean change indicators - changes_json=$(cat << 'EOF' - { - "core_any_changed": "${{ steps.changes.outputs.core_any_changed }}", - "ar_any_changed": "${{ steps.changes.outputs.ar_any_changed }}", - "de_any_changed": "${{ steps.changes.outputs.de_any_changed }}", - "en_any_changed": "${{ steps.changes.outputs.en_any_changed }}", - "es_any_changed": "${{ steps.changes.outputs.es_any_changed }}", - "fr_any_changed": "${{ steps.changes.outputs.fr_any_changed }}", - "ja_any_changed": "${{ steps.changes.outputs.ja_any_changed }}", - "ru_any_changed": "${{ steps.changes.outputs.ru_any_changed }}", - "zh-hans_any_changed": "${{ steps.changes.outputs.zh-hans_any_changed }}", - "zh-hant_any_changed": "${{ steps.changes.outputs.zh-hant_any_changed }}" - } - EOF - ) - - temp_file=$(mktemp) - echo "$changes_json" > "$temp_file" - output=$(.github/scripts/generate-locale-matrix.sh "$trigger_type" "" "$temp_file") - rm -f "$temp_file" - fi - - # Parse the output (the script outputs two lines: include= and has-changes=) - echo "$output" >> $GITHUB_OUTPUT + # Use the unified Node.js script to handle everything + node .github/scripts/check-locale-changes.js "${{ inputs.trigger-type }}" "${{ inputs.manual-locales }}" "true" diff --git a/.github/scripts/check-locale-changes.js b/.github/scripts/check-locale-changes.js new file mode 100755 index 00000000..3b1d0f6a --- /dev/null +++ b/.github/scripts/check-locale-changes.js @@ -0,0 +1,468 @@ +#!/usr/bin/env node + +/** + * Unified script for GitHub Actions locale change detection and matrix generation + * Usage: node check-locale-changes.js [manual-locales] [github-outputs] + * + * This script combines: + * 1. Files config generation for change detection + * 2. Change detection logic + * 3. Deployment matrix generation + * 4. GitHub Actions output formatting + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { execSync } = require('node:child_process'); + +// Default values +const SCRIPT_DIR = __dirname; +const ROOT_DIR = path.resolve(SCRIPT_DIR, '../..'); +const LOCALE_CONFIG_FILE = path.join(ROOT_DIR, '.github/locales-config.json'); + +/** + * Log messages with timestamp + */ +function log(message, level = 'info') { + const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19); + const prefix = level === 'error' ? '❌' : level === 'warn' ? '⚠️' : 'ℹ️'; + console.error(`${prefix} [${timestamp}] ${message}`); +} + +/** + * Print usage information + */ +function usage() { + console.log( + `Usage: ${process.argv[1]} [manual-locales] [github-outputs]`, + ); + console.log(''); + console.log('Arguments:'); + console.log( + " trigger-type Type of trigger: 'manual', 'auto', or 'docs-pr'", + ); + console.log( + ' manual-locales Comma-separated list of locales (optional, for manual trigger)', + ); + console.log( + ' github-outputs Set to "true" to output GitHub Actions format (optional)', + ); + console.log(''); + console.log('Examples:'); + console.log(` ${process.argv[1]} manual`); + console.log(` ${process.argv[1]} manual 'en,zh-hans'`); + console.log(` ${process.argv[1]} auto true`); + process.exit(1); +} + +/** + * Load and validate locale configuration + */ +function loadLocaleConfig() { + if (!fs.existsSync(LOCALE_CONFIG_FILE)) { + throw new Error(`Locale config file not found: ${LOCALE_CONFIG_FILE}`); + } + + try { + const content = fs.readFileSync(LOCALE_CONFIG_FILE, 'utf8'); + return JSON.parse(content); + } catch (error) { + throw new Error(`Failed to read or parse locale config: ${error.message}`); + } +} + +/** + * Generate files configuration for change detection + */ +function generateFilesConfig(localeConfig) { + let filesYaml = `core: + - 'apps/docs/**' + - 'packages/**' + - '!apps/docs/content/**' + - '!apps/docs/messages/**'`; + + // Add each locale from config dynamically + const locales = Object.keys(localeConfig); + for (const locale of locales) { + filesYaml += ` +${locale}: + - 'apps/docs/content/${locale}/**' + - 'apps/docs/messages/${locale}.json'`; + } + + return filesYaml; +} + +/** + * Get changed files using git (fallback if tj-actions/changed-files is not available) + */ +function getChangedFiles(localeConfig) { + try { + // Get the list of changed files from git + const changedFiles = execSync('git diff --name-only HEAD~1..HEAD', { + encoding: 'utf8', + cwd: ROOT_DIR, + }) + .split('\n') + .filter((file) => file.trim()); + + log(`Found ${changedFiles.length} changed files`); + + // Initialize changes object + const changes = { + core_any_changed: false, + }; + + // Initialize locale changes + for (const locale of Object.keys(localeConfig)) { + const localeKey = locale.replace('-', '_'); + changes[`${localeKey}_any_changed`] = false; + } + + // Check each changed file + for (const file of changedFiles) { + // Check core changes + if ( + file.startsWith('apps/docs/') && + !file.startsWith('apps/docs/content/') && + !file.startsWith('apps/docs/messages/') + ) { + changes.core_any_changed = true; + } else if (file.startsWith('packages/')) { + changes.core_any_changed = true; + } + + // Check locale-specific changes + for (const locale of Object.keys(localeConfig)) { + const localeKey = locale.replace('-', '_'); + if ( + file.startsWith(`apps/docs/content/${locale}/`) || + file === `apps/docs/messages/${locale}.json` + ) { + changes[`${localeKey}_any_changed`] = true; + } + } + } + + return changes; + } catch (error) { + log( + `Warning: Could not get changed files from git: ${error.message}`, + 'warn', + ); + // Return empty changes if git fails + const changes = { core_any_changed: false }; + for (const locale of Object.keys(localeConfig)) { + const localeKey = locale.replace('-', '_'); + changes[`${localeKey}_any_changed`] = false; + } + return changes; + } +} + +/** + * Check if locale is enabled + */ +function isLocaleEnabled(localeConfig, locale) { + return localeConfig[locale]?.enabled === true; +} + +/** + * Get locale configuration field + */ +function getLocaleConfig(localeConfig, locale, field) { + return localeConfig[locale]?.[field] || ''; +} + +/** + * Add locale to matrix + */ +function addLocaleToMatrix( + matrix, + locale, + secretProjectId, + oramaPrivateApiKey, +) { + return [ + ...matrix, + { + locale, + secret_project_id: secretProjectId, + orama_private_api_key: oramaPrivateApiKey, + }, + ]; +} + +/** + * Process manual trigger + */ +function processManualTrigger(localeConfig, manualLocales) { + let matrixInclude = []; + let hasChanges = false; + + if (!manualLocales) { + log('No specific locales provided, deploying all enabled locales'); + + // Deploy all enabled locales + for (const locale of Object.keys(localeConfig)) { + if (isLocaleEnabled(localeConfig, locale)) { + const secretProjectId = getLocaleConfig( + localeConfig, + locale, + 'secret_project_id', + ); + const oramaPrivateApiKey = getLocaleConfig( + localeConfig, + locale, + 'orama_private_api_key', + ); + + if (secretProjectId && oramaPrivateApiKey) { + matrixInclude = addLocaleToMatrix( + matrixInclude, + locale, + secretProjectId, + oramaPrivateApiKey, + ); + hasChanges = true; + log(`✅ Added ${locale} to deployment matrix`); + } else { + log(`⚠️ Skipping ${locale} (missing configuration)`, 'warn'); + } + } else { + log(`Skipping ${locale} (not enabled)`); + } + } + } else { + log(`Manual locales specified: ${manualLocales}`); + + // Parse comma-separated locales + const locales = manualLocales + .split(',') + .map((locale) => locale.trim()) + .filter((locale) => locale); + + for (const locale of locales) { + if (isLocaleEnabled(localeConfig, locale)) { + const secretProjectId = getLocaleConfig( + localeConfig, + locale, + 'secret_project_id', + ); + const oramaPrivateApiKey = getLocaleConfig( + localeConfig, + locale, + 'orama_private_api_key', + ); + + if (secretProjectId && oramaPrivateApiKey) { + matrixInclude = addLocaleToMatrix( + matrixInclude, + locale, + secretProjectId, + oramaPrivateApiKey, + ); + hasChanges = true; + log(`✅ Added ${locale} to deployment matrix`); + } else { + log(`⚠️ Skipping ${locale} (missing configuration)`, 'warn'); + } + } else { + log( + `⚠️ Skipping ${locale} (not enabled or not found in config)`, + 'warn', + ); + } + } + } + + return { matrixInclude, hasChanges }; +} + +/** + * Process automatic/docs-pr trigger + */ +function processAutoTrigger(localeConfig, changes) { + let matrixInclude = []; + let hasChanges = false; + + // Check core changes + const coreChanged = changes.core_any_changed; + + if (coreChanged) { + log('✅ Core changes detected, will deploy all enabled locales'); + } + + // Check each locale dynamically from config + for (const locale of Object.keys(localeConfig)) { + if (isLocaleEnabled(localeConfig, locale)) { + const localeKey = locale.replace('-', '_'); + const localeChanged = changes[`${localeKey}_any_changed`]; + + if (coreChanged || localeChanged) { + const secretProjectId = getLocaleConfig( + localeConfig, + locale, + 'secret_project_id', + ); + const oramaPrivateApiKey = getLocaleConfig( + localeConfig, + locale, + 'orama_private_api_key', + ); + + if (secretProjectId && oramaPrivateApiKey) { + matrixInclude = addLocaleToMatrix( + matrixInclude, + locale, + secretProjectId, + oramaPrivateApiKey, + ); + hasChanges = true; + + if (coreChanged && localeChanged) { + log( + `✅ Added ${locale} to deployment matrix (core + locale changes)`, + ); + } else if (coreChanged) { + log(`✅ Added ${locale} to deployment matrix (core changes)`); + } else { + log(`✅ Added ${locale} to deployment matrix (locale changes)`); + } + } else { + log(`⚠️ Skipping ${locale} (missing configuration)`, 'warn'); + } + } else { + log(`Skipping ${locale} (no changes detected)`); + } + } else { + log(`Skipping ${locale} (not enabled)`); + } + } + + return { matrixInclude, hasChanges }; +} + +/** + * Output results in GitHub Actions format + */ +function outputGitHubActions(result, filesYaml = null) { + const matrixOutput = JSON.stringify(result.matrixInclude); + + // Write to GITHUB_OUTPUT if the environment variable is set + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + try { + let output = `matrix-include=${matrixOutput}\n`; + output += `has-changes=${result.hasChanges}\n`; + + if (filesYaml) { + // Use heredoc syntax for multiline YAML + output += `files_yaml< { + assert.strictEqual(isLocaleEnabled(mockLocaleConfig, 'en'), true); + assert.strictEqual(isLocaleEnabled(mockLocaleConfig, 'zh-hans'), true); + assert.strictEqual(isLocaleEnabled(mockLocaleConfig, 'fr'), false); + assert.strictEqual(isLocaleEnabled(mockLocaleConfig, 'nonexistent'), false); +}); + +test('getLocaleConfig should return correct configuration values', () => { + assert.strictEqual( + getLocaleConfig(mockLocaleConfig, 'en', 'secret_project_id'), + 'VERCEL_PROJECT_EN_ID', + ); + assert.strictEqual( + getLocaleConfig(mockLocaleConfig, 'en', 'orama_private_api_key'), + 'ORAMA_PRIVATE_API_KEY_EN', + ); + assert.strictEqual( + getLocaleConfig(mockLocaleConfig, 'nonexistent', 'secret_project_id'), + '', + ); + assert.strictEqual( + getLocaleConfig(mockLocaleConfig, 'en', 'nonexistent_field'), + '', + ); +}); + +test('generateFilesConfig should create proper YAML configuration', () => { + const filesYaml = generateFilesConfig(mockLocaleConfig); + + // Should contain core files + assert.ok(filesYaml.includes("core:\n - 'apps/docs/**'")); + assert.ok(filesYaml.includes(" - 'packages/**'")); + assert.ok(filesYaml.includes(" - '!apps/docs/content/**'")); + + // Should contain locale-specific files + assert.ok(filesYaml.includes("en:\n - 'apps/docs/content/en/**'")); + assert.ok(filesYaml.includes(" - 'apps/docs/messages/en.json'")); + assert.ok(filesYaml.includes("zh-hans:\n - 'apps/docs/content/zh-hans/**'")); + assert.ok(filesYaml.includes("fr:\n - 'apps/docs/content/fr/**'")); +}); + +test('processManualTrigger should handle empty manual locales', () => { + const result = processManualTrigger(mockLocaleConfig, ''); + + // Should include all enabled locales + assert.strictEqual(result.matrixInclude.length, 2); + assert.strictEqual(result.hasChanges, true); + + const locales = result.matrixInclude.map((item) => item.locale); + assert.ok(locales.includes('en')); + assert.ok(locales.includes('zh-hans')); + assert.ok(!locales.includes('fr')); // fr is disabled +}); + +test('processManualTrigger should handle specific manual locales', () => { + const result = processManualTrigger(mockLocaleConfig, 'en'); + + // Should include only the specified locale + assert.strictEqual(result.matrixInclude.length, 1); + assert.strictEqual(result.hasChanges, true); + assert.strictEqual(result.matrixInclude[0].locale, 'en'); +}); + +test('processManualTrigger should skip disabled locales', () => { + const result = processManualTrigger(mockLocaleConfig, 'fr'); + + // Should not include disabled locale + assert.strictEqual(result.matrixInclude.length, 0); + assert.strictEqual(result.hasChanges, false); +}); + +test('processAutoTrigger should handle core changes', () => { + const mockChanges = { + core_any_changed: true, + en_any_changed: false, + zh_hans_any_changed: false, + fr_any_changed: false, + }; + + const result = processAutoTrigger(mockLocaleConfig, mockChanges); + + // Should include all enabled locales when core changes + assert.strictEqual(result.matrixInclude.length, 2); + assert.strictEqual(result.hasChanges, true); + + const locales = result.matrixInclude.map((item) => item.locale); + assert.ok(locales.includes('en')); + assert.ok(locales.includes('zh-hans')); +}); + +test('processAutoTrigger should handle specific locale changes', () => { + const mockChanges = { + core_any_changed: false, + en_any_changed: true, + zh_hans_any_changed: false, + fr_any_changed: false, + }; + + const result = processAutoTrigger(mockLocaleConfig, mockChanges); + + // Should include only the changed locale + assert.strictEqual(result.matrixInclude.length, 1); + assert.strictEqual(result.hasChanges, true); + assert.strictEqual(result.matrixInclude[0].locale, 'en'); +}); + +test('processAutoTrigger should handle no changes', () => { + const mockChanges = { + core_any_changed: false, + en_any_changed: false, + zh_hans_any_changed: false, + fr_any_changed: false, + }; + + const result = processAutoTrigger(mockLocaleConfig, mockChanges); + + // Should include no locales when no changes + assert.strictEqual(result.matrixInclude.length, 0); + assert.strictEqual(result.hasChanges, false); +}); + +test('Matrix items should have correct structure', () => { + const result = processManualTrigger(mockLocaleConfig, 'en'); + const item = result.matrixInclude[0]; + + assert.ok(Object.prototype.hasOwnProperty.call(item, 'locale')); + assert.ok(Object.prototype.hasOwnProperty.call(item, 'secret_project_id')); + assert.ok( + Object.prototype.hasOwnProperty.call(item, 'orama_private_api_key'), + ); + assert.strictEqual(typeof item.locale, 'string'); + assert.strictEqual(typeof item.secret_project_id, 'string'); + assert.strictEqual(typeof item.orama_private_api_key, 'string'); +}); + +console.log('All tests passed! ✅'); diff --git a/.github/workflows/update-docs-ci.yml b/.github/workflows/update-docs-ci.yml index 58c8d49f..f02683a9 100644 --- a/.github/workflows/update-docs-ci.yml +++ b/.github/workflows/update-docs-ci.yml @@ -2,9 +2,7 @@ name: Update Docs CI on: pull_request: - types: - - opened - - synchronize + types: [opened, synchronize, reopened] branches: - dev @@ -31,7 +29,6 @@ jobs: uses: ./.github/workflows/test-e2e.yml with: matrix-include: ${{ needs.check-changes.outputs.matrix-include }} - shard-total: 5 secrets: inherit deploy-and-update-index: