Weblate 기반의 번역 동기화 스크립트 설계 (propose_translation_update.sh)

2025 오픈소스 컨트리뷰션 아카데미 프로그램에서 OpenStack i18n 팀은 Zanata-cli 기반의 기존 번역 자동화 파이프라인을 Weblate 기반으로 마이그레이션하는 작업을 진행하고 있습니다.

https://review.opendev.org/c/openstack/openstack-zuul-jobs/+/921878

이 과정에서 제가 담당하는 작업 중 하나는 Zanata에서 번역이 완료된 .po 파일들을 자동으로 원격 Git 저장소(Gerrit)에 반영하는 스크립트(propose_trasnlation_update.sh)를 Weblate API를 이용해 새롭게 구현하는 것입니다.

이번 글에서는 해당 스크립트의 설계 의도, 구현 과정, 그리고 Zanata → Weblate 전환을 위한 구조적 차이점을 중심으로 OpenStack I18n 팀이 어떻게 새로운 번역 자동화 파이프라인을 완성해 나가고 있는지를 공유하고자 합니다.

Zanata를 사용하는 기존의 번역 파이프라인에서는 zanata-cli pull 명령어를 통해 서버에서 번역된 .po 파일을 내려받은 후 커밋 및 Gerrit 리뷰 요청을 수동으로 수행하는 구조였지만, 이 방식은 이미 더 이상 유지보수가 이루어지지 않는 Zanata 서버와 CLI 도구에 의존하고 있었습니다.

반면, Weblate은 RESTful API를 통해 모든 번역 데이터를 직접 제어할 수 있는 구조를 제공하며 Git 저장소와 양방향으로 동기화할 수 있다는 장점이 있습니다.

이에 따라 새로운 스크립트(propose_translation_update.sh)는 기존의 단순 명령 실행형 구조를 API 호출 기반의 선언적 동기화 구조로 완전히 재설계했습니다. 또한 Zuul CI 환경에서 주기적 또는 수동 트리거에 의해 실행될 수 있도록 설계하여, 개발자의 개입 없이 Weblate → Git → Gerrit으로 이어지는 번역 자동화 파이프라인을 구현하는 것을 목표로 했습니다.

propose_translation_update.sh 전체 스크립트 구조

현재까지 openstack-zuul-jobs 저장소의 921878 패치셋에서 개발중인 스크립트는 아래와 같습니다.

Zanata CLI가 수행하던 pull/push를 모두 Weblate API 호출로 치환하기 위해 Bash + curl + jq를 이용해 RESTful API 기반 비동기 데이터 파이프라인을 구현했습니다.

#!/bin/bash

# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

set -e
set -o pipefail

source "$(dirname "${BASH_SOURCE[0]}")/common_translation_update.sh"

project=$1
branch=${2:-"master"}

: ${WEBLATE_URL:?ERROR: WEBLATE_URL is not set.}

TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' INT TERM EXIT

get_filename() {
    local component=$1
    case "$component" in
        *javascript*|*js*)
            echo "djangojs.po"
            ;;
        *-dashboard|horizon|*-ui|*-web-ui)
            echo "django.po"
            ;;
        *)
            local po_name=$(echo "$component" | tr '-' '_')
            echo "${po_name}.po"
            ;;
    esac
}

download_translation() {
    local project=$1
    local component=$2
    local language=$3
    local output_file=$4

    curl -s --config ~/.curlrc \
        "$WEBLATE_URL/api/translations/$project/$component/$language/file/" \
        -o "$output_file"
    [ $? -eq 0 ] && [ -s "$output_file" ]
}

process_translations() {
    local project=$1
    local component=$2

    if [[ "$component" == "glossary" ]]; then
        return
    fi

    local languages
    set +e
    languages=$(curl -s --config ~/.curlrc \
        "$WEBLATE_URL/api/components/$project/$component/translations/" | \
        jq -r '.results[] | select(.language.code != "en") |
        .language.code' 2>/dev/null)
    set -e

    if [[ -z "$languages" ]]; then
        return
    fi

    local lang
    for lang in $languages; do
        local temp_file="$TEMP_DIR/${component}_${lang}.po"

        if download_translation "$project" "$component" "$lang" \
                                "$temp_file"; then
            local target_dir="$project/locale/$lang/LC_MESSAGES"
            mkdir -p "$target_dir"

            local po_filename=$(get_filename "$component")
            cp "$temp_file" "$target_dir/$po_filename"
        fi
    done

    if [ -d "$project/locale" ]; then
        find "$project/locale" -name "*.po" -exec git add {} +
        find "$project/locale" -name "*.pot" -exec git add {} +
    fi
}

components=$(curl -s --config ~/.curlrc \
    "$WEBLATE_URL/api/projects/$project/components/" | \
    jq -r '.results[].slug' 2>/dev/null)

if [[ -z "$components" ]]; then
    echo "ERROR: No components found for project $project."
    exit 1
fi

for comp in $components; do
    process_translations "$project" "$comp"
done

setup_git
setup_review "$branch"
filter_commits
send_patch "$branch"

get_filename()

오픈스택의 각 프로젝트는 서로 다른 번역 파일 네이밍 규칙을 가지고 있습니다. 예를 들어 Horizon 대시보드는 Django 기반이므로 django.po, JavaScript 컴포넌트는 djangojs.po를 사용합니다.

이를 자동으로 처리하기 위해서 get_filename() 함수를 통해 컴포넌트 이름 패턴에 따라 동적으로 파일명을 결정하도록 구현하였습니다.

get_filename() {
    local component=$1
    case "$component" in
        *javascript*|*js*)
            echo "djangojs.po"
            ;;
        *-dashboard|horizon|*-ui|*-web-ui)
            echo "django.po"
            ;;
        *)
            local po_name=$(echo "$component" | tr '-' '_')
            echo "${po_name}.po"
            ;;
    esac
}

