404 lines
15 KiB
YAML
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
|