mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-06-06 05:50:41 +08:00
## 背景 PR #4 翻译失败的根因:`translate.py` 总扫描整个 `skills/` 目录,任何一个 skill 的 stale source_hash 都会触发翻译并占用 GitHub Models quota。一个大文件(如 manage-skills 14KB)撞免费 tier 8K input 上限 → 413 → 整个 workflow fail → ruleset 阻塞合并。 虽然 GitHub Models 已升级付费 quota(200K input 不会再 413),但 scope 收敛仍是更稳健的做法:单 PR token 消耗与改动量成正比,而不是与整个 repo 的 stale skill 数成正比。 ## 改动 - `detect-changes` step 在 `pull_request` 事件下提取本 PR 实际触及的 skill 目录(去重到 `skills/<name>` 粒度),输出到 `GITHUB_OUTPUT skill_paths` - `precheck`(--check)与 `Translate stale locales` 步骤把 `skill_paths` 作为位置参数传给 `translate.py`,仅检查/翻译相关 skill - `manifest.json` / `categories.json` 变动时回退到全扫描(这些影响 i18n fallback 链 / supportedLocales 全局语义) - `workflow_dispatch` 仍默认全扫描;其 `skill` 输入参数优先级最高 ## 验证 - 本地测试 detect-changes shell 提取逻辑:skill 文件 → 正确去重;manifest.json → 全扫描;无相关改动 → relevant=false - 本地 `translate.py --check skills/<name>` 正常工作 - 本 PR 自身仅触及 `.github/workflows/i18n-translate.yml`,detect-changes 应输出 `relevant=false`,translate 整体走 skip 分支 ## Test plan - [ ] CI 上 `validate` / `translate` / `wait-for-copilot-review` 全绿 - [ ] Copilot 评审通过 / conversation resolved - [ ] Squash merge
264 lines
11 KiB
YAML
264 lines
11 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.
|
|
|
|
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:
|
|
ref: ${{ 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: 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'
|
|
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
|
|
if: failure() && github.event_name == 'pull_request'
|
|
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."
|