Crowdin – Download Translations & Open PR #20
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Crowdin – Download Translations & Open PR | |
| on: | |
| # schedule: | |
| # - cron: '0 6 * * 1' # Every Monday 06:00 UTC — adjust to your cadence | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| crowdin-sync: | |
| name: Sync translations from Crowdin | |
| runs-on: ubuntu-latest | |
| env: | |
| TARGET_BRANCH: locale_community # stable base — PR merges INTO this | |
| PATCH_BRANCH: patch # short-lived — PR is opened FROM this | |
| steps: | |
| # ── 1. Checkout ────────────────────────────────────────────────────────── | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| # ── 2. Restore last-run timestamp from cache ────────────────────────────── | |
| - name: Restore last-run timestamp | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: .crowdin-last-run | |
| key: crowdin-last-run-timestamp-${{ github.run_id }} | |
| restore-keys: | | |
| crowdin-last-run-timestamp- | |
| - name: Resolve sync window timestamps | |
| id: sync_time | |
| run: | | |
| if [ -f .crowdin-last-run/timestamp ]; then | |
| DATE_FROM=$(cat .crowdin-last-run/timestamp) | |
| echo "Using cached last-run timestamp: $DATE_FROM" | |
| else | |
| DATE_FROM=$(date -u -d "7 days ago" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null \ | |
| || date -u -v-7d +"%Y-%m-%dT%H:%M:%SZ") | |
| echo "No cache found — defaulting to 7 days ago: $DATE_FROM" | |
| fi | |
| DATE_TO=$(date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| echo "date_from=$DATE_FROM" >> "$GITHUB_OUTPUT" | |
| echo "date_to=$DATE_TO" >> "$GITHUB_OUTPUT" | |
| echo "Sync window: $DATE_FROM → $DATE_TO" | |
| # Snapshot current SHA of locale_community for diff check | |
| if git ls-remote --exit-code --heads origin "$TARGET_BRANCH"; then | |
| echo "prev_sha=$(git rev-parse origin/$TARGET_BRANCH)" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "prev_sha=" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ── 3. Prepare the patch branch ─────────────────────────────────────────── | |
| - name: Prepare patch branch | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| if git ls-remote --exit-code --heads origin "$TARGET_BRANCH"; then | |
| git fetch origin "$TARGET_BRANCH" | |
| git checkout -B "$TARGET_BRANCH" "origin/$TARGET_BRANCH" | |
| else | |
| echo "locale_community does not exist yet — creating from main" | |
| git checkout -B "$TARGET_BRANCH" origin/main | |
| git push origin "$TARGET_BRANCH" | |
| fi | |
| git checkout -B "$PATCH_BRANCH" "$TARGET_BRANCH" | |
| git push --force origin "$PATCH_BRANCH" | |
| # ── 4. Download translations from Crowdin onto the patch branch ─────────── | |
| - name: Download translations from Crowdin | |
| uses: crowdin/github-action@v2 | |
| with: | |
| upload_sources: false | |
| upload_translations: false | |
| download_translations: true | |
| create_pull_request: false | |
| localization_branch_name: ${{ env.PATCH_BRANCH }} | |
| commit_message: 'chore(i18n): sync translations from Crowdin' | |
| github_user_name: 'crowdin-bot' | |
| github_user_email: 'support+bot@crowdin.com' | |
| project_id: ${{ secrets.CROWDIN_PROJECT_ID }} | |
| token: ${{ secrets.CROWDIN_TOKEN }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # ── 5. Check whether anything actually changed ──────────────────────────── | |
| - name: Check for translation changes | |
| id: changed | |
| run: | | |
| git fetch origin "$TARGET_BRANCH" "$PATCH_BRANCH" | |
| CHANGED_FILES=$(git diff --name-only \ | |
| "origin/$TARGET_BRANCH" "origin/$PATCH_BRANCH" \ | |
| | grep -E '\.(po|pot)$' || echo "") | |
| if [ -z "$CHANGED_FILES" ]; then | |
| echo "No translation files changed. Skipping PR." | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| echo "Changed files:" | |
| echo "$CHANGED_FILES" | |
| { | |
| echo "changed_files<<EOF" | |
| echo "$CHANGED_FILES" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| # ── 6. Fetch contributors for the changed files only ───────────────────── | |
| # 1. Map each changed .po file back to its Crowdin source path (/en/ version) | |
| # 2. Look up the Crowdin file ID for each source path | |
| # 3. Query approvals scoped to those file IDs + sync date window | |
| # 4. Only people who approved strings in the changed files are listed | |
| - name: Fetch contributors for changed files | |
| id: contributors | |
| if: steps.changed.outputs.has_changes == 'true' | |
| env: | |
| CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} | |
| CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }} | |
| DATE_FROM: ${{ steps.sync_time.outputs.date_from }} | |
| DATE_TO: ${{ steps.sync_time.outputs.date_to }} | |
| CHANGED_FILES: ${{ steps.changed.outputs.changed_files }} | |
| run: | | |
| echo "Fetching contributors between $DATE_FROM and $DATE_TO" | |
| # ── A: Fetch all Crowdin file IDs once — matched per changed file below ─── | |
| ALL_FILES=$(curl -sf \ | |
| "https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/files?limit=500" \ | |
| -H "Authorization: Bearer ${CROWDIN_TOKEN}") | |
| # ── B: Fetch approvals per file+language, collect unique approvers ──────── | |
| # The approvals endpoint requires both fileId AND languageId. | |
| # We extract the language code from each changed file path directly | |
| # e.g. locale/fr/LC_MESSAGES/django.po → "fr" | |
| # Then query approvals for each unique file+language combination. | |
| declare -A SEEN_USERS | |
| declare -A SEEN_FILE_LANG # avoid duplicate file+lang lookups | |
| CO_AUTHORS="" | |
| while IFS= read -r changed_file; do | |
| [ -z "$changed_file" ] && continue | |
| # Extract language code from path e.g. locale/fr/LC_MESSAGES → fr | |
| lang_code=$(echo "$changed_file" | grep -oP '(?<=/locale/)[^/]+(?=/LC_MESSAGES)') | |
| [ -z "$lang_code" ] && continue | |
| # Map to source path to get file ID | |
| src_path="/$(echo "$changed_file" | sed 's|/locale/[^/]*/LC_MESSAGES/|/locale/en/LC_MESSAGES/|g')" | |
| file_id=$(echo "$ALL_FILES" | jq -r \ | |
| --arg p "$src_path" \ | |
| '.data[] | select(.data.path == $p) | .data.id') | |
| [ -z "$file_id" ] || [ "$file_id" = "null" ] && continue | |
| # Skip if we already fetched this file+language combination | |
| key="${file_id}:${lang_code}" | |
| [ -n "${SEEN_FILE_LANG[$key]+_}" ] && continue | |
| SEEN_FILE_LANG[$key]=1 | |
| echo "Fetching approvals for file ID $file_id, language $lang_code" | |
| APPROVALS=$(curl -sf \ | |
| "https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/approvals?fileId=${file_id}&languageId=${lang_code}&limit=500" \ | |
| -H "Authorization: Bearer ${CROWDIN_TOKEN}") | |
| while IFS=$'\t' read -r fullname username; do | |
| [ -z "$username" ] && continue | |
| if [ -z "${SEEN_USERS[$username]+_}" ]; then | |
| SEEN_USERS[$username]=1 | |
| name="${fullname:-$username}" | |
| CO_AUTHORS+="Co-authored-by: $name <${username}@users.noreply.github.com>"$'\n' | |
| fi | |
| done < <(echo "$APPROVALS" | jq -r \ | |
| --arg from "$DATE_FROM" \ | |
| --arg to "$DATE_TO" \ | |
| '.data[] | |
| | select(.data.createdAt >= $from and .data.createdAt <= $to) | |
| | select(.data.user != null) | |
| | [(.data.user.fullName // .data.user.username), .data.user.username] | |
| | @tsv') | |
| done <<< "$CHANGED_FILES" | |
| CO_AUTHORS=$(echo "$CO_AUTHORS" | sort -u | sed '/^$/d') | |
| if [ -z "$CO_AUTHORS" ]; then | |
| echo "No approvers found in window for the changed files." | |
| CO_AUTHORS="Co-authored-by: Crowdin Community <crowdin@noreply.com>" | |
| fi | |
| echo "Contributors:" | |
| echo "$CO_AUTHORS" | |
| { | |
| echo "co_authors<<EOF" | |
| echo "$CO_AUTHORS" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| # ── 7. Amend the commit on patch with Co-authored-by trailers ──────────── | |
| - name: Amend commit with Co-authored-by | |
| if: steps.changed.outputs.has_changes == 'true' | |
| run: | | |
| sudo chown -R "$(id -u):$(id -g)" .git | |
| git config --global safe.directory "$GITHUB_WORKSPACE" | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git fetch origin "$PATCH_BRANCH" | |
| git checkout "$PATCH_BRANCH" | |
| COMMIT_MSG="chore(i18n): sync Crowdin translations | |
| ${{ steps.contributors.outputs.co_authors }}" | |
| git commit --allow-empty --amend -m "$COMMIT_MSG" | |
| git push --force-with-lease origin "$PATCH_BRANCH" | |
| # ── 8. Open PR from patch → locale_community ───────────────────────────── | |
| - name: Create or update Pull Request | |
| if: steps.changed.outputs.has_changes == 'true' | |
| uses: peter-evans/create-pull-request@v6 | |
| with: | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| branch: ${{ env.PATCH_BRANCH }} | |
| base: ${{ env.TARGET_BRANCH }} | |
| title: '🌐 Crowdin translation sync – community contributions' | |
| body: | | |
| ## 🌐 Crowdin Translation Sync | |
| **Sync window:** `${{ steps.sync_time.outputs.date_from }}` → `${{ steps.sync_time.outputs.date_to }}` | |
| ### 📄 Changed translation files | |
| ``` | |
| ${{ steps.changed.outputs.changed_files }} | |
| ``` | |
| ### 👥 Translators in this batch | |
| Only contributors who approved strings in the changed files during | |
| this window are credited via `Co-authored-by:` in the commit: | |
| ``` | |
| ${{ steps.contributors.outputs.co_authors }} | |
| ``` | |
| --- | |
| *Merge this PR into `locale_community` to accept the translations.* | |
| *Generated by the **Crowdin Translations** workflow.* | |
| labels: | | |
| translations | |
| community | |
| crowdin | |
| draft: false | |
| # ── 9. Persist timestamp — only on full success ─────────────────────────── | |
| - name: Write last-run timestamp | |
| if: steps.changed.outputs.has_changes == 'true' | |
| run: | | |
| mkdir -p .crowdin-last-run | |
| echo "${{ steps.sync_time.outputs.date_to }}" > .crowdin-last-run/timestamp | |
| - name: Save last-run timestamp cache | |
| if: steps.changed.outputs.has_changes == 'true' | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: .crowdin-last-run | |
| key: crowdin-last-run-timestamp-${{ github.run_id }} | |
| - name: Write last-run timestamp (no changes) | |
| if: steps.changed.outputs.has_changes == 'false' | |
| run: | | |
| mkdir -p .crowdin-last-run | |
| echo "${{ steps.sync_time.outputs.date_to }}" > .crowdin-last-run/timestamp | |
| - name: Save last-run timestamp cache (no changes) | |
| if: steps.changed.outputs.has_changes == 'false' | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: .crowdin-last-run | |
| key: crowdin-last-run-timestamp-${{ github.run_id }} |