name: i18n Auto-Translate # Backends (select with repository variable TRANSLATE_BACKEND): # github (default) — GitHub Models inference API (https://models.github.ai/inference) # with the repository's GITHUB_TOKEN. Requires `models: read`. Default model # openai/gpt-5-mini; override via repository variable TRANSLATE_MODEL. # anthropic — Anthropic API direct. Set secret ANTHROPIC_API_KEY and # TRANSLATE_MODEL to a Claude model id (e.g. claude-sonnet-4-6). # openai — any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen, one-api/ # new-api gateways, vLLM, ...). Set secret TRANSLATE_API_KEY, secret or # variable TRANSLATE_ENDPOINT (API base ending in /v1), and TRANSLATE_MODEL. # # Trigger is `pull_request_target` (NOT `pull_request`) so that fork PRs also # get secrets and a write token — this is what allows auto-translating and # pushing translations back to fork branches ("Allow edits by maintainers"). # # SECURITY MODEL — read before editing: # pull_request_target runs with secrets in the BASE repo context, so this # workflow must NEVER execute code from the PR head. We therefore: # 1. check out the trusted base branch (workflow + scripts/i18n/* run from it), # 2. fetch the PR *merge ref* and copy in DATA files only # (skills/, manifest.json, categories.json), dropping symlinks, # 3. run the trusted scripts over that data, # 4. push resulting translations to the PR head branch with the bot token. # Any value derived from the PR (branch name, repo name) is passed to shell # steps via `env:` — never interpolated with ${{ }} inside `run:`. # # Scope: on PR events, only skills whose SKILL*.md files actually changed in # the PR are checked/translated — keeps token usage proportional to the PR. If # manifest.json or categories.json changed, falls back to full-repo scan (these # affect the i18n fallback chain globally). workflow_dispatch always does a full # scan unless `skill` input is supplied. on: pull_request_target: workflow_dispatch: inputs: target_locale: description: "Target locale (default: all from manifest.supportedLocales)" required: false skill: description: "Specific skill path, e.g. skills/web-access (default: all)" required: false permissions: contents: write pull-requests: write issues: write models: read concurrency: # Per-PR group (pull_request_target's github.ref is the base branch, which # would otherwise serialize/cancel unrelated PRs). group: i18n-translate-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: translate: # Don't run on the bot's own translation pushes to avoid loops if: github.actor != 'desirecore-bot' && github.actor != 'desirecore-bot[bot]' runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout trusted base uses: actions/checkout@v4 with: # No `ref:` on purpose — for pull_request_target this is the BASE # branch (trusted scripts); for workflow_dispatch it's the chosen ref. token: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }} fetch-depth: 0 - name: Overlay PR content (data only) if: github.event_name == 'pull_request_target' env: PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -e # The merge ref is maintained by GitHub in the BASE repo (fork branches # live elsewhere) and already contains PR + base merged. if ! git fetch origin "refs/pull/${PR_NUMBER}/merge"; then echo "::error::Could not fetch refs/pull/${PR_NUMBER}/merge — the PR likely has merge conflicts. Resolve them and the check will re-run." exit 1 fi # Replace data files with the PR's merged state. Trusted code # (scripts/, .github/) intentionally stays at the base version. rm -rf skills manifest.json categories.json git checkout FETCH_HEAD -- skills manifest.json categories.json # Defense in depth: a malicious PR could add a symlink named SKILL.md # pointing outside the tree; translate.py would read through it and # could leak runner file content into a committed "translation". find skills -type l -print -delete # The root data files are untrusted too — reject symlinked variants # instead of letting trusted scripts read through them. for f in manifest.json categories.json; do if [ -L "$f" ]; then echo "::error::$f in the PR is a symlink; refusing to process." exit 1 fi done git add -A git -c user.name=ci -c user.email=ci@invalid commit --allow-empty -m "overlay: PR #${PR_NUMBER} data files" - name: Detect relevant changes id: changes run: | # GitHub Actions default shell already runs with -eo pipefail; we set # it explicitly so the `|| true` workaround on grep below is unambiguous. set -eo pipefail # workflow_dispatch: full scan (skill input handled later if provided) if [ "${{ github.event_name }}" != "pull_request_target" ]; then echo "relevant=true" >> "$GITHUB_OUTPUT" echo "skill_paths=" >> "$GITHUB_OUTPUT" exit 0 fi # The overlay commit (HEAD) contains exactly the PR's effective # changes to skills/ + manifest.json + categories.json vs base. changed=$(git diff --name-only HEAD~1 HEAD) # If manifest.json or categories.json changed, fall back to full scan # (these affect i18n fallback chain / supportedLocales globally). if printf '%s\n' "$changed" | grep -qE '^(manifest\.json|categories\.json)$'; then echo "relevant=true" >> "$GITHUB_OUTPUT" echo "skill_paths=" >> "$GITHUB_OUTPUT" exit 0 fi # Otherwise: extract unique skill directories touched by SKILL*.md edits. # Grep enforces: # - skill name: lowercase ASCII letters, digits, hyphens # (no leading/trailing hyphen — anchored with [a-z0-9]) # - locale tag (BCP-47 subset): 2-3 lowercase letters + optional -RR # awk enforces the rest of the schema's name constraints (no consecutive # hyphens, no reserved names) as defense-in-depth — those would already # have been blocked upstream by validate-i18n.py / schema validation. # The brace group with `|| true` turns grep's "no match" (exit 1) into # success so pipefail doesn't kill the step when this PR is unrelated. # Strict filtering also sanitizes input for the $GITHUB_OUTPUT write # below (any path failing the shape is dropped). skill_paths=$(printf '%s\n' "$changed" \ | { grep -E '^skills/[a-z0-9]([a-z0-9-]*[a-z0-9])?/SKILL(\.[a-z]{2,3}(-[A-Z]{2})?)?\.md$' || true; } \ | awk -F/ ' $2 !~ /--/ && $2 != "anthropic" && $2 != "claude" { print $1 "/" $2 }' \ | sort -u \ | tr '\n' ' ' \ | sed 's/ $//') if [ -n "$skill_paths" ]; then echo "relevant=true" >> "$GITHUB_OUTPUT" echo "skill_paths=$skill_paths" >> "$GITHUB_OUTPUT" else echo "relevant=false" >> "$GITHUB_OUTPUT" echo "skill_paths=" >> "$GITHUB_OUTPUT" fi - name: Install uv if: steps.changes.outputs.relevant == 'true' uses: astral-sh/setup-uv@v3 - name: Check for stale translations id: precheck if: steps.changes.outputs.relevant == 'true' env: SKILL_PATHS: ${{ steps.changes.outputs.skill_paths }} INPUT_SKILL: ${{ github.event.inputs.skill }} run: | set +e ARGS=(--check) if [ -n "$INPUT_SKILL" ]; then # workflow_dispatch with explicit skill input takes precedence ARGS+=("$INPUT_SKILL") elif [ -n "$SKILL_PATHS" ]; then # pull_request_target: read space-separated paths into an array to # avoid word-splitting / glob expansion surprises. read -r -a SKILL_ARR <<<"$SKILL_PATHS" ARGS+=("${SKILL_ARR[@]}") fi uv run --quiet scripts/i18n/translate.py "${ARGS[@]}" rc=$? if [ $rc -eq 0 ]; then echo "stale=false" >> "$GITHUB_OUTPUT" else echo "stale=true" >> "$GITHUB_OUTPUT" fi exit 0 - name: Translate stale locales if: steps.changes.outputs.relevant == 'true' && steps.precheck.outputs.stale == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} TRANSLATE_API_KEY: ${{ secrets.TRANSLATE_API_KEY }} TRANSLATE_ENDPOINT: ${{ secrets.TRANSLATE_ENDPOINT || vars.TRANSLATE_ENDPOINT }} TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }} TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }} SKILL_PATHS: ${{ steps.changes.outputs.skill_paths }} INPUT_SKILL: ${{ github.event.inputs.skill }} INPUT_TARGET: ${{ github.event.inputs.target_locale }} run: | set -e ARGS=() if [ -n "$INPUT_SKILL" ]; then # workflow_dispatch input takes precedence (single specific skill) ARGS+=("$INPUT_SKILL") elif [ -n "$SKILL_PATHS" ]; then # pull_request_target: only translate skills touched by this PR read -r -a SKILL_ARR <<<"$SKILL_PATHS" ARGS+=("${SKILL_ARR[@]}") fi if [ -n "$INPUT_TARGET" ]; then ARGS+=(--target "$INPUT_TARGET") fi uv run --quiet scripts/i18n/translate.py "${ARGS[@]}" - name: Validate after translation if: steps.changes.outputs.relevant == 'true' && steps.precheck.outputs.stale == 'true' run: uv run --quiet scripts/i18n/validate-i18n.py - name: Detect changes id: diff if: steps.changes.outputs.relevant == 'true' && steps.precheck.outputs.stale == 'true' run: | if git diff --quiet; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" git diff --stat fi - name: Push translations to PR branch if: steps.diff.outputs.changed == 'true' && github.event_name == 'pull_request_target' env: HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} HEAD_REF: ${{ github.event.pull_request.head.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} BOT_TOKEN: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }} TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }} TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }} run: | set -e changed_files=$(git diff --name-only) # Enforce the security model: translate.py only ever writes # skills//SKILL*.md. Anything else in the diff means an # unexpected write — abort rather than push it to a contributor branch. unexpected=$(printf '%s\n' "$changed_files" \ | { grep -vE '^skills/[a-z0-9]([a-z0-9-]*[a-z0-9])?/SKILL(\.[a-z]{2,3}(-[A-Z]{2})?)?\.md$' || true; }) if [ -n "$unexpected" ]; then echo "::error::Refusing to push non-translation changes to the PR branch:" printf '%s\n' "$unexpected" exit 1 fi # The working tree is base + PR overlay + translations; the PR branch # itself may live in a fork. Apply only the translation-generated # files onto a checkout of the actual PR head and push that. git remote add prhead "https://x-access-token:${BOT_TOKEN}@github.com/${HEAD_REPO}.git" git fetch prhead "refs/heads/${HEAD_REF}" if [ "$(git rev-parse FETCH_HEAD)" != "$HEAD_SHA" ]; then echo "::notice::PR head moved since this run started; skipping push — the newer run will translate." exit 0 fi git worktree add --detach ../prpush FETCH_HEAD while IFS= read -r f; do mkdir -p "../prpush/$(dirname "$f")" cp "$f" "../prpush/$f" done <<<"$changed_files" cd ../prpush git config user.name "desirecore-bot" git config user.email "bot@desirecore.net" git add -A # No [skip ci] on purpose: required checks (validate/translate) must # re-run and report on the translated commit, or the PR becomes # unmergeable. Loop prevention is the job-level bot-actor guard. git commit -m "chore(i18n): auto-translate skills" \ -m "Generated by scripts/i18n/translate.py via i18n-translate workflow." \ -m "Backend: ${TRANSLATE_BACKEND} Model: ${TRANSLATE_MODEL}" if ! git push prhead HEAD:"refs/heads/${HEAD_REF}"; then echo "::error::Could not push translations to ${HEAD_REPO}:${HEAD_REF}. For fork PRs, enable 'Allow edits by maintainers' on the PR, or run 'uv run scripts/i18n/translate.py' locally and commit the generated SKILL..md files." exit 1 fi - name: Commit & push translations if: steps.diff.outputs.changed == 'true' && github.event_name != 'pull_request_target' run: | git config user.name "desirecore-bot" git config user.email "bot@desirecore.net" git add -A git commit -m "chore(i18n): auto-translate skills" \ -m "Generated by scripts/i18n/translate.py via i18n-translate workflow." \ -m "Backend: ${TRANSLATE_BACKEND:-github} Model: ${TRANSLATE_MODEL:-openai/gpt-5-mini}" git push env: TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }} TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }} - name: Comment on PR if: steps.diff.outputs.changed == 'true' && github.event_name == 'pull_request_target' uses: actions/github-script@v7 with: github-token: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }} script: | const backend = process.env.TRANSLATE_BACKEND || 'github'; const model = process.env.TRANSLATE_MODEL || 'openai/gpt-5-mini'; const body = [ '🌐 **i18n auto-translation pushed**', '', 'New machine-translated content was added to this PR by `scripts/i18n/translate.py`.', '', '**Please review the translated strings** before merging:', '- Check terminology against `scripts/i18n/glossary.json`', '- Verify Markdown structure (headings/tables/code fences) is preserved', '- If you edit a translation, set `metadata.i18n..translated_by: human` to lock it.', '', `Backend: \`${backend}\` Model: \`${model}\``, ].join('\n'); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body, }); env: TRANSLATE_BACKEND: ${{ vars.TRANSLATE_BACKEND || 'github' }} TRANSLATE_MODEL: ${{ vars.TRANSLATE_MODEL || 'openai/gpt-5-mini' }} - name: Label on translation failure if: failure() && github.event_name == 'pull_request_target' uses: actions/github-script@v7 with: github-token: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }} script: | await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, labels: ['i18n-translation-failed'], }); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: [ '🌐 **i18n translation check failed**', '', 'Please inspect the workflow logs for the exact failing skill/locale.', '', 'Common causes:', '- A machine-translated locale could not be regenerated.', '- A `translated_by: human` locale has source hash drift and must be manually synchronized.', '', 'After manually reviewing a human-locked translation, update its `source_hash` to match the current source content.', ].join('\n'), }); - name: Skip notice if: steps.changes.outputs.relevant != 'true' run: echo "No i18n-relevant changes; translation skipped."