download_translation()

기존 Zanata CLI에서는 내부적으로 HTTP 요청을 감춰두었기 때문에 다운로드 실패나 파일 손상 시 원인을 파악하기 어렵습니다. 반면에 Weblate는 REST API를 통해 각 컴포넌트/언어/프로젝트 단위로 직접 파일을 요청할 수 있기 때문에 이러한 장점을 활용하였습니다.

download_translation() {
    local project=$1
    local component=$2
    local language=$3
    local output_file=$4

    curl -s --config ~/.curlrc \
        "$WEBLATE_URL/api/translations/$project/$component/$language/file/" \
        -o "$output_file"
    [ $? -eq 0 ] && [ -s "$output_file" ]
}
  • ~/.curlrc에 인증 토큰을 저장해 보안을 유지하며, 다운로드가 실패하거나 비어 있는 파일이 생성되면 즉시 실패 처리합니다.
  • CI 환경에서 로그를 남길 때 API 요청-응답 상태를 직접 추적할 수 있습니다.

process_translations()

이 함수는 스크립트의 핵심 동작 부분으로, Weblate 프로젝트 내의 모든 컴포넌트와 언어를 순회하면서 번역 파일을 다운로드하고 Git에 반영합니다.

process_translations() {
    local project=$1
    local component=$2

    if [[ "$component" == "glossary" ]]; then
        return
    fi

    local languages
    set +e
    languages=$(curl -s --config ~/.curlrc \
        "$WEBLATE_URL/api/components/$project/$component/translations/" | \
        jq -r '.results[] | select(.language.code != "en") | .language.code' 2>/dev/null)
    set -e

이때 set +e / set -e 구문을 교대로 사용하여 일시적인 API 응답 실패로 전체 프로세스가 멈추는 것을 방지합니다.

이후 각 언어별로 다운로드된 .po 파일을 프로젝트의 locale 디렉터리로 배치하고, 다음과 같이 자동으로 Git에 스테이징합니다.

if [ -d "$project/locale" ]; then
    find "$project/locale" -name "*.po" -exec git add {} +
    find "$project/locale" -name "*.pot" -exec git add {} +
fi

번역 파일 변경 사항을 자동으로 감지하고 Git에 반영하기 때문에 개발자가 직접 커밋 파일을 선택하지 않아도 됩니다.

임시 디렉터리 처리 로직

스크립트는 모든 다운로드 작업을 mktemp -d로 생성한 임시 디렉터리에서 수행합니다. 병렬적으로 여러 번역 Job이 실행되는 Zuul CI 환경에서 파일 충돌이나 디스크 잔여물 누적 문제를 방지하기 위한 설계입니다.

TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' INT TERM EXIT

trap 구문은 스크립트가 정상 종료되든 예외로 중단되든 관계없이 임시 디렉터리를 자동 삭제하여 깨끗한 실행 환경을 유지하기 위해 존재합니다.

Gerrit 자동 커밋 & patch 전송 로직

스크립트의 마지막 부분은 수동 커밋 과정을 완전히 자동화합니다.

setup_git
setup_review "$branch"
filter_commits
send_patch "$branch"
  • setup_git: CI 환경에서 사용자 이름과 이메일 등 Git 설정 자동화
  • setup_review: Gerrit 리뷰 브랜치 생성
  • filter_commits: 변경 사항이 없을 경우 커밋 생성을 건너뜀
  • send_patch: git review 명령을 자동 실행하여 Gerrit에 패치 전송

이 스크립트는 단독으로 실행되는 것이 아니라 OpenStack Zuul CI 파이프라인 안에서 주기적으로 동작하도록 설계되어 있습니다.

Weblate에서 번역이 완료된 시점 이후 Zuul이 해당 Job을 자동으로 트리거하면 스크립트는 번역 파일을 내려받고 Gerrit에 새로운 Change를 생성하게 됩니다.

이번 글에서 설명한 propose_translation_update.sh 스크립트가 Zuul CI 파이프라인에 통합되어 작동하는 자세한 메커니즘은 이전에 작성한 글 ( propose-translation-update.sh 실행을 위한 Zuul CI/CD 파이프라인 흐름 분석 )에서 다루고 있습니다.

* 이 글의 작성 시점인 2025년 10월 11일을 기준으로, 921878 패치셋은 메인테이너와의 소통이 활발하게 진행중입니다. 따라서 본문에 기술된 내용은 리뷰 피드백과 패치셋 업데이트에 따라 보완될 수 있습니다. 의견이나 제안은 Gerrit에 코멘트로 남겨 주시면 감사하겠습니다:)