Files
market/.github/workflows/i18n-translate.yml
Yige 7db9f6e925 fix(ci): i18n-translate 支持 fork PR (#38)
## 问题

PR #36(来自 fork)触发 `i18n Auto-Translate` workflow 失败,两处报错:

1. **checkout 失败**:`ref: github.event.pull_request.head.ref` 未指定
`repository:`,fork 的 head 分支在主仓库不存在 → `A branch or tag with the name
'add-mattpocock-skills' could not be found`
2. **失败打标签 403**:fork PR 的 `GITHUB_TOKEN` 被强制只读、拿不到
`DESIRECORE_BOT_TOKEN` secret → `Resource not accessible by integration`

## 修复

- **checkout**:同仓库 PR 仍检出 head 分支(保留自动回推翻译);fork PR 改用 `github.ref`(即
`refs/pull/N/merge`,base 仓库中一定存在)
- **新增 "Fail fork PR with stale translations" 步骤**:fork PR 缺翻译时在消耗模型
token 之前直接失败,并提示贡献者本地运行 `uv run scripts/i18n/translate.py` 补交翻译
- **Commit & push / Label on failure** 两个写操作步骤加同仓库判断,fork PR 跳过

同仓库 PR 与 workflow_dispatch 行为完全不变。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:41:13 +08:00

296 lines
14 KiB
YAML

name: i18n Auto-Translate
# Uses GitHub Models inference API (https://models.github.ai/inference) with the
# repository's GITHUB_TOKEN. Requires `models: read` permission. Default model is
# openai/gpt-5-mini (a fast/cheap GPT-5 in the catalog); override via repository variable
# TRANSLATE_MODEL (e.g. openai/gpt-5-nano for cheaper, openai/gpt-5 for flagship).
#
# Optional: to use Anthropic Claude directly, add a repo secret ANTHROPIC_API_KEY,
# then set repository variable TRANSLATE_BACKEND=anthropic and TRANSLATE_MODEL to
# a Claude model id (e.g. claude-sonnet-4-6). Claude is NOT in the GitHub Models
# catalog as of 2026-05.
#
# Scope: on pull_request 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.
#
# Fork PRs: GITHUB_TOKEN is read-only and repo secrets are unavailable, so we
# can't push translations, comment, or label. We check out the PR merge ref
# (refs/pull/N/merge — the head branch only exists in the fork), run the same
# stale-translation check, and fail with instructions for the contributor to
# run scripts/i18n/translate.py locally if translations are missing.
on:
pull_request:
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
models: read
concurrency:
group: i18n-translate-${{ github.ref }}
cancel-in-progress: true
jobs:
translate:
# Don't run on bot's own commits to avoid loops
if: github.actor != 'desirecore-bot[bot]' && !contains(github.event.head_commit.message, '[skip ci]')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
# Same-repo PR: check out the head branch so translations can be
# pushed back to it. Fork PR: the head branch doesn't exist in this
# repo, so check out the PR merge ref instead (github.ref =
# refs/pull/N/merge on pull_request events) — read-only checks still
# run, write steps are skipped below. workflow_dispatch: github.ref.
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref || github.ref }}
token: ${{ secrets.DESIRECORE_BOT_TOKEN || secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Detect relevant changes
id: changes
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
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" ]; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
echo "skill_paths=" >> "$GITHUB_OUTPUT"
exit 0
fi
# Ensure BASE_SHA is locally reachable; if we can't fetch it, fall back
# to a full scan rather than silently producing an empty diff that would
# mis-classify a real i18n PR as relevant=false.
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
if ! git fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null; then
echo "::warning::failed to fetch base SHA ${BASE_SHA}; falling back to full scan"
echo "relevant=true" >> "$GITHUB_OUTPUT"
echo "skill_paths=" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
# `A...B` (triple-dot) needs merge-base(A,B) in history. With
# actions/checkout@v4 + fetch-depth: 0 it normally is, but if the base
# branch advanced after the PR was opened and the merge-base isn't
# locally reachable, fall back to a full scan instead of silently
# producing a wrong diff.
if ! merge_base=$(git merge-base "${BASE_SHA}" "${HEAD_SHA}" 2>/dev/null); then
echo "::warning::could not compute merge-base for ${BASE_SHA}...${HEAD_SHA}; falling back to full scan"
echo "relevant=true" >> "$GITHUB_OUTPUT"
echo "skill_paths=" >> "$GITHUB_OUTPUT"
exit 0
fi
changed=$(git diff --name-only "${merge_base}" "${HEAD_SHA}")
# 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: 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: Fail fork PR with stale translations
# Fork PRs get a read-only GITHUB_TOKEN and no repo secrets, so the
# workflow can't push generated translations back to the PR branch.
# Fail here (before spending model tokens) with instructions instead.
if: >-
steps.changes.outputs.relevant == 'true' &&
steps.precheck.outputs.stale == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::The i18n pre-check failed for this fork PR (stale/missing translations, or a check error — see the 'Check for stale translations' step logs above), and the workflow cannot push auto-generated translations to a fork. Please run 'uv run scripts/i18n/translate.py --check' locally to see what's wrong, then 'uv run scripts/i18n/translate.py' and commit the generated SKILL.<locale>.md files to your branch."
exit 1
- 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_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: 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: Commit & push translations
if: >-
steps.diff.outputs.changed == 'true' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
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 [skip ci]" \
-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'
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.<locale>.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
# Skip on fork PRs: GITHUB_TOKEN is read-only there and repo secrets
# are unavailable, so addLabels would 403 ("Resource not accessible
# by integration"). The failed check itself is the signal.
if: >-
failure() && github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
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'],
});
- name: Skip notice
if: steps.changes.outputs.relevant != 'true'
run: echo "No i18n-relevant changes; translation skipped."