name: Translate Locale Reusable on: workflow_call: inputs: locale: required: true type: string locale_slug: required: true type: string permissions: contents: write jobs: translate: name: Translate ${{ inputs.locale }} runs-on: ubuntu-latest concurrency: group: translate-${{ inputs.locale_slug }} cancel-in-progress: false steps: - name: Checkout publish repo uses: actions/checkout@v6 with: fetch-depth: 0 - name: Read source metadata id: meta run: | node - <<'NODE' const fs = require("node:fs"); const path = ".openclaw-sync/source.json"; const data = JSON.parse(fs.readFileSync(path, "utf8")); if (!data.repository || !data.sha) { throw new Error(`invalid source metadata in ${path}`); } fs.appendFileSync(process.env.GITHUB_OUTPUT, `repository=${data.repository}\n`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `sha=${data.sha}\n`); NODE - name: Skip stale queued source id: stale env: SOURCE_SHA: ${{ steps.meta.outputs.sha }} run: | set -euo pipefail git fetch origin main:refs/remotes/origin/main remote_source_sha="$( git show refs/remotes/origin/main:.openclaw-sync/source.json 2>/dev/null \ | node -e 'const fs = require("node:fs"); try { const data = JSON.parse(fs.readFileSync(0, "utf8")); if (data.sha) process.stdout.write(data.sha); } catch {}' \ || true )" if [ -n "$remote_source_sha" ] && [ "$remote_source_sha" != "$SOURCE_SHA" ]; then echo "Skipping stale queued translation for ${SOURCE_SHA}; origin/main mirrors ${remote_source_sha}." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "skip=false" >> "$GITHUB_OUTPUT" - name: Checkout source repo if: steps.stale.outputs.skip != 'true' uses: actions/checkout@v6 with: repository: ${{ steps.meta.outputs.repository }} ref: ${{ steps.meta.outputs.sha }} path: source fetch-depth: 1 - name: Setup Node if: steps.stale.outputs.skip != 'true' uses: actions/setup-node@v6 with: node-version: 22 - name: Setup Go if: steps.stale.outputs.skip != 'true' uses: actions/setup-go@v6 with: go-version: "1.25" - name: Install Codex CLI if: steps.stale.outputs.skip != 'true' run: npm install -g @openai/codex@0.125.0 - name: Prune stale locale pages if: steps.stale.outputs.skip != 'true' env: LOCALE: ${{ inputs.locale }} run: | python - <<'PY' import os from pathlib import Path root = Path("docs") locale_root = root / os.environ["LOCALE"] if not locale_root.exists(): raise SystemExit(0) for path in sorted(locale_root.rglob("*"), reverse=True): if path.is_dir(): if not any(path.iterdir()): path.rmdir() continue rel = path.relative_to(locale_root) source = root / rel if not source.exists(): path.unlink() for path in sorted(locale_root.rglob("*"), reverse=True): if path.is_dir() and not any(path.iterdir()): path.rmdir() PY - name: Build pending docs file list id: pending if: steps.stale.outputs.skip != 'true' env: LOCALE: ${{ inputs.locale }} LOCALE_SLUG: ${{ inputs.locale_slug }} run: | python - <<'PY' import hashlib import os import re from pathlib import Path source_hash_re = re.compile(r'^x-i18n:\n(?:[ \t]+.*\n)*?[ \t]+source_hash: ([0-9a-f]{64})$', re.M) locale_dir_re = re.compile(r"^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$") def is_locale_dir(path: Path) -> bool: return ( path.is_dir() and locale_dir_re.match(path.name) and (path / ".i18n" / "README.md").exists() ) locale_dirs = { path.name for path in Path("docs").iterdir() if is_locale_dir(path) } locale = os.environ["LOCALE"] pending_path = Path(".openclaw-sync") / f"docs-i18n-{os.environ['LOCALE_SLUG']}.txt" def stored_source_hash(path: Path) -> str: if not path.exists(): return "" text = path.read_text(encoding="utf-8", errors="ignore") match = source_hash_re.search(text) if not match: return "" return match.group(1).strip() all_files = [] pending_files = [] for path in Path("docs").rglob("*"): if not path.is_file(): continue if path.suffix.lower() not in {".md", ".mdx"}: continue rel = path.relative_to("docs") parts = rel.parts if parts and parts[0] in locale_dirs: continue if rel.as_posix().startswith(".generated/"): continue all_files.append(str(path.resolve())) locale_path = Path("docs") / locale / rel source_hash = hashlib.sha256(path.read_bytes()).hexdigest() if stored_source_hash(locale_path) != source_hash: pending_files.append(str(path.resolve())) pending_path.parent.mkdir(exist_ok=True) pending_path.write_text("\n".join(pending_files) + ("\n" if pending_files else "")) print(f"all_docs={len(all_files)} pending_docs={len(pending_files)}") with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: fh.write(f"all_count={len(all_files)}\n") fh.write(f"pending_count={len(pending_files)}\n") PY - name: Translate changed docs into locale if: steps.stale.outputs.skip != 'true' && steps.pending.outputs.pending_count != '0' env: LOCALE: ${{ inputs.locale }} LOCALE_SLUG: ${{ inputs.locale_slug }} OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }} OPENCLAW_DOCS_I18N_PROVIDER: openai OPENCLAW_DOCS_I18N_MODEL: gpt-5.5 OPENCLAW_DOCS_I18N_PROMPT_TIMEOUT: 10m run: | pending_file=".openclaw-sync/docs-i18n-${LOCALE_SLUG}.txt" if [ ! -s "${pending_file}" ]; then echo "No docs files found." exit 0 fi mapfile -t DOC_FILES < "${pending_file}" attempt=1 max_attempts=5 while [ "$attempt" -le "$max_attempts" ]; do echo "docs-i18n attempt $attempt/$max_attempts" if ( cd source/scripts/docs-i18n go run . \ --docs "$GITHUB_WORKSPACE/docs" \ --lang "${LOCALE}" \ --src en \ --mode doc \ --thinking xhigh \ --parallel 8 \ "${DOC_FILES[@]}" ); then exit 0 fi if [ "$attempt" -eq "$max_attempts" ]; then echo "docs-i18n failed after $max_attempts attempts" exit 1 fi attempt=$((attempt + 1)) sleep 5 done - name: Install docs MDX checker dependency if: steps.stale.outputs.skip != 'true' && steps.pending.outputs.pending_count != '0' run: npm install --no-save --package-lock=false @mdx-js/mdx@3.1.1 - name: Check translated MDX id: mdx_check if: steps.stale.outputs.skip != 'true' && steps.pending.outputs.pending_count != '0' continue-on-error: true env: LOCALE: ${{ inputs.locale }} run: | mkdir -p .openclaw-sync/mdx node .openclaw-sync/check-docs-mdx.mjs "docs/${LOCALE}" \ --json-out ".openclaw-sync/mdx/${LOCALE}.json" - name: Repair translated MDX if: steps.stale.outputs.skip != 'true' && steps.mdx_check.outcome == 'failure' uses: openai/codex-action@v1 env: LOCALE: ${{ inputs.locale }} with: openai-api-key: ${{ secrets.OPENCLAW_DOCS_AGENT_OPENAI_API_KEY || secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY }} prompt-file: .openclaw-sync/docs-mdx-repair.md model: gpt-5.5 effort: high sandbox: workspace-write safety-strategy: drop-sudo codex-args: '["--full-auto"]' - name: Enforce translated MDX repair scope if: steps.stale.outputs.skip != 'true' && steps.mdx_check.outcome == 'failure' env: LOCALE: ${{ inputs.locale }} run: | set -euo pipefail bad_paths="$( git diff --name-only | while IFS= read -r path; do case "$path" in "docs/${LOCALE}"/*|"docs/.i18n/${LOCALE}.tm.jsonl") ;; *) printf '%s\n' "$path" ;; esac done )" if [ -n "$bad_paths" ]; then echo "Docs MDX repair touched forbidden paths:" printf '%s\n' "$bad_paths" exit 1 fi untracked_locale="$(git ls-files --others --exclude-standard -- "docs/${LOCALE}")" if [ -n "$untracked_locale" ]; then echo "Docs MDX repair created untracked locale files; forbidden:" printf '%s\n' "$untracked_locale" exit 1 fi - name: Recheck translated MDX if: steps.stale.outputs.skip != 'true' && steps.mdx_check.outcome == 'failure' env: LOCALE: ${{ inputs.locale }} run: | node .openclaw-sync/check-docs-mdx.mjs "docs/${LOCALE}" \ --json-out ".openclaw-sync/mdx/${LOCALE}.json" - name: Commit locale refresh if: steps.stale.outputs.skip != 'true' env: LOCALE: ${{ inputs.locale }} SOURCE_SHA: ${{ steps.meta.outputs.sha }} run: | set -euo pipefail remote_source_sha() { git show refs/remotes/origin/main:.openclaw-sync/source.json 2>/dev/null \ | node -e 'const fs = require("node:fs"); try { const data = JSON.parse(fs.readFileSync(0, "utf8")); if (data.sha) process.stdout.write(data.sha); } catch {}' \ || true } skip_stale_source() { current_source_sha="$(remote_source_sha)" if [ -n "$current_source_sha" ] && [ "$current_source_sha" != "$SOURCE_SHA" ]; then echo "Skipping ${LOCALE} translation for stale source ${SOURCE_SHA}; origin/main has ${current_source_sha}." exit 0 fi } if [ -z "$(git status --porcelain --untracked-files=all -- "docs/${LOCALE}" "docs/.i18n/${LOCALE}.tm.jsonl")" ]; then echo "No ${LOCALE} translation changes." exit 0 fi if git fetch origin main:refs/remotes/origin/main; then skip_stale_source fi git config user.name "openclaw-docs-i18n[bot]" git config user.email "openclaw-docs-i18n[bot]@users.noreply.github.com" git add "docs/${LOCALE}" "docs/.i18n/${LOCALE}.tm.jsonl" git commit -m "chore(i18n): refresh ${LOCALE} translations" for attempt in 1 2 3 4 5; do if git fetch origin main:refs/remotes/origin/main; then skip_stale_source if git rebase origin/main && git push origin HEAD:main; then exit 0 fi git rebase --abort >/dev/null 2>&1 || true fi echo "Locale refresh attempt ${attempt} failed; retrying." sleep $((attempt * 2)) done echo "Failed to push ${LOCALE} translations after retries." exit 1