This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git
The following commit(s) were added to refs/heads/master by this push:
new 82ccae85825 [ci](i18n) Share one translation workflow and keep batches
across unrelated merges (#4413)
82ccae85825 is described below
commit 82ccae858254f24b1b3a85f181ec289e386d33ae
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Oct 20 14:09:59 2026 +0100
[ci](i18n) Share one translation workflow and keep batches across unrelated
merges (#4413)
## Why
`japanese-translation.yml` and `korean-translation.yml` are about 210
identical lines with five logical differences, and three of those (the
label check before model calls, the exact `paths` input, the fixed
locale) had been added to the Korean side only. Comparing them also
turned up two behaviours worth fixing:
- **Master movement discarded whole batches after the model calls.**
Generation checked master only after the last page, and any move
discarded everything. Two of the last 12 Japanese runs failed this way:
on 10-18 after 50 minutes of generation; on 10-19 after 25 minutes,
although master had already moved before generation started (the
generate job waited 40 minutes for a runner after discovery). Most of
those moves were unrelated merges.
- **The Japanese workflow's `locale` input was a second way to run
Korean generation**, with the Japanese defaults: manual `generate`, no
label check, `japanese-translation-*` artifacts.
## What changed
### 1. One reusable workflow
`.github/workflows/translation-run.yml` (`workflow_call`) holds the
discovery and generation jobs. The Japanese and Korean workflows keep
only their triggers, manual defaults, the lock and one call with their
locale and artifact prefix. Secrets are passed explicitly, not
inherited. Mode and filters are set once as workflow-level env instead
of two to four times per file. Artifact names are unchanged
(`japanese-translation-plan-<run>`,
`korean-translation-generation-<run>`, …).
### 2. Same inputs and checks for both languages
- Japanese gains the exact `paths` input and the label check before auth
loading, which is now `translate-$I18N_LOCALE`.
- The Japanese `locale` input is removed. Each workflow runs only its
own language.
### 3. Unrelated master merges no longer discard a batch
New `scripts/i18n/master-drift.cjs` decides what a master move means for
a batch:
- **Unrelated merge:** the batch stays valid. The branch stays on the
source commit, the PR body records the newer master, and the PR checks
run against current master.
- **Change to a batch page** (English source, target or state shard):
that page is dropped and stays pending for the next run. A Dev/4.x copy
is dropped with its primary. The publisher stops instead.
- **Change to translation configuration, routing identity or the
pipeline:** the batch is discarded before any further model call. This
covers the locale's scope, glossary, protection exceptions, inventory
and top-level state files, `translations/config.json`, `config/i18n.js`,
`docusaurus.config.js`, `versions.json` and `scripts/i18n/`. A master
that no longer contains the source commit is discarded too.
When the checks run:
- Generation checks master before the first model call, before each page
and after the last one. The publisher checks again before pushing.
- The generate job checks out current master instead of the discovery
commit and replans, so a long runner queue no longer dooms the batch
before it starts.
### 4. The replan reuses an unchanged discovery plan
`workflow-scan.cjs` records what a plan depends on: the checkout, the
open-PR reservations and the filters. The generate job reuses the
downloaded discovery plan when all three are unchanged, which skips the
2.5–4.7 minute recount of every native document. The planner is
deterministic.
### 5. Lock name
The shared concurrency group is renamed from `japanese-translation-ja`
to `docs-translation`.
### 6. Korean labels for the new Hive and Lance Catalog categories
#4410 turned the Hive and Lance catalog pages into sidebar categories
with type-mapping subpages, which created four new Korean dictionary
keys (Dev and 4.x). The translation pipeline translates documents but
never writes sidebar labels, so the Korean coverage audit reported them
as missing. Like the existing Iceberg, Paimon, Hudi and MaxCompute
Catalog categories, they name Doris catalog connectors and stay in
English. They are added in sidebar order, and each one has its reason
recorded in `translations/scope/ko.json`.
### Considered and not adopted
A `blob:none` checkout. Reconciliation reads the managed files of every
open translation PR with `git show`. In a partial clone each of those
blobs is a separate network fetch, which costs more than the ~30 s the
checkout would save once several translation PRs are open.
## Verification
- All 21 translation test suites listed in Build Check pass on Node
20.20.2 (193 tests). `workflow.test.cjs` has 9 new tests, and the
workflow contract tests grew from 5 to 7.
- New tests cover:
- drift detection against a real local bare remote: unrelated merge,
page change, moved source, configuration change, rewritten master;
- generation stopping before any model call on a batch-wide change, both
before the batch and before a page;
- a page changed before its turn being skipped, and a page changed after
its model call being dropped with its Dev/4.x copy;
- the manifest being kept when a batch is discarded after the last page;
- the plan fingerprint, and the replan reusing a discovery plan without
recounting;
- the label check failing for a missing label;
- the caller/shared workflow contract.
- Mutation checks: each of these breaks a test — removing the per-page
check, moving it inside the per-page `catch`, keeping copies of dropped
pages, turning rename detection back on, removing the ancestor check.
- `scripts/i18n/simulate-workflow.cjs` (local bare remote, GitHub CLI
and Codex mocks) passes for the default, incremental and ten-page
fallback scenarios, and for the new
`I18N_SIM_MASTER_DRIFT=unrelated|page|config`.
- `unrelated` publishes and notes the newer master in the PR body.
- `page` skips the page with no model request.
- `config` stops before any model request.
- The default scenario failed on master before this PR (three requests
instead of two). Its page, `engines.md`, declares `"language": "en"`,
and the Codex mock copied it. The mock now localizes a declared front
matter language, as a real model does.
- Korean coverage audit on this branch: sidebar labels missing 0 (was
4), retained technical 31. `check-configuration --all` and the Korean
translation gate (`check-translations --base c99440a`) report no errors.
- actionlint 1.7.12 reports nothing new. The two remaining findings also
exist on master: the `queue` concurrency key (newer than actionlint) and
the age of `actions/setup-python@v4`.
## Merge note
Merge while no Japanese or Korean translation run is queued or running.
Because the lock is renamed, a run started under the old name and a run
started under the new one could otherwise overlap. Today's scheduled
runs start at 16:23 (Japanese) and 16:43 UTC (Korean).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: morningman <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.github/workflows/build-check.yml | 2 +-
.github/workflows/japanese-translation.yml | 217 ++-------------
.github/workflows/korean-translation.yml | 224 ++--------------
...{korean-translation.yml => translation-run.yml} | 115 ++++----
.../ko/docusaurus-plugin-content-docs/current.json | 8 +
.../version-4.x.json | 8 +
scripts/i18n/__tests__/korean-workflow.test.cjs | 102 -------
scripts/i18n/__tests__/multi-locale.test.cjs | 5 +-
.../i18n/__tests__/translation-workflows.test.cjs | 145 ++++++++++
scripts/i18n/__tests__/workflow.test.cjs | 297 +++++++++++++++++----
scripts/i18n/master-drift.cjs | 57 ++++
scripts/i18n/simulate-workflow.cjs | 54 +++-
scripts/i18n/workflow-generate.cjs | 61 +++--
scripts/i18n/workflow-publish.cjs | 12 +-
scripts/i18n/workflow-scan.cjs | 63 +++--
translations/README.md | 73 +++--
translations/scope/ko.json | 24 ++
17 files changed, 797 insertions(+), 670 deletions(-)
diff --git a/.github/workflows/build-check.yml
b/.github/workflows/build-check.yml
index deccbbae576..0afc09c1992 100644
--- a/.github/workflows/build-check.yml
+++ b/.github/workflows/build-check.yml
@@ -119,7 +119,7 @@ jobs:
node --test scripts/i18n/__tests__/planner.test.cjs
node --test
scripts/i18n/__tests__/translation-engine.test.cjs
node --test scripts/i18n/__tests__/korean-toolchain.test.cjs
- node --test scripts/i18n/__tests__/korean-workflow.test.cjs
+ node --test
scripts/i18n/__tests__/translation-workflows.test.cjs
node --test scripts/i18n/__tests__/workflow.test.cjs
node --test scripts/i18n/__tests__/codex-adapter.test.cjs
node --test
scripts/i18n/__tests__/translate-document.test.cjs
diff --git a/.github/workflows/japanese-translation.yml
b/.github/workflows/japanese-translation.yml
index 1e5b856030e..6ff02492752 100644
--- a/.github/workflows/japanese-translation.yml
+++ b/.github/workflows/japanese-translation.yml
@@ -18,9 +18,8 @@ on:
path:
description: Optional repository-relative source path prefix
type: string
- locale:
- default: ja
- description: Configured translation locale (defaults to ja)
+ paths:
+ description: Optional JSON array of exact English .md/.mdx files
(exclusive with path)
type: string
schedule:
- cron: '23 4,16 * * *'
@@ -30,198 +29,28 @@ permissions:
pull-requests: read
concurrency:
- # One entire run across scheduled/manual triggers, locales and modes.
- # Keep this stable so an in-flight run also holds the same lock.
- group: japanese-translation-ja
+ # One entire run at a time across both translation workflows, every trigger
+ # and every mode. Rename it only while no translation run is queued or
+ # running; otherwise the run in flight and the next one hold different locks.
+ group: docs-translation
cancel-in-progress: false
queue: max
jobs:
- discover:
- if: github.ref == 'refs/heads/master'
- runs-on: ubuntu-latest
- timeout-minutes: 20
- env:
- I18N_LOCALE: ${{ inputs.locale || 'ja' }}
- outputs:
- can_generate: ${{ steps.scan.outputs.can_generate }}
- source_commit: ${{ steps.scan.outputs.source_commit }}
- steps:
- - uses: actions/checkout@v4
- with:
- ref: master
- fetch-depth: 0
- persist-credentials: false
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: yarn
- - run: yarn --frozen-lockfile
- - name: Reconcile every open master PR with managed Japanese files
- env:
- GH_TOKEN: ${{ github.token }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/japanese-translation"
- - name: Plan all current and historical pending documents
- id: scan
- env:
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PLUGIN: ${{ inputs.plugin }}
- I18N_VERSION: ${{ inputs.version }}
- I18N_PATH: ${{ inputs.path }}
- I18N_LIMIT: '20'
- run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/japanese-translation"
- - uses: actions/upload-artifact@v4
- if: always()
- with:
- name: japanese-translation-plan-${{ github.run_id }}
- path: ${{ runner.temp }}/japanese-translation/
- if-no-files-found: warn
- retention-days: 30
-
- generate:
- needs: discover
- env:
- I18N_LOCALE: ${{ inputs.locale || 'ja' }}
- if: needs.discover.outputs.can_generate == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 120
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.discover.outputs.source_commit }}
- fetch-depth: 0
- persist-credentials: false
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: yarn
- - uses: actions/setup-python@v4
- with:
- python-version: '3.9'
- - run: yarn --frozen-lockfile
- - uses: actions/download-artifact@v4
- with:
- name: japanese-translation-plan-${{ github.run_id }}
- path: ${{ runner.temp }}/japanese-translation
- - name: Check external configuration
- id: config
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- APP_ID: ${{ secrets.I18N_GITHUB_APP_ID }}
- APP_PRIVATE_KEY: ${{ secrets.I18N_GITHUB_APP_PRIVATE_KEY }}
- I18N_GITHUB_PAT: ${{ secrets.I18N_GITHUB_PAT }}
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- run: |
- if [[ -n "$APP_ID" && -n "$APP_PRIVATE_KEY" ]]; then
- echo 'auth_kind=app' >> "$GITHUB_OUTPUT"
- elif [[ -n "$I18N_GITHUB_PAT" ]]; then
- echo 'auth_kind=pat' >> "$GITHUB_OUTPUT"
- else
- echo 'auth_kind=none' >> "$GITHUB_OUTPUT"
- fi
- if [[ -n "$OSS_AK" && -n "$OSS_SK" && ( -n "$APP_ID" && -n
"$APP_PRIVATE_KEY" || -n "$I18N_GITHUB_PAT" || "$I18N_MODE" == 'validate' ) ]];
then
- echo 'ready=true' >> "$GITHUB_OUTPUT"
- else
- echo 'ready=false' >> "$GITHUB_OUTPUT"
- echo 'OSS Codex auth or a GitHub write credential is missing. No
model call or PR write was attempted.' >> "$GITHUB_STEP_SUMMARY"
- fi
- - name: Verify personal token identity before model calls
- if: steps.config.outputs.auth_kind == 'pat'
- env:
- GH_TOKEN: ${{ secrets.I18N_GITHUB_PAT }}
- run: gh api user --jq '.login' > /dev/null
- - name: Reconcile again immediately before model calls
- if: steps.config.outputs.ready == 'true'
- env:
- GH_TOKEN: ${{ github.token }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/japanese-translation"
- - name: Replan exact source and open PRs
- if: steps.config.outputs.ready == 'true'
- id: fresh
- env:
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PLUGIN: ${{ inputs.plugin }}
- I18N_VERSION: ${{ inputs.version }}
- I18N_PATH: ${{ inputs.path }}
- I18N_LIMIT: '20'
- run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/japanese-translation"
- - name: Install ossutil for the pinned Codex package and auth pool
- if: steps.fresh.outputs.can_generate == 'true'
- run: |
- temp_dir="$(mktemp -d)"
- trap 'rm -rf "$temp_dir"' EXIT
- curl -fsSL -o "$temp_dir/ossutil.zip"
https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip
- unzip -q "$temp_dir/ossutil.zip" -d "$temp_dir"
- sudo install -m 0755 "$temp_dir/ossutil-v1.7.19-linux-amd64/ossutil"
/usr/local/bin/ossutil
- - name: Install the verified Codex runtime used by Doris review
- if: steps.fresh.outputs.can_generate == 'true'
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- run: |
- archive="$RUNNER_TEMP/codex-exec-goal.tar.gz"
- package="$RUNNER_TEMP/codex-exec-goal"
- ossutil -i "$OSS_AK" -k "$OSS_SK" -e oss-cn-hongkong.aliyuncs.com \
- cp -f
oss://doris-community-ci/codex/releases/codex-exec-goal-0.156.1-a310d33f7ebb-x86_64-unknown-linux-gnu.tar.gz
"$archive"
- echo
"2ee3073479bc8c94436684b55a13d7a3f299e768d04436d90e0427814a3954bc $archive" |
sha256sum --check
- mkdir -p "$package"
- tar -xzf "$archive" -C "$package"
- (cd "$package" && sha256sum --check SHA256SUMS)
- test "$(jq -r .version "$package/codex-package.json")" = 0.156.1
- "$package/bin/codex" exec --help | grep -q -- '--output-schema'
- "$package/bin/codex" exec --help | grep -q -- '--disable'
- "$package/bin/codex" exec --help | grep -q -- '--ignore-user-config'
- "$package/bin/codex" exec --help | grep -q -- '--ephemeral'
- echo "CODEX_BIN=$package/bin/codex" >> "$GITHUB_ENV"
- - name: Load an eligible Codex auth snapshot
- if: steps.fresh.outputs.can_generate == 'true'
- id: auth
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- run: node scripts/i18n/codex-auth.cjs load
- - name: Generate a bounded, validated batch
- if: steps.auth.outcome == 'success'
- id: translation
- env:
- I18N_CODEX_MODEL: ${{ vars.I18N_CODEX_MODEL || 'gpt-6-sol' }}
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PATH: ${{ inputs.path }}
- I18N_MAX_PAGES: ${{ inputs.mode == 'validate' && '1' || '10' }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-generate.cjs
"$RUNNER_TEMP/japanese-translation"
- - uses: actions/create-github-app-token@v2
- if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind == 'app'
- id: app
- with:
- app-id: ${{ secrets.I18N_GITHUB_APP_ID }}
- private-key: ${{ secrets.I18N_GITHUB_APP_PRIVATE_KEY }}
- owner: apache
- repositories: doris-website
- permission-contents: write
- permission-pull-requests: write
- - name: Run shared PR admission checks and open reviewable PR
- if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind != 'none'
- env:
- GH_TOKEN: ${{ steps.app.outputs.token || secrets.I18N_GITHUB_PAT }}
- I18N_PR_AUTH_KIND: ${{ steps.config.outputs.auth_kind }}
- run: node scripts/i18n/workflow-publish.cjs
"$RUNNER_TEMP/japanese-translation"
- - name: Report staged validation without GitHub write credentials
- if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind == 'none'
- run: echo 'One-page Codex candidate is in the generation artifact. A
GitHub App or personal token is required for a translation PR.' >>
"$GITHUB_STEP_SUMMARY"
- - name: Sync refreshed Codex auth without overwriting a newer snapshot
- if: always() && steps.auth.outcome == 'success'
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- run: node scripts/i18n/codex-auth.cjs sync
- - uses: actions/upload-artifact@v4
- if: always()
- with:
- name: japanese-translation-generation-${{ github.run_id }}
- path: ${{ runner.temp }}/japanese-translation/
- if-no-files-found: warn
- retention-days: 30
+ translation:
+ uses: ./.github/workflows/translation-run.yml
+ with:
+ locale: ja
+ workspace: japanese-translation
+ # Scheduled runs have no inputs and generate.
+ mode: ${{ inputs.mode || 'generate' }}
+ plugin: ${{ inputs.plugin }}
+ version: ${{ inputs.version }}
+ path: ${{ inputs.path }}
+ paths: ${{ inputs.paths }}
+ secrets:
+ OSS_AK: ${{ secrets.OSS_AK }}
+ OSS_SK: ${{ secrets.OSS_SK }}
+ I18N_GITHUB_APP_ID: ${{ secrets.I18N_GITHUB_APP_ID }}
+ I18N_GITHUB_APP_PRIVATE_KEY: ${{ secrets.I18N_GITHUB_APP_PRIVATE_KEY }}
+ I18N_GITHUB_PAT: ${{ secrets.I18N_GITHUB_PAT }}
diff --git a/.github/workflows/korean-translation.yml
b/.github/workflows/korean-translation.yml
index 11487252b54..ac0e425fc9d 100644
--- a/.github/workflows/korean-translation.yml
+++ b/.github/workflows/korean-translation.yml
@@ -22,9 +22,7 @@ on:
description: Optional JSON array of exact English .md/.mdx files
(exclusive with path)
type: string
# Twenty minutes after the Japanese schedule; the shared lock below holds
- # this run while a Japanese run is still in progress. Scheduled runs have no
- # inputs, so the mode falls back to generate; manual runs keep the dry-run
- # default.
+ # this run while a Japanese run is still in progress.
schedule:
- cron: '43 4,16 * * *'
@@ -33,205 +31,29 @@ permissions:
pull-requests: read
concurrency:
- # One entire run across scheduled/manual triggers, locales and modes.
- # Keep this stable so an in-flight run also holds the same lock.
- group: japanese-translation-ja
+ # One entire run at a time across both translation workflows, every trigger
+ # and every mode. Rename it only while no translation run is queued or
+ # running; otherwise the run in flight and the next one hold different locks.
+ group: docs-translation
cancel-in-progress: false
queue: max
jobs:
- discover:
- if: github.ref == 'refs/heads/master'
- runs-on: ubuntu-latest
- timeout-minutes: 20
- env:
- I18N_LOCALE: ko
- outputs:
- can_generate: ${{ steps.scan.outputs.can_generate }}
- source_commit: ${{ steps.scan.outputs.source_commit }}
- steps:
- - uses: actions/checkout@v4
- with:
- ref: master
- fetch-depth: 0
- persist-credentials: false
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: yarn
- - run: yarn --frozen-lockfile
- - name: Reconcile every open master PR with managed Korean files
- env:
- GH_TOKEN: ${{ github.token }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/korean-translation"
- - name: Plan all current and historical pending documents
- id: scan
- env:
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PLUGIN: ${{ inputs.plugin }}
- I18N_VERSION: ${{ inputs.version }}
- I18N_PATH: ${{ inputs.path }}
- I18N_PATHS: ${{ inputs.paths }}
- I18N_LIMIT: '20'
- run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/korean-translation"
- - uses: actions/upload-artifact@v4
- if: always()
- with:
- name: korean-translation-plan-${{ github.run_id }}
- path: ${{ runner.temp }}/korean-translation/
- if-no-files-found: warn
- retention-days: 30
-
- generate:
- needs: discover
- env:
- I18N_LOCALE: ko
- if: needs.discover.outputs.can_generate == 'true'
- runs-on: ubuntu-latest
- timeout-minutes: 120
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ needs.discover.outputs.source_commit }}
- fetch-depth: 0
- persist-credentials: false
- - uses: actions/setup-node@v4
- with:
- node-version: 20
- cache: yarn
- - uses: actions/setup-python@v4
- with:
- python-version: '3.9'
- - run: yarn --frozen-lockfile
- - uses: actions/download-artifact@v4
- with:
- name: korean-translation-plan-${{ github.run_id }}
- path: ${{ runner.temp }}/korean-translation
- - name: Check external configuration
- id: config
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- APP_ID: ${{ secrets.I18N_GITHUB_APP_ID }}
- APP_PRIVATE_KEY: ${{ secrets.I18N_GITHUB_APP_PRIVATE_KEY }}
- I18N_GITHUB_PAT: ${{ secrets.I18N_GITHUB_PAT }}
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- run: |
- if [[ -n "$APP_ID" && -n "$APP_PRIVATE_KEY" ]]; then
- echo 'auth_kind=app' >> "$GITHUB_OUTPUT"
- elif [[ -n "$I18N_GITHUB_PAT" ]]; then
- echo 'auth_kind=pat' >> "$GITHUB_OUTPUT"
- else
- echo 'auth_kind=none' >> "$GITHUB_OUTPUT"
- fi
- if [[ -n "$OSS_AK" && -n "$OSS_SK" && ( -n "$APP_ID" && -n
"$APP_PRIVATE_KEY" || -n "$I18N_GITHUB_PAT" || "$I18N_MODE" == 'validate' ) ]];
then
- echo 'ready=true' >> "$GITHUB_OUTPUT"
- else
- echo 'ready=false' >> "$GITHUB_OUTPUT"
- echo 'OSS Codex auth or a GitHub write credential is missing. No
model call or PR write was attempted.' >> "$GITHUB_STEP_SUMMARY"
- fi
- - name: Verify personal token identity before model calls
- if: steps.config.outputs.auth_kind == 'pat'
- env:
- GH_TOKEN: ${{ secrets.I18N_GITHUB_PAT }}
- run: gh api user --jq '.login' > /dev/null
- - name: Reconcile again immediately before model calls
- if: steps.config.outputs.ready == 'true'
- env:
- GH_TOKEN: ${{ github.token }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/korean-translation"
- - name: Replan exact source and open PRs
- if: steps.config.outputs.ready == 'true'
- id: fresh
- env:
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PLUGIN: ${{ inputs.plugin }}
- I18N_VERSION: ${{ inputs.version }}
- I18N_PATH: ${{ inputs.path }}
- I18N_PATHS: ${{ inputs.paths }}
- I18N_LIMIT: '20'
- run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/korean-translation"
- - name: Verify the Korean PR label before model calls
- if: steps.fresh.outputs.can_generate == 'true'
- env:
- GH_TOKEN: ${{ github.token }}
- run: test "$(gh api repos/apache/doris-website/labels/translate-ko
--jq .name)" = translate-ko
- - name: Install ossutil for the pinned Codex package and auth pool
- if: steps.fresh.outputs.can_generate == 'true'
- run: |
- temp_dir="$(mktemp -d)"
- trap 'rm -rf "$temp_dir"' EXIT
- curl -fsSL -o "$temp_dir/ossutil.zip"
https://gosspublic.alicdn.com/ossutil/1.7.19/ossutil-v1.7.19-linux-amd64.zip
- unzip -q "$temp_dir/ossutil.zip" -d "$temp_dir"
- sudo install -m 0755 "$temp_dir/ossutil-v1.7.19-linux-amd64/ossutil"
/usr/local/bin/ossutil
- - name: Install the verified Codex runtime used by Doris review
- if: steps.fresh.outputs.can_generate == 'true'
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- run: |
- archive="$RUNNER_TEMP/codex-exec-goal.tar.gz"
- package="$RUNNER_TEMP/codex-exec-goal"
- ossutil -i "$OSS_AK" -k "$OSS_SK" -e oss-cn-hongkong.aliyuncs.com \
- cp -f
oss://doris-community-ci/codex/releases/codex-exec-goal-0.156.1-a310d33f7ebb-x86_64-unknown-linux-gnu.tar.gz
"$archive"
- echo
"2ee3073479bc8c94436684b55a13d7a3f299e768d04436d90e0427814a3954bc $archive" |
sha256sum --check
- mkdir -p "$package"
- tar -xzf "$archive" -C "$package"
- (cd "$package" && sha256sum --check SHA256SUMS)
- test "$(jq -r .version "$package/codex-package.json")" = 0.156.1
- "$package/bin/codex" exec --help | grep -q -- '--output-schema'
- "$package/bin/codex" exec --help | grep -q -- '--disable'
- "$package/bin/codex" exec --help | grep -q -- '--ignore-user-config'
- "$package/bin/codex" exec --help | grep -q -- '--ephemeral'
- echo "CODEX_BIN=$package/bin/codex" >> "$GITHUB_ENV"
- - name: Load an eligible Codex auth snapshot
- if: steps.fresh.outputs.can_generate == 'true'
- id: auth
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- run: node scripts/i18n/codex-auth.cjs load
- - name: Generate a bounded, validated batch
- if: steps.auth.outcome == 'success'
- id: translation
- env:
- I18N_CODEX_MODEL: ${{ vars.I18N_CODEX_MODEL || 'gpt-6-sol' }}
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PATH: ${{ inputs.path }}
- I18N_MAX_PAGES: ${{ inputs.mode == 'validate' && '1' || '10' }}
- GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-generate.cjs
"$RUNNER_TEMP/korean-translation"
- - uses: actions/create-github-app-token@v2
- if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind == 'app'
- id: app
- with:
- app-id: ${{ secrets.I18N_GITHUB_APP_ID }}
- private-key: ${{ secrets.I18N_GITHUB_APP_PRIVATE_KEY }}
- owner: apache
- repositories: doris-website
- permission-contents: write
- permission-pull-requests: write
- - name: Run shared PR admission checks and open reviewable PR
- if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind != 'none'
- env:
- GH_TOKEN: ${{ steps.app.outputs.token || secrets.I18N_GITHUB_PAT }}
- I18N_PR_AUTH_KIND: ${{ steps.config.outputs.auth_kind }}
- run: node scripts/i18n/workflow-publish.cjs
"$RUNNER_TEMP/korean-translation"
- - name: Report staged validation without GitHub write credentials
- if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind == 'none'
- run: echo 'One-page Codex candidate is in the generation artifact. A
GitHub App or personal token is required for a translation PR.' >>
"$GITHUB_STEP_SUMMARY"
- - name: Sync refreshed Codex auth without overwriting a newer snapshot
- if: always() && steps.auth.outcome == 'success'
- env:
- OSS_AK: ${{ secrets.OSS_AK }}
- OSS_SK: ${{ secrets.OSS_SK }}
- run: node scripts/i18n/codex-auth.cjs sync
- - uses: actions/upload-artifact@v4
- if: always()
- with:
- name: korean-translation-generation-${{ github.run_id }}
- path: ${{ runner.temp }}/korean-translation/
- if-no-files-found: warn
- retention-days: 30
+ translation:
+ uses: ./.github/workflows/translation-run.yml
+ with:
+ locale: ko
+ workspace: korean-translation
+ # Scheduled runs have no inputs and generate; manual runs keep the
+ # dry-run default.
+ mode: ${{ inputs.mode || 'generate' }}
+ plugin: ${{ inputs.plugin }}
+ version: ${{ inputs.version }}
+ path: ${{ inputs.path }}
+ paths: ${{ inputs.paths }}
+ secrets:
+ OSS_AK: ${{ secrets.OSS_AK }}
+ OSS_SK: ${{ secrets.OSS_SK }}
+ I18N_GITHUB_APP_ID: ${{ secrets.I18N_GITHUB_APP_ID }}
+ I18N_GITHUB_APP_PRIVATE_KEY: ${{ secrets.I18N_GITHUB_APP_PRIVATE_KEY }}
+ I18N_GITHUB_PAT: ${{ secrets.I18N_GITHUB_PAT }}
diff --git a/.github/workflows/korean-translation.yml
b/.github/workflows/translation-run.yml
similarity index 74%
copy from .github/workflows/korean-translation.yml
copy to .github/workflows/translation-run.yml
index 11487252b54..1bd26b79530 100644
--- a/.github/workflows/korean-translation.yml
+++ b/.github/workflows/translation-run.yml
@@ -1,51 +1,71 @@
-name: Korean translation discovery
+name: Translation run
+# One discovery and generation run for one managed locale. The language
+# workflows own the triggers, the manual defaults and the shared whole-run
+# lock; they pass the locale, the mode and the filters here.
on:
- workflow_dispatch:
+ workflow_call:
inputs:
+ locale:
+ description: Managed translation locale
+ type: string
+ required: true
+ workspace:
+ description: Run directory name and artifact prefix
+ type: string
+ required: true
mode:
- description: Read-only dry run, one-page live validation, or batch
generation
- type: choice
- options: [dry-run, validate, generate]
- default: dry-run
+ description: dry-run, validate or generate
+ type: string
required: true
plugin:
description: Optional plugin filter (default or community)
type: string
+ default: ''
version:
description: Optional version filter (current or 4.x)
type: string
+ default: ''
path:
description: Optional repository-relative source path prefix
type: string
+ default: ''
paths:
description: Optional JSON array of exact English .md/.mdx files
(exclusive with path)
type: string
- # Twenty minutes after the Japanese schedule; the shared lock below holds
- # this run while a Japanese run is still in progress. Scheduled runs have no
- # inputs, so the mode falls back to generate; manual runs keep the dry-run
- # default.
- schedule:
- - cron: '43 4,16 * * *'
+ default: ''
+ secrets:
+ OSS_AK:
+ required: false
+ OSS_SK:
+ required: false
+ I18N_GITHUB_APP_ID:
+ required: false
+ I18N_GITHUB_APP_PRIVATE_KEY:
+ required: false
+ I18N_GITHUB_PAT:
+ required: false
permissions:
contents: read
pull-requests: read
-concurrency:
- # One entire run across scheduled/manual triggers, locales and modes.
- # Keep this stable so an in-flight run also holds the same lock.
- group: japanese-translation-ja
- cancel-in-progress: false
- queue: max
+env:
+ I18N_LOCALE: ${{ inputs.locale }}
+ I18N_MODE: ${{ inputs.mode }}
+ I18N_PLUGIN: ${{ inputs.plugin }}
+ I18N_VERSION: ${{ inputs.version }}
+ I18N_PATH: ${{ inputs.path }}
+ I18N_PATHS: ${{ inputs.paths }}
+ I18N_LIMIT: '20'
+ I18N_MAX_PAGES: ${{ inputs.mode == 'validate' && '1' || '10' }}
+ TRANSLATION_WORKSPACE: ${{ inputs.workspace }}
jobs:
discover:
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 20
- env:
- I18N_LOCALE: ko
outputs:
can_generate: ${{ steps.scan.outputs.can_generate }}
source_commit: ${{ steps.scan.outputs.source_commit }}
@@ -60,40 +80,33 @@ jobs:
node-version: 20
cache: yarn
- run: yarn --frozen-lockfile
- - name: Reconcile every open master PR with managed Korean files
+ - name: Reconcile every open master PR with managed translation files
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/korean-translation"
+ run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/$TRANSLATION_WORKSPACE"
- name: Plan all current and historical pending documents
id: scan
- env:
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PLUGIN: ${{ inputs.plugin }}
- I18N_VERSION: ${{ inputs.version }}
- I18N_PATH: ${{ inputs.path }}
- I18N_PATHS: ${{ inputs.paths }}
- I18N_LIMIT: '20'
- run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/korean-translation"
+ run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/$TRANSLATION_WORKSPACE"
- uses: actions/upload-artifact@v4
if: always()
with:
- name: korean-translation-plan-${{ github.run_id }}
- path: ${{ runner.temp }}/korean-translation/
+ name: ${{ inputs.workspace }}-plan-${{ github.run_id }}
+ path: ${{ runner.temp }}/${{ inputs.workspace }}/
if-no-files-found: warn
retention-days: 30
generate:
needs: discover
- env:
- I18N_LOCALE: ko
if: needs.discover.outputs.can_generate == 'true'
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
+ # Current master, not the discovery commit: this job can wait for a
runner
+ # long after discovery, and the replan below works from what it checks
out.
- uses: actions/checkout@v4
with:
- ref: ${{ needs.discover.outputs.source_commit }}
+ ref: master
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
@@ -106,8 +119,8 @@ jobs:
- run: yarn --frozen-lockfile
- uses: actions/download-artifact@v4
with:
- name: korean-translation-plan-${{ github.run_id }}
- path: ${{ runner.temp }}/korean-translation
+ name: ${{ inputs.workspace }}-plan-${{ github.run_id }}
+ path: ${{ runner.temp }}/${{ inputs.workspace }}
- name: Check external configuration
id: config
env:
@@ -116,7 +129,6 @@ jobs:
APP_ID: ${{ secrets.I18N_GITHUB_APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.I18N_GITHUB_APP_PRIVATE_KEY }}
I18N_GITHUB_PAT: ${{ secrets.I18N_GITHUB_PAT }}
- I18N_MODE: ${{ inputs.mode || 'generate' }}
run: |
if [[ -n "$APP_ID" && -n "$APP_PRIVATE_KEY" ]]; then
echo 'auth_kind=app' >> "$GITHUB_OUTPUT"
@@ -141,23 +153,17 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/korean-translation"
+ run: node scripts/i18n/workflow-prs.cjs
"$RUNNER_TEMP/$TRANSLATION_WORKSPACE"
- name: Replan exact source and open PRs
if: steps.config.outputs.ready == 'true'
id: fresh
- env:
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PLUGIN: ${{ inputs.plugin }}
- I18N_VERSION: ${{ inputs.version }}
- I18N_PATH: ${{ inputs.path }}
- I18N_PATHS: ${{ inputs.paths }}
- I18N_LIMIT: '20'
- run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/korean-translation"
- - name: Verify the Korean PR label before model calls
+ run: node scripts/i18n/workflow-scan.cjs
"$RUNNER_TEMP/$TRANSLATION_WORKSPACE"
+ # The publisher labels the PR only after the model calls and the branch
push.
+ - name: Verify the PR label before model calls
if: steps.fresh.outputs.can_generate == 'true'
env:
GH_TOKEN: ${{ github.token }}
- run: test "$(gh api repos/apache/doris-website/labels/translate-ko
--jq .name)" = translate-ko
+ run: test "$(gh api
"repos/apache/doris-website/labels/translate-$I18N_LOCALE" --jq .name)" =
"translate-$I18N_LOCALE"
- name: Install ossutil for the pinned Codex package and auth pool
if: steps.fresh.outputs.can_generate == 'true'
run: |
@@ -198,11 +204,8 @@ jobs:
id: translation
env:
I18N_CODEX_MODEL: ${{ vars.I18N_CODEX_MODEL || 'gpt-6-sol' }}
- I18N_MODE: ${{ inputs.mode || 'generate' }}
- I18N_PATH: ${{ inputs.path }}
- I18N_MAX_PAGES: ${{ inputs.mode == 'validate' && '1' || '10' }}
GITHUB_REPOSITORY: ${{ github.repository }}
- run: node scripts/i18n/workflow-generate.cjs
"$RUNNER_TEMP/korean-translation"
+ run: node scripts/i18n/workflow-generate.cjs
"$RUNNER_TEMP/$TRANSLATION_WORKSPACE"
- uses: actions/create-github-app-token@v2
if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind == 'app'
id: app
@@ -218,7 +221,7 @@ jobs:
env:
GH_TOKEN: ${{ steps.app.outputs.token || secrets.I18N_GITHUB_PAT }}
I18N_PR_AUTH_KIND: ${{ steps.config.outputs.auth_kind }}
- run: node scripts/i18n/workflow-publish.cjs
"$RUNNER_TEMP/korean-translation"
+ run: node scripts/i18n/workflow-publish.cjs
"$RUNNER_TEMP/$TRANSLATION_WORKSPACE"
- name: Report staged validation without GitHub write credentials
if: steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind == 'none'
run: echo 'One-page Codex candidate is in the generation artifact. A
GitHub App or personal token is required for a translation PR.' >>
"$GITHUB_STEP_SUMMARY"
@@ -231,7 +234,7 @@ jobs:
- uses: actions/upload-artifact@v4
if: always()
with:
- name: korean-translation-generation-${{ github.run_id }}
- path: ${{ runner.temp }}/korean-translation/
+ name: ${{ inputs.workspace }}-generation-${{ github.run_id }}
+ path: ${{ runner.temp }}/${{ inputs.workspace }}/
if-no-files-found: warn
retention-days: 30
diff --git a/i18n/ko/docusaurus-plugin-content-docs/current.json
b/i18n/ko/docusaurus-plugin-content-docs/current.json
index 1a28c733e6b..7af91ea644d 100644
--- a/i18n/ko/docusaurus-plugin-content-docs/current.json
+++ b/i18n/ko/docusaurus-plugin-content-docs/current.json
@@ -255,6 +255,10 @@
"message": "데이터 Catalog",
"description": "The label for category Data Catalogs in sidebar docs"
},
+ "sidebar.docs.category.Hive Catalog": {
+ "message": "Hive Catalog",
+ "description": "The label for category Hive Catalog in sidebar docs"
+ },
"sidebar.docs.category.Iceberg Catalog": {
"message": "Iceberg Catalog",
"description": "The label for category Iceberg Catalog in sidebar docs"
@@ -263,6 +267,10 @@
"message": "Paimon Catalog",
"description": "The label for category Paimon Catalog in sidebar docs"
},
+ "sidebar.docs.category.Lance Catalog": {
+ "message": "Lance Catalog",
+ "description": "The label for category Lance Catalog in sidebar docs"
+ },
"sidebar.docs.category.Hudi Catalog": {
"message": "Hudi Catalog",
"description": "The label for category Hudi Catalog in sidebar docs"
diff --git a/i18n/ko/docusaurus-plugin-content-docs/version-4.x.json
b/i18n/ko/docusaurus-plugin-content-docs/version-4.x.json
index 01b0fe359a8..b7e13d16787 100644
--- a/i18n/ko/docusaurus-plugin-content-docs/version-4.x.json
+++ b/i18n/ko/docusaurus-plugin-content-docs/version-4.x.json
@@ -247,10 +247,18 @@
"message": "데이터 Catalog",
"description": "The label for category Data Catalogs in sidebar docs"
},
+ "sidebar.docs.category.Hive Catalog": {
+ "message": "Hive Catalog",
+ "description": "The label for category Hive Catalog in sidebar docs"
+ },
"sidebar.docs.category.Iceberg Catalog": {
"message": "Iceberg Catalog",
"description": "The label for category Iceberg Catalog in sidebar docs"
},
+ "sidebar.docs.category.Lance Catalog": {
+ "message": "Lance Catalog",
+ "description": "The label for category Lance Catalog in sidebar docs"
+ },
"sidebar.docs.category.Paimon Catalog": {
"message": "Paimon Catalog",
"description": "The label for category Paimon Catalog in sidebar docs"
diff --git a/scripts/i18n/__tests__/korean-workflow.test.cjs
b/scripts/i18n/__tests__/korean-workflow.test.cjs
deleted file mode 100644
index 6b83352a05e..00000000000
--- a/scripts/i18n/__tests__/korean-workflow.test.cjs
+++ /dev/null
@@ -1,102 +0,0 @@
-const test = require('node:test');
-const assert = require('node:assert/strict');
-const fs = require('node:fs');
-const path = require('node:path');
-const yaml = require('js-yaml');
-const os = require('node:os');
-const {spawnSync} = require('node:child_process');
-const {allowGeneration} = require('../workflow-generate.cjs');
-const workflows = path.resolve(__dirname, '../../../.github/workflows');
-const load = name => yaml.load(fs.readFileSync(path.join(workflows, name),
'utf8'));
-const korean = load('korean-translation.yml');
-const japanese = load('japanese-translation.yml');
-
-test('one-page validation rejects an exact batch list before creating scan
artifacts', t => {
- const directory = fs.mkdtempSync(path.join(os.tmpdir(),
'ko-validate-paths-'));
- t.after(() => fs.rmSync(directory, {recursive:true,force:true}));
- const output = path.join(directory,'scan');
- const result = spawnSync(process.execPath,
[path.resolve(workflows,'../../scripts/i18n/workflow-scan.cjs'),output], {
-
encoding:'utf8',env:{...process.env,I18N_LOCALE:'ko',I18N_MODE:'validate',I18N_PATHS:'["community/subscribe-mail-list.md"]'},
- });
- assert.equal(result.status,1);
- assert.match(result.stderr,/Validation mode requires path, not a list/);
- assert(!fs.existsSync(output));
-});
-
-test('Korean generation runs on its schedule or a manual choice and shares the
existing whole-run lock', () => {
- assert.deepEqual(Object.keys(korean.on), ['workflow_dispatch',
'schedule']);
- // Twice daily, offset from the Japanese runs that hold the same lock.
- assert.deepEqual(korean.on.schedule, [{cron: '43 4,16 * * *'}]);
- assert.deepEqual(japanese.on.schedule, [{cron: '23 4,16 * * *'}]);
- assert.equal(korean.on.workflow_dispatch.inputs.mode.default, 'dry-run');
- assert.deepEqual(korean.on.workflow_dispatch.inputs.mode.options,
['dry-run', 'validate', 'generate']);
- assert.equal(korean.on.workflow_dispatch.inputs.locale, undefined);
- assert.equal(korean.on.workflow_dispatch.inputs.paths.type, 'string');
- assert.deepEqual(korean.permissions, {'contents':'read',
'pull-requests':'read'});
- assert.deepEqual(korean.concurrency, japanese.concurrency);
- assert.equal(korean.concurrency.group, 'japanese-translation-ja');
- assert.equal(korean.jobs.discover.if, "github.ref == 'refs/heads/master'");
- assert.equal(korean.jobs.generate.needs, 'discover');
- assert.equal(korean.jobs.generate.if, "needs.discover.outputs.can_generate
== 'true'");
- for (const job of Object.values(korean.jobs)) {
- assert.equal(job.env.I18N_LOCALE, 'ko');
- for (const step of job.steps) {
- if (step.env?.I18N_MODE) assert.equal(step.env.I18N_MODE, "${{
inputs.mode || 'generate' }}");
- if (step.uses?.startsWith('actions/checkout@')) {
- assert.equal(step.with['fetch-depth'], 0);
- assert.equal(step.with['persist-credentials'], false);
- }
- }
- }
-});
-
-test('a scheduled run resolves to a mode that the generation gate accepts', ()
=> {
- // Discovery marks every scheduled run eligible, so the input-less fallback
- // must be a mode that workflow-generate.cjs will run, not a dry run.
- const step = korean.jobs.generate.steps.find(item => item.id ===
'translation');
- const fallback = /\|\| '([a-z-]+)' \}\}$/.exec(step.env.I18N_MODE)[1];
- assert.equal(allowGeneration({I18N_MODE: fallback, GITHUB_EVENT_NAME:
'schedule', I18N_MAX_PAGES: '10'}), true);
- assert.equal(allowGeneration({I18N_MODE:
korean.on.workflow_dispatch.inputs.mode.default,
- GITHUB_EVENT_NAME: 'workflow_dispatch', I18N_MAX_PAGES: '10'}), false);
-});
-
-test('Korean discovery and generation exchange Korean artifacts at the exact
scanned commit', () => {
- const discover = korean.jobs.discover.steps;
- const generate = korean.jobs.generate.steps;
- assert.equal(generate.find(step =>
step.uses?.startsWith('actions/checkout@')).with.ref,
- '${{ needs.discover.outputs.source_commit }}');
- const upload = discover.find(step =>
step.uses?.startsWith('actions/upload-artifact@'));
- const download = generate.find(step =>
step.uses?.startsWith('actions/download-artifact@'));
- assert.equal(upload.with.name, 'korean-translation-plan-${{ github.run_id
}}');
- assert.equal(download.with.name, upload.with.name);
- assert.equal(download.with.path, '${{ runner.temp }}/korean-translation');
- const scripts = [...discover, ...generate].filter(step => /node
scripts\/i18n\/workflow-(prs|scan|generate|publish)/.test(step.run ?? ''));
- assert.equal(scripts.length, 6);
- assert(scripts.every(step =>
step.run.includes('"$RUNNER_TEMP/korean-translation"')));
- const execute = generate.find(step => step.id === 'translation');
- assert.equal(execute.env.I18N_MAX_PAGES, "${{ inputs.mode == 'validate' &&
'1' || '10' }}");
- assert.equal(execute.env.I18N_PATH, '${{ inputs.path }}');
- for (const steps of [discover, generate]) {
- const scan = steps.find(step =>
step.run?.includes('workflow-scan.cjs'));
- assert.equal(scan.env.I18N_PATHS, '${{ inputs.paths }}');
- }
- assert.equal(execute.env.I18N_CODEX_MODEL,
japanese.jobs.generate.steps.find(step => step.id ===
'translation').env.I18N_CODEX_MODEL);
-});
-
-test('Korean publication checks the label before auth loading and preserves
the verified runtime and refresh path', () => {
- const steps = korean.jobs.generate.steps;
- const label = steps.findIndex(step => step.name === 'Verify the Korean PR
label before model calls');
- const auth = steps.findIndex(step => step.id === 'auth');
- assert(label >= 0 && label < auth);
- assert.equal(steps[label].if, "steps.fresh.outputs.can_generate ==
'true'");
- assert.equal(steps[label].env.GH_TOKEN, '${{ github.token }}');
- assert(steps[label].run.includes('labels/translate-ko'));
- const runtime = steps.find(step => step.name === 'Install the verified
Codex runtime used by Doris review');
- assert.deepEqual(runtime, japanese.jobs.generate.steps.find(step =>
step.name === runtime.name));
- const sync = steps.find(step => step.name === 'Sync refreshed Codex auth
without overwriting a newer snapshot');
- assert.equal(sync.if, "always() && steps.auth.outcome == 'success'");
- assert.equal(sync.run, 'node scripts/i18n/codex-auth.cjs sync');
- const publish = steps.find(step =>
step.run?.includes('workflow-publish.cjs'));
- assert.equal(publish.if, "steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind != 'none'");
- assert.equal(publish.env.GH_TOKEN, '${{ steps.app.outputs.token ||
secrets.I18N_GITHUB_PAT }}');
-});
diff --git a/scripts/i18n/__tests__/multi-locale.test.cjs
b/scripts/i18n/__tests__/multi-locale.test.cjs
index 9b1e4c9b0a0..f048a788780 100644
--- a/scripts/i18n/__tests__/multi-locale.test.cjs
+++ b/scripts/i18n/__tests__/multi-locale.test.cjs
@@ -109,8 +109,9 @@ test('native doc metadata, browser groups and per-locale
manifests agree, includ
test('scheduled Japanese jobs select ja and PR admission checks only changed
locales with fail-fast behavior', async t => {
const workflow = file => yaml.load(fs.readFileSync(path.join(repoRoot,
'.github/workflows', file), 'utf8'));
const translation = workflow('japanese-translation.yml');
- assert.equal(translation.on.workflow_dispatch.inputs.locale.default, 'ja');
- for (const job of ['discover', 'generate'])
assert.equal(translation.jobs[job].env.I18N_LOCALE, "${{ inputs.locale || 'ja'
}}");
+ assert.equal(translation.on.workflow_dispatch.inputs.locale, undefined);
+ assert.equal(translation.jobs.translation.with.locale, 'ja');
+ assert.equal(workflow('translation-run.yml').env.I18N_LOCALE, '${{
inputs.locale }}');
assert.equal(workflow('japanese-coverage-audit.yml').jobs.audit.env.I18N_LOCALE,
'ja');
const gate =
workflow('build-check.yml').jobs['build-and-deploy'].steps.find(step =>
step.name === 'Translation gate for changed locales');
const {root, write} = await createFixture(directory(t));
diff --git a/scripts/i18n/__tests__/translation-workflows.test.cjs
b/scripts/i18n/__tests__/translation-workflows.test.cjs
new file mode 100644
index 00000000000..c7ba427aad8
--- /dev/null
+++ b/scripts/i18n/__tests__/translation-workflows.test.cjs
@@ -0,0 +1,145 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const yaml = require('js-yaml');
+const os = require('node:os');
+const {spawnSync} = require('node:child_process');
+const {allowGeneration} = require('../workflow-generate.cjs');
+const workflows = path.resolve(__dirname, '../../../.github/workflows');
+const load = name => yaml.load(fs.readFileSync(path.join(workflows, name),
'utf8'));
+const shared = load('translation-run.yml');
+const callers = {ja: load('japanese-translation.yml'), ko:
load('korean-translation.yml')};
+
+test('one-page validation rejects an exact batch list before creating scan
artifacts', t => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(),
'ko-validate-paths-'));
+ t.after(() => fs.rmSync(directory, {recursive:true,force:true}));
+ const output = path.join(directory,'scan');
+ const result = spawnSync(process.execPath,
[path.resolve(workflows,'../../scripts/i18n/workflow-scan.cjs'),output], {
+
encoding:'utf8',env:{...process.env,I18N_LOCALE:'ko',I18N_MODE:'validate',I18N_PATHS:'["community/subscribe-mail-list.md"]'},
+ });
+ assert.equal(result.status,1);
+ assert.match(result.stderr,/Validation mode requires path, not a list/);
+ assert(!fs.existsSync(output));
+});
+
+test('each language workflow passes one fixed locale to the shared run under
one lock', () => {
+ const expected = {
+ ja: {workspace: 'japanese-translation', mode: 'generate', cron: '23
4,16 * * *'},
+ // Twenty minutes later; the shared lock queues it behind a Japanese
run.
+ ko: {workspace: 'korean-translation', mode: 'dry-run', cron: '43 4,16
* * *'},
+ };
+ for (const [locale, workflow] of Object.entries(callers)) {
+ assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch',
'schedule']);
+ assert.deepEqual(workflow.on.schedule, [{cron:
expected[locale].cron}]);
+ const inputs = workflow.on.workflow_dispatch.inputs;
+ // No locale input: each workflow runs only its own language.
+ assert.deepEqual(Object.keys(inputs), ['mode', 'plugin', 'version',
'path', 'paths']);
+ assert.equal(inputs.mode.default, expected[locale].mode);
+ assert.deepEqual(inputs.mode.options, ['dry-run', 'validate',
'generate']);
+ assert.deepEqual(workflow.permissions, {'contents': 'read',
'pull-requests': 'read'});
+ assert.deepEqual(workflow.concurrency, {group: 'docs-translation',
'cancel-in-progress': false, queue: 'max'});
+ assert.deepEqual(Object.keys(workflow.jobs), ['translation']);
+ const job = workflow.jobs.translation;
+ assert.equal(job.uses, './.github/workflows/translation-run.yml');
+ assert.deepEqual(job.with, {locale, workspace:
expected[locale].workspace,
+ mode: "${{ inputs.mode || 'generate' }}", plugin: '${{
inputs.plugin }}', version: '${{ inputs.version }}',
+ path: '${{ inputs.path }}', paths: '${{ inputs.paths }}'});
+ assert.deepEqual(Object.keys(job.secrets).sort(),
Object.keys(shared.on.workflow_call.secrets).sort());
+ for (const [name, value] of Object.entries(job.secrets))
assert.equal(value, `\${{ secrets.${name} }}`);
+ }
+});
+
+test('a scheduled run resolves to a mode that the generation gate accepts', ()
=> {
+ // Discovery marks every scheduled run eligible, so the input-less fallback
+ // must be a mode that workflow-generate.cjs will run, not a dry run.
+ for (const workflow of Object.values(callers)) {
+ const fallback = /\|\| '([a-z-]+)'
\}\}$/.exec(workflow.jobs.translation.with.mode)[1];
+ assert.equal(allowGeneration({I18N_MODE: fallback, GITHUB_EVENT_NAME:
'schedule', I18N_MAX_PAGES: '10'}), true);
+ }
+ assert.equal(allowGeneration({I18N_MODE:
callers.ko.on.workflow_dispatch.inputs.mode.default,
+ GITHUB_EVENT_NAME: 'workflow_dispatch', I18N_MAX_PAGES: '10'}), false);
+});
+
+test('the shared run names no language and sets mode and filters once', () => {
+ const text = fs.readFileSync(path.join(workflows, 'translation-run.yml'),
'utf8');
+ assert.doesNotMatch(text, /japanese|korean|translate-(ja|ko)\b/i);
+ assert.deepEqual(Object.keys(shared.on), ['workflow_call']);
+ assert.deepEqual(shared.permissions, {'contents': 'read', 'pull-requests':
'read'});
+ assert.equal(shared.concurrency, undefined);
+ const inputs = shared.on.workflow_call.inputs;
+ for (const name of ['locale', 'workspace', 'mode'])
assert.equal(inputs[name].required, true);
+ for (const name of ['plugin', 'version', 'path', 'paths'])
assert.equal(inputs[name].default, '');
+ assert.equal(shared.env.I18N_LOCALE, '${{ inputs.locale }}');
+ assert.equal(shared.env.I18N_MODE, '${{ inputs.mode }}');
+ for (const name of ['plugin', 'version', 'path', 'paths'])
+ assert.equal(shared.env[`I18N_${name.toUpperCase()}`], `\${{
inputs.${name} }}`);
+ assert.equal(shared.env.I18N_LIMIT, '20');
+ assert.equal(shared.env.I18N_MAX_PAGES, "${{ inputs.mode == 'validate' &&
'1' || '10' }}");
+ assert.equal(shared.jobs.discover.if, "github.ref == 'refs/heads/master'");
+ assert.equal(shared.jobs.generate.needs, 'discover');
+ assert.equal(shared.jobs.generate.if, "needs.discover.outputs.can_generate
== 'true'");
+ const local = new Set(['I18N_CODEX_MODEL', 'I18N_GITHUB_PAT',
'I18N_PR_AUTH_KIND']);
+ for (const job of Object.values(shared.jobs)) {
+ assert.equal(job.env, undefined);
+ for (const step of job.steps) {
+ for (const key of Object.keys(step.env ?? {}))
assert(!key.startsWith('I18N_') || local.has(key), key);
+ if (step.uses?.startsWith('actions/checkout@')) {
+ // Generation recounts against current master instead of the
discovery commit.
+ assert.deepEqual(step.with, {ref: 'master', 'fetch-depth': 0,
'persist-credentials': false});
+ }
+ }
+ }
+});
+
+test('discovery and generation exchange artifacts in the caller workspace', ()
=> {
+ const discover = shared.jobs.discover.steps;
+ const generate = shared.jobs.generate.steps;
+ const upload = discover.find(step =>
step.uses?.startsWith('actions/upload-artifact@'));
+ const download = generate.find(step =>
step.uses?.startsWith('actions/download-artifact@'));
+ assert.equal(upload.with.name, '${{ inputs.workspace }}-plan-${{
github.run_id }}');
+ assert.equal(download.with.name, upload.with.name);
+ assert.equal(download.with.path, '${{ runner.temp }}/${{ inputs.workspace
}}');
+ assert.equal(generate.at(-1).with.name, '${{ inputs.workspace
}}-generation-${{ github.run_id }}');
+ assert.equal(shared.env.TRANSLATION_WORKSPACE, '${{ inputs.workspace }}');
+ const scripts = [...discover, ...generate].filter(step => /node
scripts\/i18n\/workflow-(prs|scan|generate|publish)/.test(step.run ?? ''));
+ assert.equal(scripts.length, 6);
+ assert(scripts.every(step => step.run.endsWith('
"$RUNNER_TEMP/$TRANSLATION_WORKSPACE"')));
+ const index = name => generate.findIndex(step => step.name === name);
+ assert(index('Reconcile again immediately before model calls') <
index('Replan exact source and open PRs'));
+ assert(index('Replan exact source and open PRs') < index('Verify the PR
label before model calls'));
+ assert.equal(generate.find(step => step.id ===
'translation').env.I18N_CODEX_MODEL,
+ "${{ vars.I18N_CODEX_MODEL || 'gpt-6-sol' }}");
+});
+
+test('the label check runs before auth loading and fails for a missing locale
label', t => {
+ const steps = shared.jobs.generate.steps;
+ const label = steps.findIndex(step => step.name === 'Verify the PR label
before model calls');
+ const auth = steps.findIndex(step => step.id === 'auth');
+ assert(label >= 0 && label < auth);
+ assert.equal(steps[label].if, "steps.fresh.outputs.can_generate ==
'true'");
+ assert.equal(steps[label].env.GH_TOKEN, '${{ github.token }}');
+ const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'translation-label-'));
+ t.after(() => fs.rmSync(bin, {recursive: true, force: true}));
+ // The mock answers like the labels API: the name, or nothing when missing.
+ fs.writeFileSync(path.join(bin, 'gh'), '#!/bin/sh\ncase "$2" in
*/labels/translate-ja) echo translate-ja;; esac\n',
+ {mode: 0o755});
+ const run = locale => spawnSync('bash', ['-e', '-c', steps[label].run],
+ {env: {...process.env, PATH: `${bin}:${process.env.PATH}`,
I18N_LOCALE: locale}}).status;
+ assert.equal(run('ja'), 0);
+ assert.notEqual(run('ko'), 0);
+});
+
+test('publication preserves the verified runtime and refresh path', () => {
+ const steps = shared.jobs.generate.steps;
+ const runtime = steps.find(step => step.name === 'Install the verified
Codex runtime used by Doris review');
+
assert(runtime.run.includes('codex-exec-goal-0.156.1-a310d33f7ebb-x86_64-unknown-linux-gnu.tar.gz'));
+
assert(runtime.run.includes('2ee3073479bc8c94436684b55a13d7a3f299e768d04436d90e0427814a3954bc
$archive'));
+ assert.equal(runtime.if, "steps.fresh.outputs.can_generate == 'true'");
+ const sync = steps.find(step => step.name === 'Sync refreshed Codex auth
without overwriting a newer snapshot');
+ assert.equal(sync.if, "always() && steps.auth.outcome == 'success'");
+ assert.equal(sync.run, 'node scripts/i18n/codex-auth.cjs sync');
+ const publish = steps.find(step =>
step.run?.includes('workflow-publish.cjs'));
+ assert.equal(publish.if, "steps.translation.outputs.created == 'true' &&
steps.config.outputs.auth_kind != 'none'");
+ assert.equal(publish.env.GH_TOKEN, '${{ steps.app.outputs.token ||
secrets.I18N_GITHUB_PAT }}');
+});
diff --git a/scripts/i18n/__tests__/workflow.test.cjs
b/scripts/i18n/__tests__/workflow.test.cjs
index 5b69fff3fe9..1f6d7f895a5 100644
--- a/scripts/i18n/__tests__/workflow.test.cjs
+++ b/scripts/i18n/__tests__/workflow.test.cjs
@@ -8,9 +8,10 @@ const {hash, loadConfig, stateFileFor, loadRecords, identity}
= require('../stat
const {structure} = require('../structure.cjs');
const {readState, applyCandidates} = require('../translate-docs.cjs');
const {reconcileOpenPrs} = require('../workflow-prs.cjs');
-const {relevantPush} = require('../workflow-scan.cjs');
+const {relevantPush, planInputs} = require('../workflow-scan.cjs');
const {pairFor, machineOwned, generate, allowGeneration} =
require('../workflow-generate.cjs');
const {expectedFiles, authorFor} = require('../workflow-publish.cjs');
+const {masterDrift, checkMaster, batchWide, pageFiles} =
require('../master-drift.cjs');
// The language facts come from the real configuration, so the tests describe
// the same automation names the pipeline publishes under.
@@ -176,59 +177,84 @@ test('personal token writes commits under its
authenticated owner', () => {
assert.throws(() => authorFor('pat', {login: 'other name', id: 1},
language), /Invalid personal/);
});
-test('one model result creates separate Dev and 4.x targets and state
records', async () => {
+// One pending Dev page whose 4.x copy shares its English source.
+function pairFixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ja-workflow-'));
+ t.after(() => fs.rmSync(dir, {recursive: true, force: true}));
const git = (...args) => execFileSync('git', args, {cwd: dir, encoding:
'utf8'}).trim();
- try {
- const source = '# Hello\n\nA simple guide.\n';
- const relative = 'guide.md';
- const devSource = `docs/${relative}`;
- const x4Source = `versioned_docs/version-4.x/${relative}`;
- for (const file of [devSource, x4Source]) {
- fs.mkdirSync(path.dirname(path.join(dir, file)), {recursive:
true});
- fs.writeFileSync(path.join(dir, file), source);
- }
- fs.mkdirSync(path.join(dir, 'translations/scope'), {recursive: true});
- fs.mkdirSync(path.join(dir, 'translations/state/ja'), {recursive:
true});
- fs.writeFileSync(path.join(dir, 'translations/scope/ja.json'),
JSON.stringify(scope));
- fs.writeFileSync(path.join(dir, 'translations/state/ja/docs.json'),
'{"schemaVersion":1,"pages":[]}\n');
- git('init', '-q');
- git('config', 'user.name', 'Test');
- git('config', 'user.email', '[email protected]');
- git('add', '.');
- git('commit', '-qm', 'source');
- const sourceCommit = git('rev-parse', 'HEAD');
- const task = (id, version, sourcePath, targetPath) => ({id, groupId:
version === 'current' ? 'DEV' : '4X',
- pluginId: 'default', version, docId: 'guide', locale: 'ja', kind:
'translate_backlog',
- sourcePath, targetPath, sourceHash: hash(source), sourceCommit,
- requiresHumanReview: false, activePr: null});
- const dev = task('T06-DEV-000000000000001', 'current', devSource,
- 'i18n/ja/docusaurus-plugin-content-docs/current/guide.md');
- const x4 = task('T06-4X-000000000000002', '4.x', x4Source,
- 'i18n/ja/docusaurus-plugin-content-docs/version-4.x/guide.md');
- let calls = 0;
- const adapter = {model: 'fixture', usedTokens: 0, translate: async
({units}) => {
- calls++;
- return {translations: units.map(unit => ({id: unit.id,
- text: unit.text === 'Hello' ? 'こんにちは' : '簡単なガイドです。'}))};
- }};
- const manifest = await generate({rootDir: dir, target, output:
path.join(dir, 'output'),
- ensureFresh: commit => assert.equal(commit, sourceCommit),
- plan: {schemaVersion: 1, locale: 'ja', sourceCommit, selected:
[dev.id], tasks: [dev, x4], exceptions: []},
- reconciliation: {botPrs: [], botDetails: [], blockedTargets: []},
- maxPages: 2, adapter, glossary:
require('../../../translations/glossary/ja.json')});
- assert.equal(calls, 1);
- assert.equal(manifest.candidates.length, 2);
- assert.equal(manifest.provider, 'codex');
- assert.equal(manifest.candidates[1].reusedFrom, dev.id);
- assert.equal(fs.readFileSync(path.join(dir, dev.targetPath), 'utf8'),
- fs.readFileSync(path.join(dir, x4.targetPath), 'utf8'));
- assert.equal(fs.readFileSync(path.join(dir, 'output/candidates',
dev.targetPath), 'utf8'),
- fs.readFileSync(path.join(dir, dev.targetPath), 'utf8'));
- const state = JSON.parse(fs.readFileSync(path.join(dir,
'translations/state/ja/docs.json'), 'utf8'));
- assert.deepEqual(state.pages.map(page => page.version), ['4.x',
'current']);
- assert(state.pages.every(page => page.reviewStatus === 'machine' &&
page.sourceHash === hash(source)));
- } finally {fs.rmSync(dir, {recursive: true, force: true});}
+ const source = '# Hello\n\nA simple guide.\n';
+ const relative = 'guide.md';
+ const devSource = `docs/${relative}`;
+ const x4Source = `versioned_docs/version-4.x/${relative}`;
+ for (const file of [devSource, x4Source]) {
+ fs.mkdirSync(path.dirname(path.join(dir, file)), {recursive: true});
+ fs.writeFileSync(path.join(dir, file), source);
+ }
+ fs.mkdirSync(path.join(dir, 'translations/scope'), {recursive: true});
+ fs.mkdirSync(path.join(dir, 'translations/state/ja'), {recursive: true});
+ fs.writeFileSync(path.join(dir, 'translations/scope/ja.json'),
JSON.stringify(scope));
+ fs.writeFileSync(path.join(dir, 'translations/state/ja/docs.json'),
'{"schemaVersion":1,"pages":[]}\n');
+ git('init', '-q');
+ git('config', 'user.name', 'Test');
+ git('config', 'user.email', '[email protected]');
+ git('add', '.');
+ git('commit', '-qm', 'source');
+ const sourceCommit = git('rev-parse', 'HEAD');
+ const task = (id, version, sourcePath, targetPath) => ({id, groupId:
version === 'current' ? 'DEV' : '4X',
+ pluginId: 'default', version, docId: 'guide', locale: 'ja', kind:
'translate_backlog',
+ sourcePath, targetPath, sourceHash: hash(source), sourceCommit,
+ requiresHumanReview: false, activePr: null});
+ const dev = task('T06-DEV-000000000000001', 'current', devSource,
+ 'i18n/ja/docusaurus-plugin-content-docs/current/guide.md');
+ const x4 = task('T06-4X-000000000000002', '4.x', x4Source,
+ 'i18n/ja/docusaurus-plugin-content-docs/version-4.x/guide.md');
+ let calls = 0;
+ const adapter = {model: 'fixture', usedTokens: 0, translate: async
({units}) => {
+ calls++;
+ return {translations: units.map(unit => ({id: unit.id,
+ text: unit.text === 'Hello' ? 'こんにちは' : '簡単なガイドです。'}))};
+ }};
+ const run = ensureFresh => generate({rootDir: dir, target, output:
path.join(dir, 'output'), ensureFresh,
+ plan: {schemaVersion: 1, locale: 'ja', sourceCommit, selected:
[dev.id], tasks: [dev, x4], exceptions: []},
+ reconciliation: {botPrs: [], botDetails: [], blockedTargets: []},
+ maxPages: 2, adapter, glossary:
require('../../../translations/glossary/ja.json')});
+ return {dir, source, sourceCommit, dev, x4, run, calls: () => calls};
+}
+
+test('one model result creates separate Dev and 4.x targets and state
records', async t => {
+ const {dir, source, sourceCommit, dev, x4, run, calls} = pairFixture(t);
+ const manifest = await run(commit => assert.equal(commit, sourceCommit));
+ assert.equal(calls(), 1);
+ assert.equal(manifest.candidates.length, 2);
+ assert.equal(manifest.provider, 'codex');
+ assert.equal(manifest.candidates[1].reusedFrom, dev.id);
+ assert.equal(fs.readFileSync(path.join(dir, dev.targetPath), 'utf8'),
+ fs.readFileSync(path.join(dir, x4.targetPath), 'utf8'));
+ assert.equal(fs.readFileSync(path.join(dir, 'output/candidates',
dev.targetPath), 'utf8'),
+ fs.readFileSync(path.join(dir, dev.targetPath), 'utf8'));
+ const state = JSON.parse(fs.readFileSync(path.join(dir,
'translations/state/ja/docs.json'), 'utf8'));
+ assert.deepEqual(state.pages.map(page => page.version), ['4.x',
'current']);
+ assert(state.pages.every(page => page.reviewStatus === 'machine' &&
page.sourceHash === hash(source)));
+});
+
+test('a page changed on master after its model call is dropped together with
its Dev/4.x copy', async t => {
+ const {dir, dev, x4, run, calls} = pairFixture(t);
+ const seen = [];
+ // Before the batch, before the page, then after it: only the last check
sees the change.
+ const manifest = await run((commit, pages) => {
+ seen.push(pages);
+ if (seen.length === 3) return {head: 'b'.repeat(40), moved: true,
files: [dev.sourcePath],
+ batchWide: [], affected: [dev.id]};
+ });
+ assert.equal(calls(), 1);
+ assert.deepEqual([...seen[0].keys()].sort(), [dev.id, x4.id].sort());
+ assert.deepEqual(seen[0].get(x4.id), [x4.sourcePath, x4.targetPath,
stateFileFor(x4)]);
+ assert.deepEqual(manifest.candidates, []);
+ assert.deepEqual(manifest.failures.map(item => item.id), [dev.id, x4.id]);
+ assert.match(manifest.failures[0].error, /master changed this page/);
+ assert.deepEqual(manifest.master, {head: 'b'.repeat(40), moved: true,
changedFiles: 1});
+ for (const task of [dev, x4]) assert(!fs.existsSync(path.join(dir,
task.targetPath)));
+ assert(!fs.existsSync(path.join(dir, 'output/pr-body.md')));
});
async function updateFixture(t, {sharded = true, human = false,
@@ -513,7 +539,9 @@ test('publication deduplicates shared state and rejects an
unrelated page shard'
[record.targetPath, second.targetPath,
'translations/state/ja/docs.json'].sort());
});
-test('normal generation skips open PRs and blocked targets, then generates ten
other pages', async t => {
+// Fourteen pending pages; the first is in an open PR, the second is reserved
+// by another PR's candidate and the third is blocked.
+function pendingPagesFixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(),
'translation-ten-page-batch-'));
t.after(() => fs.rmSync(root, {recursive: true, force: true}));
const put = (file, text) => {
@@ -543,15 +571,21 @@ test('normal generation skips open PRs and blocked
targets, then generates ten o
calls++;
return {translations: units.map(unit => ({id: unit.id, text: '訳' +
unit.text}))};
}};
- const manifest = await generate({rootDir: root, target, output:
path.join(root, 'output'),
+ const run = ensureFresh => generate({rootDir: root, target, output:
path.join(root, 'output'),
plan: {schemaVersion: 1, locale: 'ja', sourceCommit, selected:
tasks.map(task => task.id), tasks, exceptions: []},
reconciliation: {botPrs: [21, 22], botDetails: [{number: 21,
untouched: false}, {number: 22, untouched: true}],
active: {candidates: [{...tasks[1], sourceHash: hash('older
source'), pr: '#22'}]},
- blockedTargets: [tasks[2].targetPath]}, ensureFresh: () => {},
+ blockedTargets: [tasks[2].targetPath]}, ensureFresh,
adapter, glossary: require('../../../translations/glossary/ja.json')});
+ return {root, tasks, run, calls: () => calls};
+}
+
+test('normal generation skips open PRs and blocked targets, then generates ten
other pages', async t => {
+ const {root, tasks, run, calls} = pendingPagesFixture(t);
+ const manifest = await run(() => {});
assert.equal(manifest.candidates.length, 10);
assert.equal(manifest.failures.length, 0);
- assert.equal(calls, 10);
+ assert.equal(calls(), 10);
assert.equal(loadRecords(root, 'ja').size, 10);
assert.deepEqual(manifest.candidates.map(item => item.id), tasks.slice(3,
13).map(item => item.id));
for (const task of [tasks[0], tasks[1], tasks[2], tasks[13]]) {
@@ -559,4 +593,155 @@ test('normal generation skips open PRs and blocked
targets, then generates ten o
assert(!fs.existsSync(path.join(root, stateFileFor(task))));
}
assert.equal(manifest.replacesPr, undefined);
+ assert.equal(manifest.master, undefined);
+});
+
+test('a page master changed before its turn is skipped without a model call
and the batch continues', async t => {
+ const {root, tasks, run, calls} = pendingPagesFixture(t);
+ let checks = 0;
+ const drift = {head: 'a'.repeat(40), moved: true, files:
[tasks[4].sourcePath, 'blog/post.md'],
+ batchWide: [], affected: [tasks[4].id]};
+ // One check before the batch, one before each page that reaches the model
and
+ // one after the last; the check before page 4 is the first to see the
change.
+ const manifest = await run(() => ++checks >= 3 ? drift : undefined);
+ assert.equal(calls(), 10);
+ assert.equal(checks, 13);
+ assert.deepEqual(manifest.candidates.map(item => item.id), [tasks[3],
...tasks.slice(5, 14)].map(item => item.id));
+ assert.deepEqual(manifest.failures.map(item => item.id), [tasks[4].id]);
+ assert(!fs.existsSync(path.join(root, tasks[4].targetPath)));
+ assert.deepEqual(manifest.master, {head: drift.head, moved: true,
changedFiles: 2});
+ assert.match(fs.readFileSync(path.join(root, 'output/pr-body.md'), 'utf8'),
+ new RegExp(`Master moved to \`${drift.head}\` during generation; none
of its 2 changed files affect this batch\\.`));
+});
+
+test('a batch-wide master change stops generation before any model call',
async t => {
+ // Seen before the batch, or before the page: not a page failure either
way.
+ for (const failingCheck of [1, 2]) {
+ const fixture = await updateFixture(t);
+ let checks = 0;
+ await assert.rejects(fixture.run({ensureFresh: () => {
+ if (++checks === failingCheck) throw new Error('master changed
translations/glossary/ja.json after the source commit');
+ }}), /translations\/glossary\/ja\.json/);
+ assert.equal(checks, failingCheck);
+ assert.equal(fixture.calls(), 0);
+ assert(!fs.existsSync(path.join(fixture.root,
'output/generation.json')));
+ assert.equal(fs.readFileSync(path.join(fixture.root,
record.targetPath), 'utf8'), fixture.previousTarget);
+ }
+});
+
+test('a batch-wide master change after the last page discards the batch and
keeps its manifest', async t => {
+ const fixture = await updateFixture(t);
+ let checks = 0;
+ await assert.rejects(fixture.run({ensureFresh: () => {
+ if (++checks === 3) throw new Error('master changed
scripts/i18n/translate-docs.cjs after the source commit');
+ }}), /scripts\/i18n\/translate-docs\.cjs/);
+ assert.equal(fixture.calls(), 1);
+ const manifest = JSON.parse(fs.readFileSync(path.join(fixture.root,
'output/generation.json'), 'utf8'));
+ assert.match(manifest.discarded, /scripts\/i18n\/translate-docs\.cjs/);
+ assert.equal(fs.readFileSync(path.join(fixture.root, record.targetPath),
'utf8'), fixture.previousTarget);
+ assert(!fs.existsSync(path.join(fixture.root, 'output/pr-body.md')));
+});
+
+// A source commit on a bare "origin" plus a second clone that advances master.
+function driftFixture(t) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(),
'translation-master-drift-'));
+ t.after(() => fs.rmSync(dir, {recursive: true, force: true}));
+ const remote = path.join(dir, 'remote.git'), root = path.join(dir,
'root'), other = path.join(dir, 'other');
+ const git = (cwd, ...args) => execFileSync('git', ['-c', 'user.name=Test',
'-c', '[email protected]',
+ '-c', 'init.defaultBranch=master', ...args], {cwd, encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']}).trim();
+ const write = (cwd, file, text) => {
+ fs.mkdirSync(path.dirname(path.join(cwd, file)), {recursive: true});
+ fs.writeFileSync(path.join(cwd, file), text);
+ };
+ git(dir, 'init', '-q', '--bare', remote);
+ git(dir, 'init', '-q', root);
+ for (const [file, text] of [['docs/guide.md', '# Source\n'],
[record.targetPath, japanese],
+ [stateFileFor(record), '{}\n'], ['translations/glossary/ja.json',
'{}\n'], ['blog/post.md', '# Post\n']])
+ write(root, file, text);
+ git(root, 'add', '.');
+ git(root, 'commit', '-qm', 'source');
+ git(root, 'push', '-q', remote, 'HEAD:refs/heads/master');
+ const sourceCommit = git(root, 'rev-parse', 'HEAD');
+ git(dir, 'clone', '-q', remote, other);
+ const advance = (file, text) => {
+ if (text === null) git(other, 'rm', '-q', file);
+ else write(other, file, text);
+ git(other, 'add', '-A');
+ git(other, 'commit', '-qm', `change ${file}`);
+ git(other, 'push', '-q', 'origin', 'HEAD:master');
+ };
+ const pages = new Map([['T1', pageFiles(record, 'ja')]]);
+ const options = {root, sourceCommit, locale: 'ja', pages, remote};
+ return {git, other, remote, advance, options};
+}
+
+test('master drift allows unrelated merges and reports a changed or moved
batch page', t => {
+ const {git, other, advance, options} = driftFixture(t);
+ assert.deepEqual(masterDrift(options), {head: options.sourceCommit, moved:
false, files: [], batchWide: [], affected: []});
+ advance('blog/post.md', '# Post 2\n');
+ let drift = checkMaster(options);
+ assert.equal(drift.moved, true);
+ assert.deepEqual([drift.files, drift.batchWide, drift.affected],
[['blog/post.md'], [], []]);
+ git(other, 'mv', 'docs/guide.md', 'docs/moved.md');
+ advance('docs/moved.md', '# Source\n');
+ drift = masterDrift(options);
+ // A move reports its old path, so the page that lost its source is
affected.
+ assert.deepEqual(drift.files, ['blog/post.md', 'docs/guide.md',
'docs/moved.md']);
+ assert.deepEqual(drift.affected, ['T1']);
+});
+
+test('master drift discards the batch for translation configuration or tooling
changes', t => {
+ const {advance, options} = driftFixture(t);
+ advance('translations/glossary/ja.json', '{"version": "2"}\n');
+ assert.deepEqual(masterDrift(options).batchWide,
['translations/glossary/ja.json']);
+ assert.throws(() => checkMaster(options), /master changed
translations\/glossary\/ja\.json after the source commit/);
+ const wide = batchWide('ja');
+ for (const file of ['translations/config.json', 'config/i18n.js',
'docusaurus.config.js', 'versions.json',
+ 'translations/scope/ja.json',
'translations/protection-exceptions/ja.json', 'translations/state/ja/docs.json',
+ 'translations/state/ja/shards.schema.json',
'translations/inventory/ja/2026-10-12/summary.json',
+ 'scripts/i18n/workflow-generate.cjs']) assert(wide(file), file);
+ for (const file of [stateFileFor(record), 'translations/glossary/ko.json',
'translations/state/ko/docs.json',
+ 'i18n/ja/code.json', 'sidebars.ts', 'blog/post.md',
'scripts/check_move.js']) assert(!wide(file), file);
+});
+
+test('master drift stops when master no longer contains the source commit', t
=> {
+ const {git, other, options} = driftFixture(t);
+ git(other, 'checkout', '-q', '--orphan', 'rewritten');
+ git(other, 'commit', '-qm', 'rewritten history');
+ git(other, 'push', '-q', '--force', 'origin', 'HEAD:master');
+ assert.throws(() => masterDrift(options), /master no longer contains the
source commit/);
+});
+
+test('a plan is reused only for the same checkout, reservations and filters',
() => {
+ const head = 'a'.repeat(40), active =
'{"schemaVersion":1,"candidates":[],"blockedTargets":[]}\n';
+ const base = planInputs(head, active, {});
+ assert.equal(base, planInputs(head, active, {I18N_LIMIT: '20', I18N_MODE:
'generate'}));
+ for (const changed of [planInputs('b'.repeat(40), active, {}),
+ planInputs(head, active.replace('[]}', '["i18n/ja/x.md"]}'), {}),
+ planInputs(head, active, {I18N_PATHS: '["docs/guide.md"]'}),
planInputs(head, active, {I18N_PLUGIN: 'community'}),
+ planInputs(head, active, {I18N_LIMIT: '5'})]) assert.notEqual(changed,
base);
+});
+
+test('the generation replan reuses an unchanged discovery plan without
recounting', t => {
+ const output = fs.mkdtempSync(path.join(os.tmpdir(),
'translation-plan-reuse-'));
+ t.after(() => fs.rmSync(output, {recursive: true, force: true}));
+ const head = execFileSync('git', ['rev-parse', 'HEAD'], {cwd: repoRoot,
encoding: 'utf8'}).trim();
+ const active = '{"schemaVersion":1,"candidates":[],"blockedTargets":[]}\n';
+ fs.writeFileSync(path.join(output, 'active-candidates.json'), active);
+ fs.writeFileSync(path.join(output, 'open-pr-reconciliation.json'),
JSON.stringify({schemaVersion: 1,
+ blockedTargets: [], botPrs: [], openPrCount: 3, relevantPrs: [],
exceptions: []}));
+ fs.writeFileSync(path.join(output, 'coverage.json'), '{}\n');
+ const env = {...process.env, I18N_LOCALE: 'ko', I18N_MODE: 'generate',
GITHUB_EVENT_NAME: 'schedule',
+ GITHUB_OUTPUT: path.join(output, 'github-output'),
GITHUB_STEP_SUMMARY: path.join(output, 'summary.md')};
+ for (const key of ['I18N_PLUGIN', 'I18N_VERSION', 'I18N_PATH',
'I18N_PATHS', 'I18N_LIMIT']) delete env[key];
+ // A plan the planner could not have produced proves that nothing
recounted it.
+ fs.writeFileSync(path.join(output, 'plan.json'),
JSON.stringify({schemaVersion: 1, locale: 'ko', sourceCommit: head,
+ counts: {required: 1, mergedCurrent: 0, pending: 1, activeCandidates:
0}, selected: ['T-REUSED'],
+ tasks: [{id: 'T-REUSED'}], exceptions: [], planInputs:
planInputs(head, active, env)}));
+ const started = Date.now();
+ execFileSync(process.execPath, [path.join(repoRoot,
'scripts/i18n/workflow-scan.cjs'), output], {env});
+ assert(Date.now() - started < 30000);
+ assert.equal(fs.readFileSync(env.GITHUB_OUTPUT, 'utf8'),
`can_generate=true\nsource_commit=${head}\n`);
+ assert.match(fs.readFileSync(env.GITHUB_STEP_SUMMARY, 'utf8'), /Plan:
reused from discovery/);
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(output,
'plan.json'), 'utf8')).selected, ['T-REUSED']);
});
diff --git a/scripts/i18n/master-drift.cjs b/scripts/i18n/master-drift.cjs
new file mode 100644
index 00000000000..cf5bdf442dd
--- /dev/null
+++ b/scripts/i18n/master-drift.cjs
@@ -0,0 +1,57 @@
+// Whether master moved since a batch's source commit, and whether the move
+// touches anything the batch depends on. Runner queues and model calls take
+// long enough that unrelated merges are common, so only these decide:
+// a changed batch page drops that page, while a change to translation
+// configuration, routing identity or the pipeline itself invalidates every
+// candidate and discards the batch.
+const assert = require('node:assert/strict');
+const {execFileSync} = require('node:child_process');
+const {stateFileFor} = require('./state.cjs');
+
+function repositoryUrl(repository = process.env.GITHUB_REPOSITORY ||
'apache/doris-website') {
+ assert.equal(repository, 'apache/doris-website', 'Unexpected repository');
+ return `https://github.com/${repository}.git`;
+}
+
+function batchWide(locale) {
+ const files = new Set(['translations/config.json', 'config/i18n.js',
'docusaurus.config.js', 'versions.json',
+ `translations/scope/${locale}.json`,
`translations/glossary/${locale}.json`,
+ `translations/protection-exceptions/${locale}.json`]);
+ // Top-level state files: the legacy ledger, resolved deletions and the
shard schema.
+ const state = new RegExp(`^translations/state/${locale}/[^/]+$`);
+ return file => files.has(file) || state.test(file) ||
file.startsWith('scripts/i18n/')
+ || file.startsWith(`translations/inventory/${locale}/`);
+}
+
+// Every file one page of a batch reads or writes.
+function pageFiles(task, locale) {
+ return [task.sourcePath, task.targetPath, stateFileFor({...task, locale})];
+}
+
+// `pages` maps a task or candidate ID to its page files.
+function masterDrift({root, sourceCommit, locale, pages = new Map(), remote =
repositoryUrl()}) {
+ const git = (...args) => execFileSync('git', args, {cwd: root, encoding:
'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 *
1024}).trim();
+ const head = git('ls-remote', remote, 'refs/heads/master').split(/\s+/)[0];
+ assert(/^[a-f0-9]{40}$/.test(head), 'Cannot read the remote master');
+ if (head === sourceCommit) return {head, moved: false, files: [],
batchWide: [], affected: []};
+ git('fetch', '--no-tags', '--quiet', remote, 'refs/heads/master');
+ const fetched = git('rev-parse', 'FETCH_HEAD');
+ try {git('merge-base', '--is-ancestor', sourceCommit, fetched);}
+ catch {throw new Error('master no longer contains the source commit;
discard this batch and rescan');}
+ // Without rename detection a moved file reports both its old and new path.
+ const files = git('diff', '--name-only', '--no-renames', '-z',
sourceCommit, fetched).split('\0').filter(Boolean);
+ const changed = new Set(files);
+ return {head: fetched, moved: true, files, batchWide:
files.filter(batchWide(locale)),
+ affected: [...pages].filter(([, paths]) => paths.some(file =>
changed.has(file))).map(([id]) => id)};
+}
+
+// Generation and publication stop on batch-wide changes and report the rest.
+function checkMaster(options) {
+ const drift = masterDrift(options);
+ assert(!drift.batchWide.length, `master changed ${drift.batchWide.slice(0,
3).join(', ')}`
+ + (drift.batchWide.length > 3 ? ' and more' : '') + ' after the source
commit; discard this batch and rescan');
+ return drift;
+}
+
+module.exports = {repositoryUrl, batchWide, pageFiles, masterDrift,
checkMaster};
diff --git a/scripts/i18n/simulate-workflow.cjs
b/scripts/i18n/simulate-workflow.cjs
index 6695c7d9335..9870775dab8 100644
--- a/scripts/i18n/simulate-workflow.cjs
+++ b/scripts/i18n/simulate-workflow.cjs
@@ -20,6 +20,11 @@ const scenario = process.env.I18N_SIM_SCENARIO || 'retry';
assert([1, 10].includes(pages), 'Simulation supports one or ten pages');
assert(['success', 'retry', 'fallback', 'incremental'].includes(scenario),
'Unknown simulation scenario');
assert(scenario !== 'incremental' || pages === 1, 'Incremental simulation uses
one page');
+// A merge that lands on master after discovery: unrelated, to the selected
page,
+// or to the translation configuration.
+const drift = process.env.I18N_SIM_MASTER_DRIFT || '';
+assert(['', 'unrelated', 'page', 'config'].includes(drift), 'Unknown master
drift');
+assert(!drift || pages === 1, 'Master drift simulation uses one page');
function command(binary, args, options = {}) {
return execFileSync(binary, args, {encoding: 'utf8', ...options}).trim();
@@ -50,6 +55,8 @@ const {extractUnits} =
require(${JSON.stringify(path.join(repo, 'scripts/i18n/tr
const units = await extractUnits(translation,
${JSON.stringify(path.extname(sourcePath))});
for (const unit of [...units].reverse()) translation =
translation.slice(0, unit.start)
+ '訳' + unit.text + translation.slice(unit.end);
+ // Like a real model, localize a declared front matter language.
+ translation = translation.replace(/("language":\\s*")en(")/, '$1ja$2');
if (${JSON.stringify(mode)} === 'invalid') translation = '';
if (${JSON.stringify(mode)} === 'retry' && payload.feedback.length === 0)
translation = translation.replace(/^#{1,6} .*$/gm, '');
@@ -179,6 +186,44 @@ async function main() {
runNode(repo, 'scripts/i18n/workflow-scan.cjs', [scan], env, logs);
const plan = JSON.parse(fs.readFileSync(path.join(scan, 'plan.json'),
'utf8'));
assert.equal(plan.selected.length, pages);
+ if (drift) {
+ const other = path.join(output, 'other');
+ command('git', ['clone', '--shared', '-q', remote, other]);
+ command('git', ['config', '--local', 'user.name', 'Local Simulator'],
{cwd: other});
+ command('git', ['config', '--local', 'user.email',
'[email protected]'], {cwd: other});
+ const file = {unrelated: 'blog/translation-simulation-drift.md',
config: 'translations/glossary/ja.json',
+ page: plan.tasks.find(task => task.id ===
plan.selected[0]).sourcePath}[drift];
+ fs.mkdirSync(path.dirname(path.join(other, file)), {recursive: true});
+ fs.appendFileSync(path.join(other, file), drift === 'config' ? '\n' :
'\nMaster drift simulation.\n');
+ command('git', ['add', '.'], {cwd: other});
+ command('git', ['commit', '-qm', `Master drift simulation: ${drift}`],
{cwd: other});
+ command('git', ['push', '-q', 'origin', 'HEAD:master'], {cwd: other});
+ }
+ if (drift === 'page' || drift === 'config') {
+ // Neither model sees a request: the page is skipped, or the batch
stops first.
+ const results = ['invalid', 'valid'].map(kind => {
+ const result = runNode(repo, 'scripts/i18n/workflow-generate.cjs',
[scan],
+ {...env, CODEX_BIN: path.join(bin, `codex-${kind}`),
I18N_SIM_PHASE: `-${kind}`}, logs, 1);
+ const file = path.join(scan, 'generation.json');
+ if (drift === 'config') {
+ assert.match(result.stderr, /master changed
translations\/glossary\/ja\.json after the source commit/);
+ assert(!fs.existsSync(file));
+ return {exitCode: result.status, error: result.stderr.trim()};
+ }
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
+ assert.equal(manifest.candidates.length, 0);
+ assert.equal(manifest.usedTokens, 0);
+ assert.match(manifest.failures[0].error, /master changed this page
after the source commit/);
+ assert.equal(manifest.master.moved, true);
+ return {exitCode: result.status, error:
manifest.failures[0].error, master: manifest.master};
+ });
+ const report = {kind: 'local simulation; no external model or GitHub
writes', output, drift,
+ sourceCommit: plan.sourceCommit, selected: plan.selected,
sourcePath: selectedPath, generation: results,
+ pullRequest: null};
+ fs.writeFileSync(path.join(output, 'report.json'),
JSON.stringify(report, null, 2) + '\n');
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
+ return;
+ }
const failed = {...env, CODEX_BIN: path.join(bin, 'codex-invalid'),
I18N_SIM_PHASE: '-invalid'};
runNode(repo, 'scripts/i18n/workflow-generate.cjs', [scan], failed, logs,
pages === 1 ? 1 : 0);
const failure = JSON.parse(fs.readFileSync(path.join(scan,
'generation.json'), 'utf8'));
@@ -201,6 +246,12 @@ async function main() {
runNode(repo, 'scripts/i18n/workflow-publish.cjs', [scan], env, logs);
const pr = JSON.parse(fs.readFileSync(env.I18N_SIM_PR_RECORD, 'utf8'));
assert.match(pr.body, /Shared PR admission checks: passed/);
+ if (drift === 'unrelated') {
+ // Published from the source commit; the PR checks run against current
master.
+ assert.equal(generation.master.moved, true);
+ assert.match(pr.body, /Master moved to `[a-f0-9]{40}` during
generation; none of its 1 changed files affect this batch/);
+ assert.match(pr.body, /Master was at `[a-f0-9]{40}` when publishing;
none of its 1 changed files/);
+ } else assert.equal(generation.master.moved, false);
assert.equal(pr.head, `i18n/ja-automated-${runId}`);
const branch = command('git', ['rev-parse', `refs/heads/${pr.head}`],
{cwd: remote});
const candidate = generation.candidates[0];
@@ -217,7 +268,8 @@ async function main() {
const expectedFiles = [...new Set(generation.candidates.flatMap(item =>
[item.targetPath, item.statePath]))].sort();
const committedFiles = command('git', ['diff', '--name-only',
plan.sourceCommit, branch], {cwd: repo}).split('\n').sort();
assert.deepEqual(committedFiles, expectedFiles);
- const report = {kind: 'local simulation; no external model or GitHub
writes', output,
+ const report = {kind: 'local simulation; no external model or GitHub
writes', output, drift: drift || null,
+ master: generation.master,
sourceCommit: plan.sourceCommit, selected: plan.selected, sourcePath:
selectedPath, scenario,
invalidModel: {exitCode: pages === 1 ? 1 : 0, error:
failure.failures[0].error, usedTokens: failure.usedTokens},
generation: {candidates: generation.candidates.length, failures:
generation.failures.length,
diff --git a/scripts/i18n/workflow-generate.cjs
b/scripts/i18n/workflow-generate.cjs
index 9a6b4bde2f9..635726e3e87 100644
--- a/scripts/i18n/workflow-generate.cjs
+++ b/scripts/i18n/workflow-generate.cjs
@@ -9,6 +9,7 @@ const {checkedTask, managedPath, safeFile} =
require('./translation-engine.cjs')
const {codexAdapter} = require('./codex-adapter.cjs');
const {prepare, applyCandidates, readState, stagedBaseline,
statePathForRecord, targetFor} = require('./translate-docs.cjs');
const {markerPrefixFor} = require('./automation-naming.cjs');
+const {checkMaster, pageFiles} = require('./master-drift.cjs');
const root = path.resolve(__dirname, '../..');
const allowedKinds = new Set(['translate_new', 'translate_backlog', 'update']);
@@ -55,14 +56,6 @@ function copyPair(primary, partner, scope, state, rootDir =
root) {
requests: 0, reusedFrom: primary.task.id};
}
-function ensureMasterFresh(sourceCommit) {
- const repository = process.env.GITHUB_REPOSITORY || 'apache/doris-website';
- assert.equal(repository, 'apache/doris-website', 'Unexpected repository');
- const actual = execFileSync('git', ['ls-remote',
`https://github.com/${repository}.git`, 'refs/heads/master'],
- {encoding: 'utf8'}).trim().split(/\s+/)[0];
- assert.equal(actual, sourceCommit, 'master advanced during generation;
discard this batch and rescan');
-}
-
function bodyFor(manifest) {
const rows = manifest.candidates.map(item => {
const review = item.generation;
@@ -79,8 +72,10 @@ function bodyFor(manifest) {
return `- \`${item.sourcePath}\`: third attempt retained for
review. `
+ detail.slice(0, 1500) + (detail.length > 1500 ? '…' : '');
});
+ const master = manifest.master?.moved ? [`Master moved to
\`${manifest.master.head}\` during generation; `
+ + `none of its ${manifest.master.changedFiles} changed files affect
this batch.`] : [];
return [`## ${manifest.language} translation candidate`, '',
- `Source commit: \`${manifest.sourceCommit}\``,
+ `Source commit: \`${manifest.sourceCommit}\``, ...master,
`Model: \`${manifest.model}\`; glossary:
\`${manifest.glossaryVersion}\``,
`Review status: machine. ${manifest.language} proofreading and the
normal PR checks are still required.`,
'', '| Action | Version | English source | SHA-256 prefix | Generation
|',
@@ -92,14 +87,16 @@ function bodyFor(manifest) {
'Generation uses at most three attempts per document. Deletions and
code-only updates may need no model call.',
'Each retry receives the earlier validation errors. Third-attempt
content issues are retained as warnings above.',
'Full attempt history and diagnostics are in generation.json and the
page state records.',
- 'Committed source hashes, managed paths, target/state hashes and the
master SHA are checked before applying candidates.',
+ 'Committed source hashes, managed paths and target/state hashes are
checked before applying candidates.',
+ 'Master is checked before each model call and before publishing:
unrelated merges are allowed, a page changed on master is dropped, and a
translation configuration or tooling change discards the batch.',
'The publisher runs the shared PR translation-state, syntax and
internal-link checks before pushing.',
'The normal PR build and human proofreading are still required before
merging.',
'', `${markerPrefixFor(manifest.language)}PENDING -->`, ''].join('\n');
}
-async function generate({plan, reconciliation, maxPages = 10, target,
- adapter, glossary, rootDir = root, ensureFresh = ensureMasterFresh,
output}) {
+async function generate({plan, reconciliation, maxPages = 10, target, adapter,
glossary, rootDir = root,
+ ensureFresh = (sourceCommit, pages) => checkMaster({root: rootDir,
sourceCommit, locale: target.locale, pages}),
+ output}) {
assert(Number.isInteger(maxPages) && maxPages > 0 && maxPages <= 20,
'Invalid page limit');
assert(plan.schemaVersion === 1 && plan.sourceCommit ===
execFileSync('git', ['rev-parse', 'HEAD'],
{cwd: rootDir, encoding: 'utf8'}).trim(), 'Plan does not match trusted
checkout');
@@ -108,6 +105,22 @@ async function generate({plan, reconciliation, maxPages =
10, target,
const blocked = new Set(reconciliation.blockedTargets);
const active = new Set((reconciliation.active?.candidates ??
[]).map(identity));
const candidates = [], failures = [], consumed = new Set();
+ // Master is checked before the first model call, before each page and
after
+ // the last one, so no model call goes to a page master has already
changed.
+ const pages = new Map();
+ for (const task of plan.selected.map(id => plan.tasks.find(item => item.id
=== id)).filter(Boolean)) {
+ for (const item of [task, pairFor(task, plan.tasks,
scope)].filter(Boolean))
+ pages.set(item.id, pageFiles(item, target.locale));
+ }
+ const stale = new Set();
+ let drift = {moved: false};
+ const refresh = () => {
+ drift = ensureFresh(plan.sourceCommit, pages) ?? drift;
+ for (const id of drift.affected ?? []) stale.add(id);
+ };
+ const staleFailure = task => ({id: task.id, sourcePath: task.sourcePath,
+ error: 'master changed this page after the source commit; it stays
pending for the next run'});
+ refresh();
for (const id of plan.selected) {
if (candidates.length >= maxPages) break;
const task = plan.tasks.find(item => item.id === id);
@@ -122,11 +135,17 @@ async function generate({plan, reconciliation, maxPages =
10, target,
if (partner && (active.has(identity(partner)) ||
blocked.has(partner.targetPath) || !machineOwned(partner, state)
|| state.data.pages.some(record => identity(record) ===
identity(partner))
|| candidates.length + 2 > maxPages)) partner = null;
+ // Outside the per-page catch: a batch-wide change must stop the run.
+ refresh();
+ if (stale.has(id)) {
+ failures.push(staleFailure(task));
+ continue;
+ }
try {
checkedTask(plan, id, scope, rootDir);
const primary = await prepare({root: rootDir, plan, taskId: id,
scope, state, adapter, glossary, target});
candidates.push(primary);
- if (partner) try {
+ if (partner && !stale.has(partner.id)) try {
const copy = copyPair(primary, partner, scope, state, rootDir);
candidates.push(copy);
consumed.add(partner.id);
@@ -137,10 +156,20 @@ async function generate({plan, reconciliation, maxPages =
10, target,
failures.push({id, sourcePath: task.sourcePath, error:
error.message});
}
}
+ let discarded = null;
+ if (candidates.length) {
+ try {refresh();} catch (error) {discarded = error;}
+ // A copy shares its primary's translation, so it goes with it.
+ const dropped = candidates.filter(item => stale.has(item.task.id) ||
stale.has(item.reusedFrom));
+ for (const item of dropped) failures.push(staleFailure(item.task));
+ candidates.splice(0, candidates.length, ...candidates.filter(item =>
!dropped.includes(item)));
+ }
const manifest = {schemaVersion: 1, sourceCommit: plan.sourceCommit,
provider: 'codex',
locale: target.locale, language: target.language,
model: adapter.model, glossaryVersion: glossary.version, liveCall:
adapter.usedTokens > 0,
liveVerified: false, usedTokens: adapter.usedTokens,
+ ...(drift.head ? {master: {head: drift.head, moved: drift.moved,
changedFiles: drift.files.length}} : {}),
+ ...(discarded ? {discarded: discarded.message} : {}),
candidates: candidates.map(item => ({id: item.task.id, kind:
item.task.kind,
pluginId: item.task.pluginId, version: item.task.version, docId:
item.task.docId,
sourcePath: item.task.sourcePath, sourceHash: item.task.sourceHash,
@@ -150,8 +179,8 @@ async function generate({plan, reconciliation, maxPages =
10, target,
...(item.record.generation ? {generation: item.record.generation}
: {})})), failures};
fs.mkdirSync(output, {recursive: true});
fs.writeFileSync(path.join(output, 'generation.json'),
JSON.stringify(manifest, null, 2) + '\n');
+ if (discarded) throw discarded;
if (candidates.length) {
- ensureFresh(plan.sourceCommit);
for (const item of candidates) checkedTask({...plan, selected:
[...plan.selected, item.task.id]},
item.task.id, scope, rootDir);
for (const item of candidates) {
@@ -195,7 +224,9 @@ async function main() {
`created=${manifest.candidates.length > 0}\n`);
if (process.env.GITHUB_STEP_SUMMARY)
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY,
`\nGenerated ${manifest.candidates.length} ${manifest.language} page
candidates at ${manifest.sourceCommit}; `
- + `${manifest.failures.length} failed or need manual handling. No
translation is merged yet.\n`);
+ + `${manifest.failures.length} failed or need manual handling. No
translation is merged yet.\n`
+ + (manifest.master?.moved ? `Master moved to ${manifest.master.head}
meanwhile; none of its `
+ + `${manifest.master.changedFiles} changed files affect the
remaining candidates.\n` : ''));
process.stdout.write(JSON.stringify({candidates:
manifest.candidates.length,
failures: manifest.failures.length, usedTokens: manifest.usedTokens})
+ '\n');
if (process.env.I18N_MODE === 'validate' && manifest.candidates.length ===
0)
diff --git a/scripts/i18n/workflow-publish.cjs
b/scripts/i18n/workflow-publish.cjs
index 2a3fc7bdad6..0897f8f6f8f 100644
--- a/scripts/i18n/workflow-publish.cjs
+++ b/scripts/i18n/workflow-publish.cjs
@@ -7,6 +7,7 @@ const {languageName, pipelineLocale, stateFileFor} =
require('./state.cjs');
const {managedPath} = require('./translation-engine.cjs');
const {statePathFor} = require('./workflow-prs.cjs');
const {botBranchFor, botBranchPattern} = require('./automation-naming.cjs');
+const {checkMaster} = require('./master-drift.cjs');
const root = path.resolve(__dirname, '../..');
const git = (...args) => execFileSync('git', args, {cwd: root, encoding:
'utf8'}).trim();
@@ -74,10 +75,15 @@ async function main() {
const body = fs.readFileSync(bodyFile, 'utf8');
assert(body.includes('bot-head=PENDING'), 'Missing PR ownership marker');
await require('./pr-preflight.cjs').preflight({root, base:
manifest.sourceCommit, locale});
+ // The branch stays on the source commit; the PR checks run against
current master.
+ const drift = checkMaster({root, sourceCommit: manifest.sourceCommit,
locale,
+ pages: new Map(manifest.candidates.map(item => [item.id,
+ [item.sourcePath, item.targetPath, item.statePath ??
statePathFor(locale)]]))});
+ assert(!drift.affected.length, `master changed ${drift.affected.join(',
')} after the source commit; rescan required`);
fs.writeFileSync(bodyFile, body.replace('bot-head=PENDING',
`bot-head=${newHead}`)
- + `\nShared PR admission checks: passed (translation state, syntax,
internal links).\n`);
- const remoteMaster = git('ls-remote', 'origin',
'refs/heads/master').split(/\s+/)[0];
- assert.equal(remoteMaster, manifest.sourceCommit, 'master advanced before
publish; rescan required');
+ + `\nShared PR admission checks: passed (translation state, syntax,
internal links).\n`
+ + (drift.moved ? `Master was at \`${drift.head}\` when publishing;
none of its ${drift.files.length} `
+ + 'changed files since the source commit affect this batch.\n' :
''));
// gh configures Git's credential helper from GH_TOKEN. The token is never
// placed in a remote URL, command argument, log or generated file.
execFileSync('gh', ['auth', 'setup-git'], {cwd: root, stdio: 'inherit'});
diff --git a/scripts/i18n/workflow-scan.cjs b/scripts/i18n/workflow-scan.cjs
index 32adc3d5a2a..68721231157 100644
--- a/scripts/i18n/workflow-scan.cjs
+++ b/scripts/i18n/workflow-scan.cjs
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const {execFileSync} = require('node:child_process');
-const {languageName, pipelineLocale} = require('./state.cjs');
+const {hash, languageName, pipelineLocale} = require('./state.cjs');
const root = path.resolve(__dirname, '../..');
const configInputs = ['docusaurus.config.js', 'config/i18n.js', 'sidebars.ts',
'sidebarsCommunity.json',
@@ -34,7 +34,14 @@ function changedPaths(before, head) {
catch {return null;}
}
-function summarize(plan, reconciliation, eligible, mode, language) {
+// Everything a plan depends on besides the planner itself: the checkout, the
+// open-PR reservations (blocked targets included) and the requested filters.
+function planInputs(head, activeCandidates, env = process.env) {
+ return hash(JSON.stringify({head, activeCandidates, filters:
['I18N_PLUGIN', 'I18N_VERSION', 'I18N_PATH', 'I18N_PATHS']
+ .map(key => env[key] || '').concat(env.I18N_LIMIT || '20')}));
+}
+
+function summarize(plan, reconciliation, eligible, mode, language, reused =
false) {
const exceptionKinds = new Map();
for (const item of [...plan.exceptions, ...reconciliation.exceptions])
exceptionKinds.set(item.kind, (exceptionKinds.get(item.kind) ?? 0) +
1);
@@ -42,6 +49,8 @@ function summarize(plan, reconciliation, eligible, mode,
language) {
`## ${language} translation discovery`,
'',
`- Source commit: \`${plan.sourceCommit}\``,
+ `- Plan: ${reused ? 'reused from discovery; master and open-PR
reservations are unchanged'
+ : 'recounted from the native documents'}`,
`- Mode: \`${mode}\`; generation eligible: **${eligible}**`,
`- Required: ${plan.counts.required}; merged current:
${plan.counts.mergedCurrent}; pending: ${plan.counts.pending}`,
`- Open PRs inspected: ${reconciliation.openPrCount}; relevant:
${reconciliation.relevantPrs.length}; active candidates:
${plan.counts.activeCandidates}`,
@@ -63,25 +72,37 @@ function main() {
const reconciliation = JSON.parse(fs.readFileSync(path.join(output,
'open-pr-reconciliation.json'), 'utf8'));
const planFile = path.join(output, 'plan.json');
const auditFile = path.join(output, 'coverage.json');
- const args = [path.join(__dirname, 'plan-translations.cjs'),
'--source-ref', 'HEAD',
- '--active-candidates', path.join(output, 'active-candidates.json'),
'--output', planFile,
- '--audit-output', auditFile, '--limit', process.env.I18N_LIMIT ||
'20'];
- for (const [key, option] of [['I18N_PLUGIN', '--plugin'], ['I18N_VERSION',
'--version'],
- ['I18N_PATH', '--path'], ['I18N_PATHS', '--paths']]) {
- if (process.env[key]) args.push(option, process.env[key]);
+ const activeFile = path.join(output, 'active-candidates.json');
+ const event = process.env.GITHUB_EVENT_NAME || 'workflow_dispatch';
+ const inputs = planInputs(execFileSync('git', ['rev-parse', 'HEAD'], {cwd:
root, encoding: 'utf8'}).trim(),
+ fs.readFileSync(activeFile, 'utf8'));
+ // The generation job downloads the discovery plan. Recounting every native
+ // document takes minutes, so an unchanged plan is reused.
+ const previous = fs.existsSync(planFile) && fs.existsSync(auditFile)
+ ? JSON.parse(fs.readFileSync(planFile, 'utf8')) : null;
+ const reused = event !== 'push' && previous?.planInputs === inputs;
+ let plan = previous;
+ if (!reused) {
+ const args = [path.join(__dirname, 'plan-translations.cjs'),
'--source-ref', 'HEAD',
+ '--active-candidates', activeFile, '--output', planFile,
+ '--audit-output', auditFile, '--limit', process.env.I18N_LIMIT ||
'20'];
+ for (const [key, option] of [['I18N_PLUGIN', '--plugin'],
['I18N_VERSION', '--version'],
+ ['I18N_PATH', '--path'], ['I18N_PATHS', '--paths']]) {
+ if (process.env[key]) args.push(option, process.env[key]);
+ }
+ execFileSync(process.execPath, args, {cwd: root, stdio: 'inherit'});
+ plan = JSON.parse(fs.readFileSync(planFile, 'utf8'));
+ const blocked = new Set(reconciliation.blockedTargets);
+ const originalSelected = plan.selected.length;
+ plan.selected = plan.selected.filter(id =>
!blocked.has(plan.tasks.find(task => task.id === id)?.targetPath));
+ plan.selectedCount = plan.selected.length;
+ plan.selectedEstimatedSourceTokens = plan.selected.reduce((total, id)
=>
+ total + (plan.tasks.find(task => task.id ===
id)?.estimatedSourceTokens || 0), 0);
+ plan.blockedSelected = originalSelected - plan.selected.length;
+ plan.planInputs = inputs;
+ fs.writeFileSync(planFile, JSON.stringify(plan, null, 2) + '\n');
}
- execFileSync(process.execPath, args, {cwd: root, stdio: 'inherit'});
- const plan = JSON.parse(fs.readFileSync(planFile, 'utf8'));
const audit = JSON.parse(fs.readFileSync(auditFile, 'utf8'));
- const blocked = new Set(reconciliation.blockedTargets);
- const originalSelected = plan.selected.length;
- plan.selected = plan.selected.filter(id =>
!blocked.has(plan.tasks.find(task => task.id === id)?.targetPath));
- plan.selectedCount = plan.selected.length;
- plan.selectedEstimatedSourceTokens = plan.selected.reduce((total, id) =>
- total + (plan.tasks.find(task => task.id ===
id)?.estimatedSourceTokens || 0), 0);
- plan.blockedSelected = originalSelected - plan.selected.length;
- fs.writeFileSync(planFile, JSON.stringify(plan, null, 2) + '\n');
- const event = process.env.GITHUB_EVENT_NAME || 'workflow_dispatch';
const mode = process.env.I18N_MODE || 'dry-run';
const changed = event === 'push' ?
changedPaths(process.env.GITHUB_EVENT_BEFORE, plan.sourceCommit) : [];
const locale = pipelineLocale(root);
@@ -94,7 +115,7 @@ function main() {
}
const eligible = event === 'schedule' || event === 'workflow_dispatch' &&
['generate', 'validate'].includes(mode)
|| event === 'push' && changed !== null && relevantPush(changed,
audit, scope);
- const summary = summarize(plan, reconciliation, eligible, mode,
languageName(root, locale));
+ const summary = summarize(plan, reconciliation, eligible, mode,
languageName(root, locale), reused);
if (process.env.GITHUB_STEP_SUMMARY)
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
else process.stdout.write(summary);
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT,
@@ -105,4 +126,4 @@ function main() {
if (require.main === module) {
try {main();} catch (error) {console.error(error.message);
process.exitCode = 1;}
}
-module.exports = {relevantPush, changedPaths, summarize, rulesFor};
+module.exports = {relevantPush, changedPaths, summarize, rulesFor, planInputs};
diff --git a/translations/README.md b/translations/README.md
index 75806b97e50..b318b8f4a28 100644
--- a/translations/README.md
+++ b/translations/README.md
@@ -104,10 +104,11 @@ exercise these boundaries without real credentials or
model calls; actual
single-page generation is a separate validation step.
The `Korean translation discovery` workflow runs at 04:43 and 16:43 UTC daily
-and on manual dispatch. It fixes the locale to `ko`, uses separate Korean
-artifacts, and shares the existing `japanese-translation-ja` lock with Japanese
-runs. The schedule starts twenty minutes after the Japanese one, so a Korean
run
-waits while a Japanese run is still in progress. A scheduled run generates a
+and on manual dispatch. Like the Japanese workflow, it calls the shared
+`.github/workflows/translation-run.yml`, here with the locale fixed to `ko` and
+its own `korean-translation-*` artifacts; both workflows hold the same
+`docs-translation` lock. The schedule starts twenty minutes after the Japanese
+one, so a Korean run waits while a Japanese run is still in progress. A
scheduled run generates a
batch when eligible Korean documents are pending, with the same limits as a
manual `generate` run: the planner considers at most 20 targets and a batch has
at most 10 pages. It opens a normal labeled PR that maintainers review and
merge
@@ -367,9 +368,9 @@ The translation pipeline works on one locale per run.
`scripts/i18n/state.cjs`
exposes `pipelineLocale(root)`: a configuration that declares a single locale
needs no selector and behaves exactly as before, and `I18N_LOCALE` picks one as
soon as several are managed. An absent or unconfigured selector then fails
-before discovery or generation. The Japanese translation workflow defaults its
-manual input and scheduled runs to `ja`; the Japanese coverage audit always
-selects `ja`. A manual translation run can select another configured locale.
+before discovery or generation. The Japanese coverage audit always
+selects `ja`. Neither translation workflow accepts a locale input: the Japanese
+workflow passes `ja` and the Korean workflow `ko` to the shared run.
Build Check runs PR admission for every configured translation locale,
including
locales disabled for the site build, when a shared input changes. Otherwise it
@@ -471,10 +472,15 @@ be applied to managed translation files. The offline
fixture does not establish
live model quality, latency, cost or availability. A real limited call and
review are recommended before running batch generation.
-## Asynchronous Japanese discovery and generation
+## Asynchronous discovery and generation
`.github/workflows/japanese-translation.yml` runs at 04:23 and 16:23 UTC daily
-and on manual dispatch. It has no push trigger. Every run first recounts
+and on manual dispatch; `.github/workflows/korean-translation.yml` runs twenty
+minutes later. Each sets only its triggers, manual defaults and the lock, then
+calls `.github/workflows/translation-run.yml` with its locale and artifact
+prefix, so both languages run the same steps. Neither has a push trigger.
+The description below uses Japanese; Korean differs only in its locale, label,
+artifact names and manual default. Every run first recounts
the current native Dev, 4.x and community documents, reads every open master
PR's changed files, and checks Japanese target files against that PR's state
records. Each run uploads `plan.json`, `coverage.json`,
@@ -486,22 +492,28 @@ attention and neither closes nor overwrites the existing
PR. A PR whose
candidate cannot be verified blocks that target from robot generation and
appears in the exception report.
-Manual dispatch defaults to `generate` and accepts optional plugin, version and
-source path filters. `validate` mode requires one exact English source path
+Japanese manual dispatch defaults to `generate` (Korean to `dry-run`) and
+accepts optional plugin, version and source path prefix filters, or an exact
+`paths` list. `validate` mode requires one exact English source path
ending in `.md` or `.mdx`; it can call the model and create a regular PR for
one
page. It provides a single-page live check after OSS model access is available.
With OSS access but no GitHub write credential, `validate` still writes the
candidate to the generation artifact and makes no GitHub write.
`dry-run` remains available for read-only discovery. Full scans recover
pending work from new or changed source documents and earlier failed runs.
-One workflow-level concurrency group covers every trigger, mode and locale.
+One concurrency group, `docs-translation`, covers both workflows, every
+trigger and every mode. Rename it only while no translation run is queued or
+running; otherwise the run in flight and the next one hold different locks.
Only one complete run can execute at a time; `cancel-in-progress: false`
preserves it when another run is requested. `queue: max` lets up to 100
pending runs wait instead of replacing the previous pending run; see
[GitHub's concurrency
documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency).
Each queued run takes a fresh master checkout and recounts pending documents.
-Translation PR
-merges do not trigger this workflow. It does not participate in website
+The generation job checks out master again instead of the discovery commit,
+because it can wait for a runner long after discovery, then reconciles open PRs
+and replans. When master and the open-PR reservations are unchanged since
+discovery, it reuses the discovery plan instead of recounting every native
+document. Translation PR merges do not trigger this workflow. It does not
participate in website
builds or release deployment.
The mode controls generation directly: manual `generate` runs and scheduled
@@ -545,7 +557,9 @@ The generator calls the model once for exact Dev/4.x pairs
whose doc ID,
relative source path and normalized source hash match. It writes two separate
state records. Human-owned or changed targets, moves, deletions and other
protected tasks stay in the manual queue. The robot opens a regular PR with
-machine review status and the `translate-ja` label, and never merges it.
+machine review status and the `translate-ja` label, and never merges it. The
+run checks that the label exists before loading Codex auth, because the
+publisher applies it only after the model calls and the branch push.
Maintainers review and merge PRs when ready. Multiple batches can remain open:
their documents are excluded, while unrelated pending documents can form the
next batch. This exclusion includes stale candidates and manually edited
@@ -570,6 +584,21 @@ warnings to the exact source and target hashes. The PR
translation gate recogniz
that recorded fallback, and the PR body lists its warnings for review. Three
requests that return no usable document remain an operational failure.
+Master usually moves while a batch waits for a runner or for the model.
+Generation checks it before the first model call, before each page and after
the
+last one, and the publisher checks it again before pushing
+(`scripts/i18n/master-drift.cjs`). A merge that touches none of the batch's
files
+leaves the batch valid: the PR branch stays on the source commit, the PR body
+notes the newer master, and the normal PR checks run against current master. A
+merge that changes a batch page's English source, target or state shard drops
+that page, which stays pending for the next run; the publisher stops instead.
+A merge that changes translation configuration, routing identity or the
+pipeline itself discards the batch before any further model call: the
+locale's scope, glossary, protection exceptions, inventory or top-level state
+files, `translations/config.json`, `config/i18n.js`, `docusaurus.config.js`,
+`versions.json` or `scripts/i18n/`. So does a master that no longer contains
+the source commit.
+
Before pushing, the publisher runs `scripts/i18n/pr-preflight.cjs`, the same
entry
point used by the PR build workflow. It checks translation state, Markdown/MDX
syntax and internal links using the PR link checker's resolver. Invalid syntax
@@ -591,6 +620,10 @@ I18N_SIM_PAGES=10 I18N_SIM_SCENARIO=fallback \
# Translate a real page exceeding the former 60,000-character limit in one
request.
I18N_SIM_SOURCE_PATH=docs/admin-manual/config/be-config.md
I18N_SIM_SCENARIO=success \
node scripts/i18n/simulate-workflow.cjs /tmp/doris-ja-long-page-sim
+
+# A merge lands on master after discovery: unrelated, to the selected page,
+# or to the Japanese glossary.
+I18N_SIM_MASTER_DRIFT=unrelated node scripts/i18n/simulate-workflow.cjs
/tmp/doris-ja-drift-sim
```
The output directory must be empty. The simulator copies the current scripts,
@@ -600,6 +633,10 @@ source fixtures there. Real open-PR reconciliation and
full planning run against
a GitHub CLI mock, followed by the production engine and a schema-shaped Codex
mock. `I18N_SIM_SCENARIO` accepts `success` (one attempt), `retry` (two
attempts,
the default), or `fallback` (three attempts retained with warnings).
+`I18N_SIM_MASTER_DRIFT` pushes one more commit to the local remote after
+discovery: `unrelated` publishes normally and records the newer master in the
PR
+body, `page` skips the selected page without a model request, and `config`
stops
+the batch before any model request.
The simulator also verifies three empty responses, token accounting, the shared
PR admission checks, and publication of only managed Japanese targets/state to
@@ -609,10 +646,10 @@ No real model call or GitHub write occurs.
## Validation
-### Selecting an exact Korean manual batch
+### Selecting an exact manual batch
-Manual Korean dispatch defaults to `dry-run`. Its optional
-`paths` input accepts a JSON array of 1–20 exact English source files, so a
bounded
+Both translation workflows take an optional `paths` input on manual dispatch.
+It accepts a JSON array of 1–20 exact English source files, so a bounded
batch can select documents from different folders without including every page
under a shared prefix:
diff --git a/translations/scope/ko.json b/translations/scope/ko.json
index 1aae217ddac..cb6dd2d4391 100644
--- a/translations/scope/ko.json
+++ b/translations/scope/ko.json
@@ -30,6 +30,12 @@
"key": "sidebar.docs.category.S3",
"reason": "Storage service name."
},
+ {
+ "pluginId": "default",
+ "version": "current",
+ "key": "sidebar.docs.category.Hive Catalog",
+ "reason": "Named Doris Catalog connector; preserve its documented
connector name."
+ },
{
"pluginId": "default",
"version": "current",
@@ -42,6 +48,12 @@
"key": "sidebar.docs.category.Paimon Catalog",
"reason": "Named Doris Catalog connector; preserve its documented
connector name."
},
+ {
+ "pluginId": "default",
+ "version": "current",
+ "key": "sidebar.docs.category.Lance Catalog",
+ "reason": "Named Doris Catalog connector; preserve its documented
connector name."
+ },
{
"pluginId": "default",
"version": "current",
@@ -114,12 +126,24 @@
"key": "sidebar.docs.category.S3",
"reason": "Storage service name."
},
+ {
+ "pluginId": "default",
+ "version": "4.x",
+ "key": "sidebar.docs.category.Hive Catalog",
+ "reason": "Named Doris Catalog connector; preserve its documented
connector name."
+ },
{
"pluginId": "default",
"version": "4.x",
"key": "sidebar.docs.category.Iceberg Catalog",
"reason": "Named Doris Catalog connector; preserve its documented
connector name."
},
+ {
+ "pluginId": "default",
+ "version": "4.x",
+ "key": "sidebar.docs.category.Lance Catalog",
+ "reason": "Named Doris Catalog connector; preserve its documented
connector name."
+ },
{
"pluginId": "default",
"version": "4.x",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]