chore(i18n): sync en-US from Citadel @ 70b8822 #30
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
| ############################################################################################# | |
| # AI translation fill | |
| # | |
| # Purpose: When locales/en-US.yml changes (because Citadel just synced it), | |
| # run Gemini to fill new and changed strings into every other locale. | |
| # | |
| # Token handling, learned the expensive way: | |
| # - The Gemini pass can run well over an hour. A GitHub App installation | |
| # token only lives 1 hour, so it is minted AFTER translation, immediately | |
| # before the push — never at job start. | |
| # - The push authenticates with that token explicitly in the remote URL. It | |
| # does not rely on actions/checkout credential persistence (checkout runs | |
| # with persist-credentials: false). | |
| # - Pushing via the App token (not GITHUB_TOKEN) is also what lets the push | |
| # trigger notify-citadel.yml; GITHUB_TOKEN pushes do not trigger workflows. | |
| # - The translated files are uploaded as an artifact before the push, so a | |
| # run's Gemini spend is recoverable even if the push somehow fails. | |
| # | |
| # No recursion: this workflow triggers only on en-US.yml / languages.yml, and | |
| # the files it commits are the *other* locales. | |
| # | |
| # Required secrets (org-level): | |
| # GEMINI_API_KEY — Google Gemini API key with generateContent access | |
| # CIDER_I18N_BOT_APP_ID — Saki-Yuzishima App identifier | |
| # CIDER_I18N_BOT_PRIVATE_KEY — PEM contents of the App's private key | |
| ############################################################################################# | |
| name: AI fill | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - "locales/en-US.yml" | |
| - "locales/languages.yml" | |
| workflow_dispatch: | |
| inputs: | |
| force: | |
| description: "Re-translate everything (ignores existing files)" | |
| type: boolean | |
| default: false | |
| lang: | |
| description: "Limit to one locale (e.g. es). Leave blank for all." | |
| type: string | |
| default: "" | |
| # The job authenticates with the App token; the default GITHUB_TOKEN is unused. | |
| permissions: {} | |
| concurrency: | |
| group: ai-fill-${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| fill: | |
| name: Translate delta with Gemini | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # fetch-depth 2 lets the translator inspect the prior en-US.yml. | |
| # persist-credentials: false — the push at the end authenticates | |
| # explicitly with a freshly-minted App token, not a stored one. | |
| fetch-depth: 2 | |
| persist-credentials: false | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "24" | |
| - run: npm install --no-package-lock | |
| - name: Translate delta | |
| env: | |
| GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} | |
| FORCE_FLAG: ${{ inputs.force }} | |
| LANG_FILTER: ${{ inputs.lang }} | |
| run: | | |
| set -euo pipefail | |
| args=() | |
| if [ "${FORCE_FLAG}" = "true" ]; then args+=(--force); fi | |
| if [ -n "${LANG_FILTER:-}" ]; then args+=(--lang "${LANG_FILTER}"); fi | |
| node scripts/i18n-translate.mjs "${args[@]}" | |
| # Recovery net: upload the translated files before the push is attempted. | |
| # If the push fails for any reason, the run's Gemini output is still | |
| # downloadable from the run's Artifacts, so the spend is never lost. | |
| - name: Upload translated locales (recovery artifact) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: locales | |
| path: locales/ | |
| retention-days: 7 | |
| if-no-files-found: warn | |
| # Minted HERE, not at job start: a full fill can outlast the 1-hour | |
| # installation-token lifetime. Minting right before the push keeps the | |
| # token fresh when it is actually used. | |
| - name: 🪪 Mint App installation token | |
| id: app-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.CIDER_I18N_BOT_APP_ID }} | |
| private-key: ${{ secrets.CIDER_I18N_BOT_PRIVATE_KEY }} | |
| permission-contents: write | |
| - name: Commit and push translations | |
| env: | |
| APP_TOKEN: ${{ steps.app-token.outputs.token }} | |
| run: | | |
| set -euo pipefail | |
| # Stage first, then diff the index. `git diff` alone ignores | |
| # untracked files, so a first-ever fill (all-new locale files) | |
| # would look like "no changes" and never commit. | |
| git add locales/ | |
| if git diff --cached --quiet; then | |
| echo "No locale changes after translation pass." | |
| exit 0 | |
| fi | |
| git config user.name 'saki-yuzishima[bot]' | |
| git config user.email '286499753+saki-yuzishima[bot]@users.noreply.github.com' | |
| git commit -m "chore(i18n): AI fill" | |
| # Push with rebase-retry. Cross-workflow pushes (apply commits from | |
| # translation-issue, en-US mirror from Citadel) can land between | |
| # checkout and push and cause a non-fast-forward rejection. Rebase | |
| # our commit onto the new remote tip and retry; the AI fill's diff | |
| # only touches missing keys, so rebasing across an unrelated commit | |
| # is a clean replay in practice. Token explicit in the URL — does | |
| # not depend on stored credentials. GITHUB_REPOSITORY is owner/repo; | |
| # the App token is auto-masked in logs. | |
| REMOTE="https://x-access-token:${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" | |
| for attempt in 1 2 3 4 5; do | |
| if git push "$REMOTE" HEAD:main; then | |
| echo "push succeeded (attempt $attempt)" | |
| exit 0 | |
| fi | |
| echo "::warning::push attempt $attempt rejected (likely concurrent push). Rebasing onto origin/main..." | |
| git fetch "$REMOTE" main | |
| if ! git rebase FETCH_HEAD; then | |
| echo "::error::rebase conflicts the bot can't resolve. Translated locales are uploaded as an artifact on this run — re-run the workflow or apply manually from the artifact." | |
| git rebase --abort || true | |
| exit 1 | |
| fi | |
| sleep $((attempt + RANDOM % 3)) | |
| done | |
| echo "::error::push failed after 5 attempts; the remote ref keeps moving." | |
| exit 1 |