Skip to content

chore(i18n): sync en-US from Citadel @ 70b8822 #30

chore(i18n): sync en-US from Citadel @ 70b8822

chore(i18n): sync en-US from Citadel @ 70b8822 #30

Workflow file for this run

#############################################################################################
# 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