Translations sync #19
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: Translations sync | |
| # Daily pull of merged translations from the translate repo into the app. | |
| # Bumps the deps/translate submodule to its latest master, regenerates the | |
| # committed locale assets via `make translate` (SKIP_COMMIT=1 so CI stages the | |
| # diff), opens a PR, and auto-merges it when a cheap gate passes (en.json is a | |
| # faithful regen of source + every changed locale JSON parses). Translation-only | |
| # diffs can't affect the binary, so the PR skips the heavy iOS/Android CI jobs | |
| # (see ci.yml) and does not wait on them. Modelled on the org's | |
| # blokadaorg/landing-github-pages blocklist-sync + dependabot auto-merge pattern. | |
| # | |
| # Kill-switch: set the repo variable TRANSLATIONS_SYNC_AUTOMERGE_PAUSED to | |
| # 'true' (Settings -> Secrets and variables -> Actions -> Variables) to keep the | |
| # PR open without merging. Unset = enabled. | |
| on: | |
| schedule: | |
| # Daily 05:00 UTC = 06:00 CET / 07:00 CEST. Tunable. | |
| - cron: '0 5 * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: translations-sync | |
| cancel-in-progress: false | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| env: | |
| # convert.py writes raw UTF-8 via the locale's preferred encoding; pin a | |
| # UTF-8 locale so non-ASCII strings round-trip byte-for-byte and the | |
| # en.json drift guard stays clean. Matches ci.yml's `en.json drift` job. | |
| LANG: C.UTF-8 | |
| LC_ALL: C.UTF-8 | |
| steps: | |
| - name: Mint App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v3 | |
| with: | |
| # client-id input — `app-id` is deprecated in v3. Secrets are | |
| # distributed per-repo by ops/secrets.tf. | |
| client-id: ${{ secrets.BLOKADA_APP_CLIENT_ID }} | |
| private-key: ${{ secrets.BLOKADA_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| with: | |
| token: ${{ steps.app-token.outputs.token }} | |
| # No submodules here — only deps/translate is needed and it's inited | |
| # in the next step, skipping the heavy wireguard-apple / landing / | |
| # tracker-radar siblings (same approach as ci.yml's drift job). | |
| - name: Configure git identity | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| APP_SLUG: ${{ steps.app-token.outputs.app-slug }} | |
| run: | | |
| set -euo pipefail | |
| # Derive identity from the App slug. The bot user isn't always | |
| # exposed via /users/<slug>%5Bbot%5D (404), so the numeric-ID lookup | |
| # is best-effort with a slug-only noreply fallback. | |
| name="${APP_SLUG}[bot]" | |
| if id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id 2>/dev/null); then | |
| email="${id}+${name}@users.noreply.github.com" | |
| else | |
| email="${name}@users.noreply.github.com" | |
| fi | |
| git config user.name "${name}" | |
| git config user.email "${email}" | |
| echo "Configured: ${name} <${email}>" | |
| - name: Set up Python | |
| uses: actions/setup-python@v6 | |
| with: | |
| python-version: '3.x' | |
| - name: Init + bump translate submodule to latest master | |
| # Init only deps/translate, then put it on master tracking origin/master | |
| # so `make translate` (sync-translations.sh) can fast-forward it and we | |
| # bump to the newest merged translations. | |
| run: | | |
| set -euo pipefail | |
| git submodule update --init deps/translate | |
| git -C deps/translate fetch --quiet origin master | |
| git -C deps/translate checkout -B master origin/master | |
| echo "translate -> $(git -C deps/translate rev-parse --short HEAD)" | |
| - name: Regenerate translations (SKIP_COMMIT=1) | |
| env: | |
| SKIP_COMMIT: '1' | |
| run: make translate | |
| - name: Check for changes | |
| id: changes | |
| run: | | |
| set -euo pipefail | |
| if git diff --quiet HEAD && [ -z "$(git ls-files --others --exclude-standard)" ]; then | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| echo "No new translations — exiting cleanly." | |
| else | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Gate | |
| id: gates | |
| if: steps.changes.outputs.has_changes == 'true' | |
| # Cheap, mechanical "is it OK" check, and the AUTHORITATIVE pre-merge | |
| # gate: the merge below fires immediately and does not wait on the PR's | |
| # CI, so the en.json drift job there is only advisory on these PRs. | |
| # Always exits 0 — a failure just sets verdict=FAIL so the PR is still | |
| # opened (for a human) but not merged. | |
| run: | | |
| set +e | |
| verdict=PASS | |
| reason="en.json matches source; all changed translation JSON valid" | |
| out="$GITHUB_WORKSPACE/common/assets/translations" | |
| # 1. en.json must be a faithful regen of the pinned v6 source (the | |
| # same check ci.yml's drift job enforces) — compare a fresh regen | |
| # to what `make translate` wrote. | |
| ( cd deps/translate/scripts | |
| for p in Ui:ui Packs:packs PackTags:packtags; do | |
| python3 ./convert.py -i "../v6/${p%%:*}.strings" -o "/tmp/${p##*:}-en.json" -f flutter | |
| done ) | |
| for c in ui packs packtags; do | |
| if ! diff -q "/tmp/$c-en.json" "$out/$c/en.json" >/dev/null 2>&1; then | |
| verdict=FAIL; reason="$c/en.json is not a faithful regen of v6 source" | |
| fi | |
| done | |
| # 2. Every changed/added JSON must parse. make translate writes JSON | |
| # under common/assets/translations AND android/app/src/main/assets/ | |
| # translations, and all of it ships via `git add -A`, so validate | |
| # every changed/added .json (iOS/Android .strings/.xml aren't JSON). | |
| if [ "$verdict" = PASS ]; then | |
| files=$( { git diff --name-only HEAD; \ | |
| git ls-files --others --exclude-standard; } \ | |
| | grep '\.json$' | sort -u ) | |
| for f in $files; do | |
| if ! python3 -c "import json,sys; json.load(open(sys.argv[1], encoding='utf-8'))" "$f" 2>/dev/null; then | |
| verdict=FAIL; reason="invalid JSON: $f"; break | |
| fi | |
| done | |
| fi | |
| echo "verdict=$verdict" >> "$GITHUB_OUTPUT" | |
| echo "reason=$reason" >> "$GITHUB_OUTPUT" | |
| echo "verdict=$verdict reason=$reason" | |
| - name: Build PR body | |
| id: body | |
| if: steps.changes.outputs.has_changes == 'true' | |
| env: | |
| VERDICT: ${{ steps.gates.outputs.verdict }} | |
| REASON: ${{ steps.gates.outputs.reason }} | |
| run: | | |
| set -euo pipefail | |
| today=$(date -u +%Y-%m-%d) | |
| sha=$(git -C deps/translate rev-parse --short HEAD) | |
| locales=$( { git diff --name-only HEAD -- common/assets/translations; \ | |
| git ls-files --others --exclude-standard -- common/assets/translations; } \ | |
| | grep -oE '/[a-z0-9_]+\.json$' | tr -d '/' | sed 's/\.json$//' | sort -u | tr '\n' ' ') | |
| nfiles=$( { git diff --name-only HEAD; git ls-files --others --exclude-standard; } | sort -u | wc -l | tr -d ' ') | |
| { | |
| echo "Automated translation sync ($today)." | |
| echo | |
| echo "Bumps \`deps/translate\` to \`$sha\` and regenerates the committed locale assets via \`make translate\`." | |
| echo | |
| if [ "${VERDICT}" = "PASS" ]; then | |
| echo ":white_check_mark: Gate passed — eligible for auto-merge." | |
| else | |
| echo ":warning: Gate tripped — needs human review: ${REASON}" | |
| fi | |
| echo | |
| echo "- changed files: \`$nfiles\`" | |
| echo "- locales touched: ${locales:-none}" | |
| } > /tmp/pr-body.md | |
| { | |
| echo 'body<<TRANSLATIONS_SYNC_BODY_EOF' | |
| cat /tmp/pr-body.md | |
| echo 'TRANSLATIONS_SYNC_BODY_EOF' | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Push branch | |
| id: push | |
| if: steps.changes.outputs.has_changes == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| set -euo pipefail | |
| today=$(date -u +%Y-%m-%d) | |
| branch="automation/translations-sync/${today}" | |
| if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then | |
| branch="${branch}-${{ github.run_id }}" | |
| fi | |
| hash=$(git -C deps/translate describe --abbrev=4 --always --tags --dirty) | |
| git switch -c "${branch}" | |
| git add -A | |
| git commit -m "sync: update translate strings to: ${hash}" | |
| git push origin "${branch}" | |
| echo "branch=${branch}" >> "$GITHUB_OUTPUT" | |
| echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| - name: Open PR | |
| id: pr | |
| if: steps.changes.outputs.has_changes == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| REPO: ${{ github.repository }} | |
| BRANCH: ${{ steps.push.outputs.branch }} | |
| BODY: ${{ steps.body.outputs.body }} | |
| run: | | |
| set -euo pipefail | |
| today=$(date -u +%Y-%m-%d) | |
| # Close any stale open sync PR (match by branch prefix) so unattended | |
| # needs-review PRs don't accumulate across daily runs. | |
| old_prs=$(gh pr list --repo "$REPO" --state open \ | |
| --json number,headRefName \ | |
| --jq '.[] | select(.headRefName | startswith("automation/translations-sync/")) | .number') | |
| for pr in $old_prs; do | |
| gh pr close "$pr" --repo "$REPO" --delete-branch \ | |
| --comment "Superseded by today's translations sync." || true | |
| done | |
| default_branch=$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name) | |
| pr_url=$(gh pr create \ | |
| --base "${default_branch}" \ | |
| --head "${BRANCH}" \ | |
| --title "Translations sync (${today})" \ | |
| --body "${BODY}") | |
| echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT" | |
| pr_number=$(echo "${pr_url}" | sed -E 's|.*/pull/([0-9]+).*|\1|') | |
| echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" | |
| - name: Verify PR head unchanged before merge | |
| id: stale-check | |
| if: >- | |
| steps.changes.outputs.has_changes == 'true' && | |
| steps.gates.outputs.verdict == 'PASS' && | |
| vars.TRANSLATIONS_SYNC_AUTOMERGE_PAUSED != 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR: ${{ steps.pr.outputs.pr_number }} | |
| REPO: ${{ github.repository }} | |
| PUSHED_HEAD: ${{ steps.push.outputs.head_sha }} | |
| run: | | |
| set -euo pipefail | |
| current=$(gh pr view "$PR" --repo "$REPO" --json headRefOid --jq .headRefOid) | |
| if [ "$current" != "$PUSHED_HEAD" ]; then | |
| echo "PR head ($current) differs from pushed head ($PUSHED_HEAD); refusing stale merge" | |
| exit 1 | |
| fi | |
| echo "PR head matches pushed SHA: $PUSHED_HEAD" | |
| - name: Squash-merge | |
| if: >- | |
| steps.changes.outputs.has_changes == 'true' && | |
| steps.gates.outputs.verdict == 'PASS' && | |
| vars.TRANSLATIONS_SYNC_AUTOMERGE_PAUSED != 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| PR: ${{ steps.pr.outputs.pr_number }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| # No self-approve: the App authors and merges this PR, and `main` | |
| # requires no approving review. | |
| gh pr merge "$PR" --repo "$REPO" --squash --delete-branch | |
| - name: Summary | |
| if: always() | |
| env: | |
| HAS_CHANGES: ${{ steps.changes.outputs.has_changes }} | |
| VERDICT: ${{ steps.gates.outputs.verdict }} | |
| REASON: ${{ steps.gates.outputs.reason }} | |
| PR_URL: ${{ steps.pr.outputs.pr_url }} | |
| KILL_SWITCH: ${{ vars.TRANSLATIONS_SYNC_AUTOMERGE_PAUSED }} | |
| run: | | |
| { | |
| if [ "${HAS_CHANGES}" != "true" ]; then | |
| echo "### No new translations" | |
| else | |
| echo "### Translations sync" | |
| echo | |
| echo "- PR: ${PR_URL}" | |
| echo "- Gate verdict: \`${VERDICT}\`" | |
| echo "- Reason: ${REASON}" | |
| echo "- Kill-switch (TRANSLATIONS_SYNC_AUTOMERGE_PAUSED): \`${KILL_SWITCH:-unset}\`" | |
| fi | |
| } >> "$GITHUB_STEP_SUMMARY" |