docs/.github/workflows/translate-all.yml
2026-05-06 21:10:10 +01:00

512 lines
18 KiB
YAML

name: Translate All
on:
push:
branches:
- main
paths:
- ".openclaw-sync/source.json"
- "docs/**"
- "!docs/ar/**"
- "!docs/de/**"
- "!docs/es/**"
- "!docs/fa/**"
- "!docs/fr/**"
- "!docs/id/**"
- "!docs/it/**"
- "!docs/ja-JP/**"
- "!docs/ko/**"
- "!docs/nl/**"
- "!docs/pl/**"
- "!docs/pt-BR/**"
- "!docs/th/**"
- "!docs/tr/**"
- "!docs/uk/**"
- "!docs/vi/**"
- "!docs/zh-CN/**"
- "!docs/zh-TW/**"
- "!docs/.i18n/**"
- "docs/.i18n/glossary.*.json"
repository_dispatch:
types:
- translate-all-release
# Compatibility with the old source-repo release dispatcher. These events
# are collapsed by concurrency, but the source workflow should dispatch
# translate-all-release directly.
- translate-ar-release
- translate-de-release
- translate-es-release
- translate-fa-release
- translate-fr-release
- translate-id-release
- translate-it-release
- translate-ja-jp-release
- translate-ko-release
- translate-nl-release
- translate-pl-release
- translate-pt-br-release
- translate-th-release
- translate-tr-release
- translate-uk-release
- translate-vi-release
- translate-zh-cn-release
- translate-zh-tw-release
schedule:
- cron: "17 3 * * 0"
workflow_dispatch:
inputs:
mode:
description: Translation mode.
required: true
default: incremental
type: choice
options:
- incremental
- full
cooldown_seconds:
description: Seconds to wait for hot main to settle before translating.
required: true
default: "0"
type: string
permissions:
actions: write
contents: write
concurrency:
group: translate-all
cancel-in-progress: true
jobs:
prepare:
name: Debounce and pick source
runs-on: ubuntu-latest
outputs:
mode: ${{ steps.prepare.outputs.mode }}
publish_ref: ${{ steps.prepare.outputs.publish_ref }}
should_translate: ${{ steps.prepare.outputs.should_translate }}
source_repository: ${{ steps.prepare.outputs.source_repository }}
source_sha: ${{ steps.prepare.outputs.source_sha }}
steps:
- name: Check out
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Debounce main
id: prepare
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before || '' }}
AFTER_SHA: ${{ github.sha }}
REQUESTED_MODE: ${{ inputs.mode || github.event.client_payload.mode || '' }}
REQUESTED_COOLDOWN_SECONDS: ${{ inputs.cooldown_seconds || github.event.client_payload.cooldown_seconds || '' }}
DEFAULT_COOLDOWN_SECONDS: ${{ vars.OPENCLAW_DOCS_TRANSLATION_COOLDOWN_SECONDS || '3600' }}
DEFAULT_MAX_WAIT_SECONDS: ${{ vars.OPENCLAW_DOCS_TRANSLATION_MAX_WAIT_SECONDS || vars.OPENCLAW_DOCS_TRANSLATION_COOLDOWN_SECONDS || '3600' }}
run: |
set -euo pipefail
mode="${REQUESTED_MODE}"
if [ -z "${mode}" ]; then
if [ "${EVENT_NAME}" = "schedule" ]; then
mode="full"
else
mode="incremental"
fi
fi
case "${mode}" in
incremental|full) ;;
*)
echo "Invalid translation mode: ${mode}" >&2
exit 1
;;
esac
if [ "${EVENT_NAME}" = "push" ] && [ "${mode}" = "incremental" ] && [ -n "${BEFORE_SHA}" ]; then
if git diff --name-only "${BEFORE_SHA}" "${AFTER_SHA}" | grep -Eq '^docs/\.i18n/glossary\..*\.json$'; then
echo "Glossary changed; using full reconciliation mode."
mode="full"
fi
fi
cooldown="${REQUESTED_COOLDOWN_SECONDS}"
if [ -z "${cooldown}" ]; then
case "${EVENT_NAME}" in
push|repository_dispatch) cooldown="${DEFAULT_COOLDOWN_SECONDS}" ;;
*) cooldown="0" ;;
esac
fi
case "${cooldown}" in
''|*[!0-9]*)
echo "Invalid cooldown_seconds: ${cooldown}" >&2
exit 1
;;
esac
max_wait="${DEFAULT_MAX_WAIT_SECONDS}"
case "${max_wait}" in
''|*[!0-9]*)
echo "Invalid OPENCLAW_DOCS_TRANSLATION_MAX_WAIT_SECONDS: ${max_wait}" >&2
exit 1
;;
esac
if [ "${max_wait}" -lt "${cooldown}" ]; then
max_wait="${cooldown}"
fi
read_main_state() {
git fetch --quiet origin main:refs/remotes/origin/main
publish_ref="$(git rev-parse refs/remotes/origin/main)"
source_json="$(git show refs/remotes/origin/main:.openclaw-sync/source.json)"
source_repository="$(printf '%s' "${source_json}" | node -e 'const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(data.repository || "");')"
source_sha="$(printf '%s' "${source_json}" | node -e 'const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(data.sha || "");')"
if [ -z "${source_repository}" ] || [ -z "${source_sha}" ]; then
echo "Invalid .openclaw-sync/source.json on origin/main" >&2
exit 1
fi
}
elapsed=0
while :; do
read_main_state
before_ref="${publish_ref}"
before_source="${source_sha}"
if [ "${cooldown}" -eq 0 ]; then
break
fi
echo "Waiting ${cooldown}s for docs main to settle at ${before_ref} (${before_source})."
sleep "${cooldown}"
elapsed=$((elapsed + cooldown))
read_main_state
if [ "${publish_ref}" = "${before_ref}" ] && [ "${source_sha}" = "${before_source}" ]; then
break
fi
echo "Docs main moved to ${publish_ref} (${source_sha}) during cooldown."
if [ "${elapsed}" -ge "${max_wait}" ]; then
echo "Cooldown cap reached; translating newest observed state."
break
fi
done
should_translate="true"
if [ "${EVENT_NAME}" = "push" ] && [ "${mode}" = "incremental" ] && [ -n "${BEFORE_SHA}" ]; then
changed_paths="$(git diff --name-only "${BEFORE_SHA}" "${publish_ref}" || true)"
if printf '%s\n' "${changed_paths}" | grep -Eq '^docs/\.i18n/glossary\..*\.json$'; then
mode="full"
elif ! printf '%s\n' "${changed_paths}" | node -e '
const fs = require("node:fs");
const locales = new Set([
"ar", "de", "es", "fa", "fr", "id", "it", "ja-JP", "ko", "nl",
"pl", "pt-BR", "th", "tr", "uk", "vi", "zh-CN", "zh-TW",
]);
const paths = fs.readFileSync(0, "utf8").split(/\r?\n/).filter(Boolean);
const hasTranslatable = paths.some((path) => {
if (!path.startsWith("docs/")) return false;
const rel = path.slice("docs/".length);
const first = rel.split("/", 1)[0];
if (locales.has(first)) return false;
if (rel.startsWith(".generated/") || rel.startsWith(".i18n/")) return false;
return /\.(md|mdx)$/i.test(rel);
});
process.exit(hasTranslatable ? 0 : 1);
'
then
echo "No translatable docs changed after cooldown; skipping translation matrix."
should_translate="false"
fi
fi
{
echo "mode=${mode}"
echo "publish_ref=${publish_ref}"
echo "should_translate=${should_translate}"
echo "source_repository=${source_repository}"
echo "source_sha=${source_sha}"
} >> "${GITHUB_OUTPUT}"
{
echo "### Translate All"
echo
echo "- mode: \`${mode}\`"
echo "- publish ref: \`${publish_ref}\`"
echo "- source: \`${source_repository}@${source_sha}\`"
echo "- cooldown seconds: \`${cooldown}\`"
echo "- should translate: \`${should_translate}\`"
} >> "${GITHUB_STEP_SUMMARY}"
translate:
name: Translate ${{ matrix.locale }}
needs: prepare
if: needs.prepare.outputs.should_translate == 'true'
strategy:
fail-fast: false
matrix:
include:
- locale: zh-CN
locale_slug: zh-cn
- locale: zh-TW
locale_slug: zh-tw
- locale: ja-JP
locale_slug: ja-jp
- locale: es
locale_slug: es
- locale: pt-BR
locale_slug: pt-br
- locale: ko
locale_slug: ko
- locale: de
locale_slug: de
- locale: fr
locale_slug: fr
- locale: ar
locale_slug: ar
- locale: it
locale_slug: it
- locale: vi
locale_slug: vi
- locale: nl
locale_slug: nl
- locale: fa
locale_slug: fa
- locale: tr
locale_slug: tr
- locale: uk
locale_slug: uk
- locale: id
locale_slug: id
- locale: pl
locale_slug: pl
- locale: th
locale_slug: th
uses: ./.github/workflows/translate-locale-reusable.yml
with:
locale: ${{ matrix.locale }}
locale_slug: ${{ matrix.locale_slug }}
publish_ref: ${{ needs.prepare.outputs.publish_ref }}
source_sha: ${{ needs.prepare.outputs.source_sha }}
mode: ${{ needs.prepare.outputs.mode }}
secrets: inherit
finalize:
name: Commit successful locale artifacts
needs:
- prepare
- translate
if: always() && needs.prepare.result == 'success' && needs.prepare.outputs.should_translate == 'true'
runs-on: ubuntu-latest
steps:
- name: Check out latest main
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download locale artifacts
id: download
continue-on-error: true
uses: actions/download-artifact@v4
with:
pattern: i18n-*-${{ needs.prepare.outputs.source_sha }}
path: .openclaw-sync/i18n-artifacts
- name: Apply locale artifacts
id: apply
env:
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
MODE: ${{ needs.prepare.outputs.mode }}
EXPECTED_LOCALES: zh-cn=zh-CN zh-tw=zh-TW ja-jp=ja-JP es=es pt-br=pt-BR ko=ko de=de fr=fr ar=ar it=it vi=vi nl=nl fa=fa tr=tr uk=uk id=id pl=pl th=th
run: |
set -euo pipefail
git fetch --quiet origin main:refs/remotes/origin/main
git checkout -B main refs/remotes/origin/main
current_source_sha="$(
git show HEAD:.openclaw-sync/source.json \
| node -e 'const fs = require("node:fs"); const data = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(data.sha || "");'
)"
if [ "${current_source_sha}" != "${SOURCE_SHA}" ]; then
echo "stale=true" >> "${GITHUB_OUTPUT}"
echo "changed_count=0" >> "${GITHUB_OUTPUT}"
{
echo "### Translation finalizer skipped"
echo
echo "Current source moved to \`${current_source_sha}\`; artifacts were for \`${SOURCE_SHA}\`."
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
python - <<'PY'
import json
import os
import shutil
import subprocess
from pathlib import Path
source_sha = os.environ["SOURCE_SHA"]
expected_pairs = os.environ["EXPECTED_LOCALES"].split()
expected = dict(pair.split("=", 1) for pair in expected_pairs)
root = Path(".openclaw-sync/i18n-artifacts")
applied = []
no_changes = []
stale = []
invalid = []
failed = []
seen = set()
if root.exists():
for artifact in sorted(path for path in root.iterdir() if path.is_dir()):
metadata_path = artifact / "metadata.json"
if not metadata_path.exists():
invalid.append(f"{artifact.name}: missing metadata.json")
continue
try:
metadata = json.loads(metadata_path.read_text())
except Exception as exc:
invalid.append(f"{artifact.name}: invalid metadata ({exc})")
continue
slug = metadata.get("locale_slug")
locale = metadata.get("locale")
if slug not in expected or expected[slug] != locale:
invalid.append(f"{artifact.name}: unexpected locale {locale!r}/{slug!r}")
continue
if metadata.get("source_sha") != source_sha:
stale.append(f"{locale}: {metadata.get('source_sha')}")
continue
seen.add(slug)
failed_reason = metadata.get("failed_reason") or ""
if failed_reason:
failed.append(f"{locale}: {failed_reason}")
continue
changed = [line.strip() for line in (artifact / "changed-files.txt").read_text().splitlines() if line.strip()]
deleted = [line.strip() for line in (artifact / "deleted-files.txt").read_text().splitlines() if line.strip()]
for rel in deleted:
path = Path(rel)
if path.exists():
path.unlink()
payload = artifact / "payload"
for rel in changed:
source = payload / rel
if not source.exists():
invalid.append(f"{artifact.name}: missing payload file {rel}")
continue
target = Path(rel)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
if changed or deleted:
applied.append(locale)
else:
no_changes.append(locale)
missing = [expected[slug] for slug in expected if slug not in seen]
missing_or_failed = missing + failed
status = subprocess.run(
["git", "status", "--porcelain", "--untracked-files=all", "--", "docs"],
check=True,
text=True,
stdout=subprocess.PIPE,
).stdout.splitlines()
out = Path(os.environ["GITHUB_OUTPUT"])
with out.open("a", encoding="utf-8") as fh:
fh.write("stale=false\n")
fh.write(f"changed_count={len(status)}\n")
summary = Path(os.environ["GITHUB_STEP_SUMMARY"])
with summary.open("a", encoding="utf-8") as fh:
fh.write("### Translation finalizer\n\n")
fh.write(f"- mode: `{os.environ['MODE']}`\n")
fh.write(f"- source: `{source_sha}`\n")
fh.write(f"- changed paths: `{len(status)}`\n")
if applied:
fh.write(f"- applied locales: {', '.join(applied)}\n")
if no_changes:
fh.write(f"- locales with no changes: {', '.join(no_changes)}\n")
if missing_or_failed:
fh.write(f"- missing or failed locales: {', '.join(missing_or_failed)}\n")
if stale:
fh.write(f"- stale artifacts ignored: {', '.join(stale)}\n")
if invalid:
fh.write(f"- invalid artifacts ignored: {'; '.join(invalid)}\n")
print(f"changed paths: {len(status)}")
PY
- name: Set up Node
if: steps.apply.outputs.changed_count != '0'
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Install
if: steps.apply.outputs.changed_count != '0'
run: npm ci
- name: Install librsvg2-bin
if: steps.apply.outputs.changed_count != '0'
run: sudo apt-get update && sudo apt-get install -y librsvg2-bin
- name: Check aggregate docs
if: steps.apply.outputs.changed_count != '0'
run: npm run docs:check
- name: Commit aggregate translation refresh
if: steps.apply.outputs.changed_count != '0'
env:
SOURCE_SHA: ${{ needs.prepare.outputs.source_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
}
ensure_source_current() {
current_source_sha="$(remote_source_sha)"
if [ -n "${current_source_sha}" ] && [ "${current_source_sha}" != "${SOURCE_SHA}" ]; then
echo "Source moved from ${SOURCE_SHA} to ${current_source_sha}; skipping stale aggregate translation commit."
exit 0
fi
}
git config user.name "openclaw-docs-i18n[bot]"
git config user.email "openclaw-docs-i18n[bot]@users.noreply.github.com"
git add docs
git commit -m "chore(i18n): refresh translations"
for attempt in 1 2 3 4 5; do
if git fetch origin main:refs/remotes/origin/main; then
ensure_source_current
if git rebase origin/main; then
ensure_source_current
if git push origin HEAD:main; then
exit 0
fi
fi
git rebase --abort >/dev/null 2>&1 || true
fi
echo "Aggregate translation push attempt ${attempt} failed; retrying."
sleep $((attempt * 2))
done
echo "Failed to push aggregate translations after retries."
exit 1
- name: Dispatch aggregate docs deploy
if: steps.apply.outputs.changed_count != '0'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh workflow run pages.yml --ref main