docs/.github/workflows/translate-locale-reusable.yml
2026-05-06 20:46:05 +01:00

404 lines
15 KiB
YAML

name: Translate Locale Reusable
on:
workflow_call:
inputs:
locale:
required: true
type: string
locale_slug:
required: true
type: string
publish_ref:
required: true
type: string
source_sha:
required: true
type: string
mode:
required: true
type: string
permissions:
contents: read
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:
ref: ${{ inputs.publish_ref }}
fetch-depth: 0
- name: Read source metadata
id: meta
env:
SOURCE_SHA: ${{ inputs.source_sha }}
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}`);
}
if (data.sha !== process.env.SOURCE_SHA) {
throw new Error(`publish ref source ${data.sha} does not match requested ${process.env.SOURCE_SHA}`);
}
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 }}
MODE: ${{ inputs.mode }}
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"]
mode = os.environ["MODE"]
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(".i18n/"):
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 mode == "full" or 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
id: translate_docs
if: steps.stale.outputs.skip != 'true' && steps.pending.outputs.pending_count != '0'
continue-on-error: true
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 \
--allow-partial \
--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' && steps.translate_docs.outcome == 'success'
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' && steps.translate_docs.outcome == 'success'
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
id: mdx_repair
if: steps.stale.outputs.skip != 'true' && steps.translate_docs.outcome == 'success' && steps.mdx_check.outcome == 'failure'
continue-on-error: true
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
id: mdx_scope
if: steps.stale.outputs.skip != 'true' && steps.translate_docs.outcome == 'success' && steps.mdx_check.outcome == 'failure'
continue-on-error: true
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
id: mdx_recheck
if: steps.stale.outputs.skip != 'true' && steps.translate_docs.outcome == 'success' && steps.mdx_check.outcome == 'failure' && steps.mdx_repair.outcome == 'success' && steps.mdx_scope.outcome == 'success'
continue-on-error: true
env:
LOCALE: ${{ inputs.locale }}
run: |
node .openclaw-sync/check-docs-mdx.mjs "docs/${LOCALE}" \
--json-out ".openclaw-sync/mdx/${LOCALE}.json"
- name: Prepare locale artifact
if: steps.stale.outputs.skip != 'true'
env:
LOCALE: ${{ inputs.locale }}
LOCALE_SLUG: ${{ inputs.locale_slug }}
SOURCE_SHA: ${{ steps.meta.outputs.sha }}
MODE: ${{ inputs.mode }}
PENDING_COUNT: ${{ steps.pending.outputs.pending_count || '0' }}
ALL_COUNT: ${{ steps.pending.outputs.all_count || '0' }}
TRANSLATE_OUTCOME: ${{ steps.translate_docs.outcome || 'skipped' }}
MDX_CHECK_OUTCOME: ${{ steps.mdx_check.outcome || 'skipped' }}
MDX_REPAIR_OUTCOME: ${{ steps.mdx_repair.outcome || 'skipped' }}
MDX_SCOPE_OUTCOME: ${{ steps.mdx_scope.outcome || 'skipped' }}
MDX_RECHECK_OUTCOME: ${{ steps.mdx_recheck.outcome || 'skipped' }}
run: |
set -euo pipefail
artifact_dir=".openclaw-sync/artifacts/${LOCALE_SLUG}"
payload_dir="${artifact_dir}/payload"
mkdir -p "${payload_dir}"
failed_reason=""
if [ "${TRANSLATE_OUTCOME}" = "failure" ]; then
failed_reason="translation failed"
elif [ "${MDX_CHECK_OUTCOME}" = "failure" ]; then
if [ "${MDX_REPAIR_OUTCOME}" = "failure" ]; then
failed_reason="mdx repair failed"
elif [ "${MDX_SCOPE_OUTCOME}" = "failure" ]; then
failed_reason="mdx repair scope failed"
elif [ "${MDX_RECHECK_OUTCOME}" != "success" ]; then
failed_reason="mdx repair failed"
fi
fi
export FAILED_REASON="${failed_reason}"
if [ -n "${failed_reason}" ]; then
: > "${artifact_dir}/changed-files.txt"
: > "${artifact_dir}/deleted-files.txt"
else
git diff --name-only --diff-filter=ACMRT -- "docs/${LOCALE}" "docs/.i18n/${LOCALE}.tm.jsonl" > "${artifact_dir}/changed-files.txt"
git ls-files --others --exclude-standard -- "docs/${LOCALE}" "docs/.i18n/${LOCALE}.tm.jsonl" >> "${artifact_dir}/changed-files.txt"
sort -u "${artifact_dir}/changed-files.txt" -o "${artifact_dir}/changed-files.txt"
git diff --name-only --diff-filter=D -- "docs/${LOCALE}" "docs/.i18n/${LOCALE}.tm.jsonl" > "${artifact_dir}/deleted-files.txt"
fi
while IFS= read -r file; do
[ -n "${file}" ] || continue
[ -f "${file}" ] || continue
mkdir -p "${payload_dir}/$(dirname "${file}")"
cp "${file}" "${payload_dir}/${file}"
done < "${artifact_dir}/changed-files.txt"
python - <<'PY'
import json
import os
from pathlib import Path
artifact_dir = Path(".openclaw-sync/artifacts") / os.environ["LOCALE_SLUG"]
changed = [line for line in (artifact_dir / "changed-files.txt").read_text().splitlines() if line.strip()]
deleted = [line for line in (artifact_dir / "deleted-files.txt").read_text().splitlines() if line.strip()]
metadata = {
"locale": os.environ["LOCALE"],
"locale_slug": os.environ["LOCALE_SLUG"],
"source_sha": os.environ["SOURCE_SHA"],
"mode": os.environ["MODE"],
"pending_count": int(os.environ["PENDING_COUNT"]),
"all_count": int(os.environ["ALL_COUNT"]),
"changed_count": len(changed),
"deleted_count": len(deleted),
"translate_outcome": os.environ["TRANSLATE_OUTCOME"],
"mdx_check_outcome": os.environ["MDX_CHECK_OUTCOME"],
"mdx_repair_outcome": os.environ["MDX_REPAIR_OUTCOME"],
"mdx_scope_outcome": os.environ["MDX_SCOPE_OUTCOME"],
"mdx_recheck_outcome": os.environ["MDX_RECHECK_OUTCOME"],
"failed_reason": os.environ["FAILED_REASON"],
}
(artifact_dir / "metadata.json").write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n")
print(json.dumps(metadata, indent=2, sort_keys=True))
PY
- name: Upload locale artifact
if: steps.stale.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: i18n-${{ inputs.locale_slug }}-${{ inputs.source_sha }}
path: .openclaw-sync/artifacts/${{ inputs.locale_slug }}
retention-days: 7