diff --git a/.github/workflows/seed-all-locales.yml b/.github/workflows/seed-all-locales.yml new file mode 100644 index 00000000000..93ce07c08c4 --- /dev/null +++ b/.github/workflows/seed-all-locales.yml @@ -0,0 +1,102 @@ +name: Test seeding in all locales + +on: + schedule: + - cron: '0 2 * * 0' # Weekly on Sunday at 2 AM UTC + workflow_dispatch: + inputs: + ref: + description: 'Git ref to test (branch, tag, or SHA). Defaults to latest release branch.' + required: false + type: string + +permissions: + contents: read + +jobs: + prepare: + if: github.repository == 'opf/openproject' + name: Prepare + runs-on: ubuntu-latest + outputs: + locales: ${{ steps.list.outputs.locales }} + ref: ${{ steps.use_input_or_find_latest_release.outputs.ref }} + steps: + - name: Determine git ref to test seeding in + id: use_input_or_find_latest_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_REF: ${{ inputs.ref }} + run: | + if [ -n "$INPUT_REF" ]; then + echo "ref=$INPUT_REF" >> "$GITHUB_OUTPUT" + else + BRANCH=$(gh api repos/opf/openproject/branches --paginate --jq '.[].name' | grep '^release/' | sort --version-sort | tail -1) + if [ -z "$BRANCH" ]; then + echo "Error: no release branch found" + exit 1 + fi + echo "Found latest release branch: $BRANCH" + echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ steps.use_input_or_find_latest_release.outputs.ref }} + + - name: List available locales + id: list + run: | + locales=$(ruby script/i18n/test_seed_all_locales --list) + echo "locales=$locales" >> "$GITHUB_OUTPUT" + + seed: + needs: prepare + if: github.repository == 'opf/openproject' + name: Seed ${{ matrix.locale }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + locale: ${{ fromJson(needs.prepare.outputs.locales) }} + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/openproject_seed_test + RAILS_ENV: development + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.ref }} + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libpq-dev + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Configure database + run: | + cat > config/database.yml <<'EOF' + development: + url: <%= ENV["DATABASE_URL"] %> + EOF + + - name: Seed locale ${{ matrix.locale }} + run: ruby script/i18n/test_seed_all_locales ${{ matrix.locale }} diff --git a/script/i18n/test_seed_all_locales b/script/i18n/test_seed_all_locales new file mode 100755 index 00000000000..36d01e83104 --- /dev/null +++ b/script/i18n/test_seed_all_locales @@ -0,0 +1,185 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Tests that seeding works in all available locales. +# +# Usage: +# script/i18n/test_seed_all_locales # Seed all locales sequentially +# script/i18n/test_seed_all_locales --list # Output available locales as JSON +# script/i18n/test_seed_all_locales zh-CN # Seed a single locale + +require "pathname" +require "json" + +class SeedAllLocales + class << self + def call(args) + case args.first + when "-h", "--help" + print_usage + when "--list" + puts JSON.generate(available_locales) + when nil + seed_all_locales + else + locale = args.first + validate_locale!(locale) + seed_one_locale(locale) + end + end + + private + + def print_usage + puts <<~USAGE + Usage: #{$0} [OPTIONS] [LOCALE] + + Tests that seeding works in all available locales. + Uses the current development database. + + Options: + -h, --help Show this help message + --list Output available locales as JSON array + + Arguments: + LOCALE Seed a single locale (e.g. zh-CN, de, en) + + Examples: + #{$0} # Seed all locales sequentially + #{$0} --list # Output available locales as JSON + #{$0} zh-CN # Seed a single locale + USAGE + end + + def validate_locale!(locale) + return if available_locales.include?(locale) + + warn "Error: unknown locale '#{locale}'" + warn "Available locales: #{available_locales.join(', ')}" + exit 1 + end + + def available_locales + @available_locales ||= + rails_root.glob("config/locales/**/*.yml") + .map { |f| f.basename.to_s.split(".", 2).first } + .reject { |l| l.start_with?("js-") || l == "lol" } + .uniq + .sort + end + + def rails_root + @rails_root ||= + Pathname.new(__dir__) + .ascend + .find { |dir| dir.join("Gemfile").exist? } + .tap { |dir| raise "Unable to find Rails root directory (looking up from #{__dir__})" if dir.nil? } + end + + # Runs all locales sequentially and reports all failures at the end. + def seed_all_locales + locales = available_locales + puts "Testing seeding in #{locales.count} locales" + puts "Locales: #{locales.join(', ')}" + puts + + unless setup_schema + puts "ERROR: Database schema setup failed. Cannot continue." + exit 1 + end + + results = {} + locales.each_with_index do |locale, index| + puts + puts "=== [#{index + 1}/#{locales.count}] Seeding locale: #{locale} ===" + success = reset_and_seed(locale) + results[locale] = success + status = success ? "OK" : "FAILED" + puts "--- #{locale}: #{status} ---" + end + + print_summary(results) + end + + # Runs a single locale and exits with appropriate status code. + def seed_one_locale(locale) + puts "=== Seeding locale: #{locale} ===" + + terminate_db_connections + unless run("bin/rails", "db:drop", "db:create", "db:migrate") + puts "--- #{locale}: FAILED (database setup) ---" + exit 1 + end + + unless run("bin/rails", "db:seed", env: { "OPENPROJECT_SEED_LOCALE" => locale }) + puts "--- #{locale}: FAILED (seeding) ---" + exit 1 + end + + puts "--- #{locale}: OK ---" + end + + def setup_schema + puts "=== Setting up database schema ===" + terminate_db_connections + run("bin/rails", "db:drop", "db:create", "db:migrate", "db:schema:dump") + end + + def reset_and_seed(locale) + terminate_db_connections + run("bin/rails", "db:drop", "db:create", "db:schema:load") && + run("bin/rails", "db:seed", env: { "OPENPROJECT_SEED_LOCALE" => locale }) + end + + def terminate_db_connections + db_name = database_name + return unless db_name + + run("psql", "-d", "postgres", "-c", + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity " \ + "WHERE datname = '#{db_name}' AND pid <> pg_backend_pid()") + end + + def database_name + @database_name ||= begin + require "uri" + db_url = ENV.fetch("DATABASE_URL", nil) + if db_url + URI.parse(db_url).path.delete_prefix("/") + else + # Fall back to asking Rails; use .split.last to ignore any + # extra output from initializers (e.g. REPL commands messages) + `bin/rails runner "print ActiveRecord::Base.connection_db_config.database"`.split.last + end + end + end + + def run(*cmd, env: {}) + env_str = env.map { |k, v| "#{k}=#{v}" }.join(" ") + puts " $ #{env_str} #{cmd.join(' ')}".strip + system(env, *cmd, chdir: rails_root.to_s) + end + + def print_summary(results) + failed = results.reject { |_, success| success } + succeeded = results.select { |_, success| success } + + puts <<~SUMMARY + ================================ + SUMMARY: #{succeeded.count} succeeded, #{failed.count} failed out of #{results.count} locales + ================================ + SUMMARY + + if failed.any? + puts <<~FAILED + + Failed locales: + #{failed.keys.map { |locale| " - #{locale}" }.join("\n")} + FAILED + exit 1 + end + end + end +end + +SeedAllLocales.call(ARGV)