-
Notifications
You must be signed in to change notification settings - Fork 598
workflow: sync multi-language docs from changed source docs on main #3626
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
fc77ef4
54d2ef4
dda0785
c6c6d40
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,238 @@ | ||
| name: Sync translated docs | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| paths: | ||
| - docs/src/**/*.md | ||
|
|
||
| permissions: | ||
| contents: write | ||
| models: read | ||
|
|
||
| jobs: | ||
| sync-docs: | ||
| if: github.actor != 'github-actions[bot]' | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v6 | ||
| with: | ||
| fetch-depth: 0 | ||
|
|
||
| - name: Setup Python | ||
| uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: '3.13' | ||
|
|
||
| - name: Sync translations | ||
| env: | ||
| BEFORE_SHA: ${{ github.event.before }} | ||
| AFTER_SHA: ${{ github.sha }} | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| MODEL_ENDPOINT: https://models.inference.ai.azure.com/chat/completions | ||
| MODEL_NAME: gpt-4.1-mini | ||
| run: | | ||
| python - <<'PY' | ||
| import json | ||
| import os | ||
| import pathlib | ||
| import re | ||
| import subprocess | ||
| import urllib.error | ||
| import urllib.request | ||
|
|
||
| repo = pathlib.Path.cwd() | ||
| docs_root = repo / "docs" / "src" | ||
|
|
||
| # Keep folder names aligned with existing docs paths. | ||
| language_dirs = [ | ||
| "de", | ||
| "en", | ||
| "es", | ||
| "fr", | ||
| "id", | ||
| "it", | ||
| "jp", | ||
| "ko-KR", | ||
| "pt-BR", | ||
| "ru", | ||
| "vi-VN", | ||
| "zh-TW", | ||
| ] | ||
| locale_to_dir = {"zh-CN": ""} | ||
| locale_to_dir.update({lang: lang for lang in language_dirs}) | ||
| locale_names = { | ||
| "zh-CN": "Simplified Chinese", | ||
| "de": "German", | ||
| "en": "English", | ||
| "es": "Spanish", | ||
| "fr": "French", | ||
| "id": "Indonesian", | ||
| "it": "Italian", | ||
| "jp": "Japanese", | ||
| "ko-KR": "Korean", | ||
| "pt-BR": "Brazilian Portuguese", | ||
| "ru": "Russian", | ||
| "vi-VN": "Vietnamese", | ||
| "zh-TW": "Traditional Chinese", | ||
| } | ||
|
|
||
| before_sha = os.environ.get("BEFORE_SHA", "") | ||
| after_sha = os.environ.get("AFTER_SHA", "HEAD") | ||
|
|
||
| if not before_sha or re.fullmatch(r"0+", before_sha): | ||
| before_sha = "HEAD~1" | ||
|
|
||
| diff_cmd = ["git", "diff", "--name-only", f"{before_sha}..{after_sha}"] | ||
| diff_output = subprocess.check_output(diff_cmd, text=True) | ||
| changed_files = [line.strip() for line in diff_output.splitlines() if line.strip()] | ||
|
|
||
| if not changed_files: | ||
| print("No changed files.") | ||
| raise SystemExit(0) | ||
|
|
||
| def detect_locale_and_relative(path: str): | ||
| if not path.startswith("docs/src/") or not path.endswith(".md"): | ||
| return None, None | ||
| relative = path[len("docs/src/"):] | ||
| if relative.startswith(".vuepress/"): | ||
| return None, None | ||
| for lang in language_dirs: | ||
| prefix = f"{lang}/" | ||
| if relative.startswith(prefix): | ||
| return lang, relative[len(prefix):] | ||
| return "zh-CN", relative | ||
|
|
||
| def render_path(locale: str, relative: str): | ||
| lang_dir = locale_to_dir[locale] | ||
| if lang_dir: | ||
| return docs_root / lang_dir / relative | ||
| return docs_root / relative | ||
|
|
||
| token = os.environ["GITHUB_TOKEN"] | ||
| endpoint = os.environ["MODEL_ENDPOINT"] | ||
| model = os.environ["MODEL_NAME"] | ||
|
|
||
| updates = {} | ||
|
|
||
| for changed in changed_files: | ||
| source_locale, relative_path = detect_locale_and_relative(changed) | ||
| if not source_locale or not relative_path: | ||
| continue | ||
|
|
||
| source_path = render_path(source_locale, relative_path) | ||
| if not source_path.exists(): | ||
| continue | ||
|
|
||
| source_content = source_path.read_text(encoding="utf-8") | ||
| for target_locale in locale_to_dir: | ||
| if target_locale == source_locale: | ||
| continue | ||
| target_path = render_path(target_locale, relative_path) | ||
| if not target_path.exists(): | ||
| continue | ||
| updates[target_path] = (source_locale, target_locale, source_content, relative_path) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When one push intentionally updates the same document in multiple locales, each source schedules translations for every other locale and this assignment silently replaces any earlier scheduled value for the same target. This is a real repository workflow: for example, commit Useful? React with 👍 / 👎.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot 帮忙根据建议优化下
lizheming marked this conversation as resolved.
Outdated
|
||
|
|
||
| if not updates: | ||
| print("No translation targets found.") | ||
| raise SystemExit(0) | ||
|
|
||
| def translate( | ||
| source_locale: str, | ||
| target_locale: str, | ||
| content: str, | ||
| context: str, | ||
| keep_trailing_newline: bool, | ||
| ): | ||
| source_name = locale_names[source_locale] | ||
| target_name = locale_names[target_locale] | ||
| prompt = ( | ||
| f"Translate this Markdown document from {source_name} to {target_name}. " | ||
| "Preserve heading structure, frontmatter, links, code blocks, inline code, " | ||
| "HTML tags, and markdown formatting. Return translated markdown only." | ||
| ) | ||
| body = { | ||
| "model": model, | ||
| "messages": [ | ||
| {"role": "system", "content": "You are a professional technical documentation translator."}, | ||
| { | ||
| "role": "user", | ||
| "content": f"{prompt}\n\n--- BEGIN MARKDOWN ---\n{content}\n--- END MARKDOWN ---", | ||
| }, | ||
| ], | ||
| "temperature": 0.2, | ||
| } | ||
|
|
||
| request = urllib.request.Request( | ||
| endpoint, | ||
| data=json.dumps(body).encode("utf-8"), | ||
| headers={ | ||
| "Content-Type": "application/json", | ||
| "Authorization": "Bearer " + token, | ||
| }, | ||
| method="POST", | ||
| ) | ||
| try: | ||
| with urllib.request.urlopen(request) as response: | ||
| payload = json.loads(response.read().decode("utf-8")) | ||
| except urllib.error.HTTPError as error: | ||
| body = error.read().decode("utf-8", errors="replace") | ||
| raise RuntimeError( | ||
| f"Translation API HTTP {error.code} for {context}: {body}" | ||
| ) from error | ||
| except urllib.error.URLError as error: | ||
| raise RuntimeError( | ||
| f"Translation API network error for {context}: {error.reason}" | ||
| ) from error | ||
|
|
||
| choices = payload.get("choices") if isinstance(payload, dict) else None | ||
| if not choices or not isinstance(choices, list): | ||
| raise RuntimeError(f"Translation API returned no choices for {context}: {payload}") | ||
| first_choice = choices[0] | ||
| message = first_choice.get("message") if isinstance(first_choice, dict) else None | ||
| translated = message.get("content") if isinstance(message, dict) else None | ||
| if not isinstance(translated, str) or not translated.strip(): | ||
| raise RuntimeError( | ||
| f"Translation API returned empty content for {context}: {payload}" | ||
| ) | ||
| translated = translated.rstrip() | ||
| if keep_trailing_newline: | ||
| return translated + "\n" | ||
| return translated | ||
|
|
||
| updated_count = 0 | ||
| for target_path, (source_locale, target_locale, source_content, rel_path) in sorted(updates.items()): | ||
| print(f"Translating {rel_path}: {source_locale} -> {target_locale}") | ||
| translated_content = translate( | ||
| source_locale, | ||
| target_locale, | ||
| source_content, | ||
| f"{rel_path} ({source_locale}->{target_locale})", | ||
| source_content.endswith("\n"), | ||
| ) | ||
| current_content = target_path.read_text(encoding="utf-8") | ||
| if current_content != translated_content: | ||
| target_path.write_text(translated_content, encoding="utf-8") | ||
| updated_count += 1 | ||
|
|
||
| if updated_count == 0: | ||
| print("No file content changed after translation.") | ||
| raise SystemExit(0) | ||
|
|
||
| print(f"Updated {updated_count} translated doc files.") | ||
| PY | ||
|
|
||
| - name: Commit changes | ||
| run: | | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||
| git add docs/src | ||
| if git diff --cached --quiet; then | ||
| echo "No changes to commit" | ||
| exit 0 | ||
| fi | ||
| git commit -m "docs: sync docs translations [skip ci]" | ||
| git push | ||
|
Comment on lines
+249
to
+250
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in the latest commit by adding a |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every docs push reaches an endpoint that no longer serves GitHub Models requests. GitHub's deprecation notice states that support for
https://models.inference.ai.azure.comwas removed on October 17, 2025, and the current REST API documentation useshttps://models.github.ai/inference/chat/completionswith publisher-qualified model IDs such asopenai/gpt-4.1. Update both values here, including changing the model to its catalog ID such asopenai/gpt-4.1-mini; otherwise the newly added workflow fails before it can synchronize any translation.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot 帮我根据这个建议修改当前 PR 内容