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 ffb97b065fd ci(i18n): run serial twice-daily translation batches as 
regular PRs (#4332)
ffb97b065fd is described below

commit ffb97b065fddd340a8c7d243e86f82620bd982a9
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Fri Oct 16 17:02:03 2026 +0800

    ci(i18n): run serial twice-daily translation batches as regular PRs (#4332)
    
    ## Problem and behavior
    
    Translation progress currently waits for maintainers to merge the only
    allowed robot batch, and generated PRs are drafts. Run automatic batches
    at 04:23 and 16:23 UTC, plus manual dispatch that defaults to generate.
    Remove the source-push trigger so automatic runs follow that twice-daily
    schedule.
    
    Each enabled run creates a regular PR with `translate-ja` and at most 10
    target pages, for maintainers to review and merge. Multiple batches may
    remain open. Reserve every document already covered by an open PR,
    including stale candidates; invalid or edited target/state records block
    their own documents before applying the batch limit. Recheck
    reservations for primary and reused Dev/4.x targets. Preserve existing
    PRs instead of closing or replacing them.
    
    A stable workflow-level concurrency group covers every trigger, mode and
    locale. `cancel-in-progress: false` preserves the active run, while
    `queue: max` queues subsequent runs. Discovery checks out the latest
    master when a queued run starts; generation reconciles again and
    verifies source/target and master freshness before publication. The
    local simulator now accommodates the full native scan and accounts for
    multiple model chunks.
    
    ## Validation
    
    - Node 20.20.2: all 107 i18n tests passed, including stale reservations,
    close-and-retry, multiple open batches, blocked targets before the
    limit, generation of ten other pages, and reserved Dev/4.x partner
    protection.
    - Full local Git/GitHub/Codex simulation passed: malformed response
    rejected; multi-chunk candidate accepted; translation gate passed; only
    the target and its page state committed; regular PR and translate-ja
    label verified. No external model or GitHub writes in this simulation.
    - Existing master concurrency API observed one run in progress and
    another pending. Both read-only probes were then cancelled; the same
    group is retained by this change. The new queue setting is checked
    against current GitHub documentation and is pending deployment.
    - Local translation gate, five Python link-resolver tests,
    YAML/trusted-checkout checks and changed-file size checks passed.
    - Final commit differs from the tested runtime commit only in README
    wording. GitHub CI for the latest head is still waiting for runners.
    
    Continuous generation remains disabled until this change is merged and
    enabled. The real one-page model/PAT/PR/CI acceptance already passed in
    #4330. No website rendering, English/Chinese source content, model
    credentials or token scopes change.
    
    ---------
    
    Co-authored-by: morningman <[email protected]>
---
 .github/workflows/japanese-translation.yml | 37 +++++------------
 scripts/i18n/__tests__/planner.test.cjs    | 28 ++++++++++---
 scripts/i18n/__tests__/workflow.test.cjs   | 50 +++++++++++++++-------
 scripts/i18n/plan-translations.cjs         |  7 +++-
 scripts/i18n/simulate-workflow.cjs         | 14 ++++---
 scripts/i18n/workflow-generate.cjs         | 11 ++---
 scripts/i18n/workflow-prs.cjs              |  2 +-
 scripts/i18n/workflow-publish.cjs          | 13 ++----
 scripts/i18n/workflow-scan.cjs             | 13 +-----
 translations/README.md                     | 67 +++++++++++++++++++-----------
 10 files changed, 134 insertions(+), 108 deletions(-)

diff --git a/.github/workflows/japanese-translation.yml 
b/.github/workflows/japanese-translation.yml
index 29b89b2005c..37c66792004 100644
--- a/.github/workflows/japanese-translation.yml
+++ b/.github/workflows/japanese-translation.yml
@@ -7,7 +7,7 @@ on:
         description: Read-only dry run, one-page live validation, or enabled 
batch
         type: choice
         options: [dry-run, validate, generate]
-        default: dry-run
+        default: generate
         required: true
       plugin:
         description: Optional plugin filter (default or community)
@@ -21,33 +21,19 @@ on:
       locale:
         description: Optional translation locale; required once several are 
managed
         type: string
-  push:
-    branches: [master]
-    paths:
-      - 'docs/**'
-      - 'versioned_docs/version-4.x/**'
-      - 'community/**'
-      - 'i18n/zh-CN/docusaurus-plugin-content-docs/current/**'
-      - 'i18n/zh-CN/docusaurus-plugin-content-docs/version-4.x/**'
-      - 'i18n/zh-CN/docusaurus-plugin-content-docs-community/current/**'
-      - 'translations/config.json'
-      - 'translations/scope/**'
-      - 'translations/glossary/**'
-      - 'sidebars.ts'
-      - 'sidebarsCommunity.json'
-      - 'versioned_sidebars/version-4.x-sidebars.json'
-      - 'docusaurus.config.js'
-      - 'config/i18n.js'
   schedule:
-    - cron: '23 4 * * *'
+    - cron: '23 4,16 * * *'
 
 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
 
 jobs:
   discover:
@@ -62,6 +48,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
         with:
+          ref: master
           fetch-depth: 0
           persist-credentials: false
       - uses: actions/setup-node@v4
@@ -77,8 +64,7 @@ jobs:
       - name: Plan all current and historical pending documents
         id: scan
         env:
-          GITHUB_EVENT_BEFORE: ${{ github.event.before }}
-          I18N_MODE: ${{ inputs.mode || 'dry-run' }}
+          I18N_MODE: ${{ inputs.mode || 'generate' }}
           I18N_PLUGIN: ${{ inputs.plugin }}
           I18N_VERSION: ${{ inputs.version }}
           I18N_PATH: ${{ inputs.path }}
@@ -122,7 +108,7 @@ 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 || 'dry-run' }}
+          I18N_MODE: ${{ inputs.mode || 'generate' }}
         run: |
           if [[ -n "$APP_ID" && -n "$APP_PRIVATE_KEY" ]]; then
             echo 'auth_kind=app' >> "$GITHUB_OUTPUT"
@@ -152,8 +138,7 @@ jobs:
         if: steps.config.outputs.ready == 'true'
         id: fresh
         env:
-          GITHUB_EVENT_BEFORE: ${{ github.event.before }}
-          I18N_MODE: ${{ inputs.mode || 'dry-run' }}
+          I18N_MODE: ${{ inputs.mode || 'generate' }}
           I18N_PLUGIN: ${{ inputs.plugin }}
           I18N_VERSION: ${{ inputs.version }}
           I18N_PATH: ${{ inputs.path }}
@@ -216,7 +201,7 @@ jobs:
           repositories: doris-website
           permission-contents: write
           permission-pull-requests: write
-      - name: Open draft PR with the configured write identity
+      - name: Open reviewable PR with the configured write identity
         if: steps.translation.outputs.created == 'true' && 
steps.config.outputs.auth_kind != 'none'
         env:
           GH_TOKEN: ${{ steps.app.outputs.token || secrets.I18N_GITHUB_PAT }}
@@ -224,7 +209,7 @@ jobs:
         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 draft PR.' >> 
"$GITHUB_STEP_SUMMARY"
+        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:
diff --git a/scripts/i18n/__tests__/planner.test.cjs 
b/scripts/i18n/__tests__/planner.test.cjs
index fbddcfd1b94..5b3baa377bd 100644
--- a/scripts/i18n/__tests__/planner.test.cjs
+++ b/scripts/i18n/__tests__/planner.test.cjs
@@ -57,22 +57,40 @@ test('modified completed source is an update and valid 
active PR suppresses sele
     assert.deepEqual(result.selected, []);
 });
 
-test('stale active PR is reported and a later source revision reenters 
selection', async () => {
+test('stale active PR stays excluded until closed, without adding merged 
coverage', async () => {
     const changed = page('edited', {state: 'stale_unknown'});
     const active = {schemaVersion: 1, candidates: [{pluginId: 'default', 
version: 'current', docId: 'edited',
         locale: 'ja', sourcePath: changed.sourcePath, sourceHash: 
hash('older'), pr: '#123'}]};
     const result = await run([changed], {old: [{...changed, sourceHash: 
hash('older'), sourceCommit: baselineSha}], active});
     assert.equal(result.exceptions.some(item => item.kind === 
'stale_active_candidate'), true);
-    assert.equal(result.selected.length, 1);
+    assert.equal(result.tasks[0].activePr, '#123');
+    assert.equal(result.counts.activeCandidates, 1);
+    assert.equal(result.counts.mergedCurrent, 0);
+    assert.deepEqual(result.selected, []);
+    const afterClose = await run([changed], {old: [{...changed, sourceHash: 
hash('older'), sourceCommit: baselineSha}]});
+    assert.equal(afterClose.selected.length, 1);
 });
 
-test('an active PR for an old path cannot suppress a moved page with the same 
source hash', async () => {
+test('an active PR for an old path reserves the same document identity until 
resolved', async () => {
     const changed = page('new', {sourcePath: 'docs/new.md'});
     const active = {schemaVersion: 1, candidates: [{pluginId: 'default', 
version: 'current', docId: 'new',
         locale: 'ja', sourcePath: 'docs/old.md', sourceHash: 
changed.sourceHash, pr: '#123'}]};
     const result = await run([changed], {active});
-    assert.equal(result.tasks[0].activePr, null);
-    assert.equal(result.selected.length, 1);
+    assert.equal(result.tasks[0].activePr, '#123');
+    assert.deepEqual(result.selected, []);
+    assert.equal(result.exceptions[0].kind, 'stale_active_candidate');
+});
+
+test('multiple open PRs reserve only their documents and blocked targets do 
not consume the batch limit', async () => {
+    const pages = [page('a'), page('b'), page('c'), page('d')];
+    const active = {schemaVersion: 1, candidates: pages.slice(0, 2).map((p, i) 
=> ({...p, locale: 'ja',
+        sourceHash: i === 0 ? p.sourceHash : hash('older'), pr: `#${i + 1}`})),
+        blockedTargets: [pages[2].targetPath]};
+    const result = await run(pages, {active, limit: 1});
+    assert.equal(result.counts.pending, 4);
+    assert.equal(result.counts.mergedCurrent, 0);
+    assert.equal(result.counts.activeCandidates, 2);
+    assert.deepEqual(result.selected, [result.tasks.find(task => task.docId 
=== 'd').id]);
 });
 
 test('same identity path move includes cleanup and only reuses a verified 
target', async () => {
diff --git a/scripts/i18n/__tests__/workflow.test.cjs 
b/scripts/i18n/__tests__/workflow.test.cjs
index fc2825f1cf7..80f443034c3 100644
--- a/scripts/i18n/__tests__/workflow.test.cjs
+++ b/scripts/i18n/__tests__/workflow.test.cjs
@@ -8,7 +8,7 @@ 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, replaceableBotPr} = require('../workflow-scan.cjs');
+const {relevantPush} = require('../workflow-scan.cjs');
 const {pairFor, machineOwned, generate, allowGeneration} = 
require('../workflow-generate.cjs');
 const {expectedFiles, authorFor} = require('../workflow-publish.cjs');
 
@@ -113,15 +113,6 @@ test('two open PRs for one source are flagged and robot 
branch is identified', a
     assert.equal(result.exceptions[0].kind, 'duplicate_open_pr_candidate');
 });
 
-test('an untouched stale robot PR may be replaced, but a human edit blocks 
replacement', () => {
-    const sha = 'a'.repeat(40);
-    const plan = {exceptions: [{kind: 'stale_active_candidate', pr: '#21'}]};
-    const bot = {number: 21, headSha: sha, expectedHead: sha, untouched: true};
-    assert.equal(replaceableBotPr(plan, {botDetails: [bot]}), bot);
-    assert.equal(replaceableBotPr(plan, {botDetails: [{...bot, untouched: 
false}]}), null);
-    assert.equal(replaceableBotPr({exceptions: []}, {botDetails: [bot]}), 
null);
-});
-
 test('only exact current Dev/4.x identity, path and source hash are paired', 
() => {
     const dev = {pluginId: 'default', version: 'current', docId: 'guide', 
kind: 'translate_backlog',
         sourcePath: 'docs/guide.md', sourceHash};
@@ -282,6 +273,28 @@ async function updateFixture(t, {sharded = true, human = 
false} = {}) {
         calls: () => calls};
 }
 
+test('a paired target reserved by an open PR is not copied even when the 
supplied plan omits that reservation', async t => {
+    const fixture = await updateFixture(t);
+    const sourcePath = 'versioned_docs/version-4.x/guide.md';
+    fixture.put(sourcePath, fs.readFileSync(path.join(fixture.root, 
fixture.task.sourcePath), 'utf8'));
+    fixture.git('add', '.');
+    fixture.git('-c', 'user.name=Test', '-c', '[email protected]', 
'commit', '-qm', 'paired source');
+    const sourceCommit = fixture.git('rev-parse', 'HEAD');
+    const primary = {...fixture.task, sourceCommit};
+    const partner = {...primary, version: '4.x', groupId: '4X', id: 
'T06-4X-000000000000002',
+        kind: 'translate_new', sourcePath, targetHash: null,
+        targetPath: 
'i18n/ja/docusaurus-plugin-content-docs/version-4.x/guide.md'};
+    const manifest = await fixture.run({maxPages: 2, ensureFresh: () => {},
+        plan: {...fixture.plan, sourceCommit, tasks: [primary, partner]},
+        reconciliation: {botPrs: [22], blockedTargets: [],
+            active: {candidates: [{...partner, sourceHash: hash('older 
source'), pr: '#22'}]}}});
+    assert.equal(manifest.candidates.length, 1);
+    assert.equal(manifest.candidates[0].version, 'current');
+    assert.equal(fixture.calls(), 1);
+    assert(!fs.existsSync(path.join(fixture.root, partner.targetPath)));
+    assert(!fs.existsSync(path.join(fixture.root, stateFileFor(partner))));
+});
+
 test('an existing page shard is updated without rewriting or trusting its 
obsolete legacy copy', async t => {
     const fixture = await updateFixture(t);
     const {root, legacyPath, shardPath, task} = fixture;
@@ -414,7 +427,7 @@ test('publication deduplicates shared state and rejects an 
unrelated page shard'
         [record.targetPath, second.targetPath, 
'translations/state/ja/docs.json'].sort());
 });
 
-test('normal generation defaults to ten target pages and leaves the next task 
untouched', async t => {
+test('normal generation skips open PRs and blocked targets, then generates ten 
other pages', async 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) => {
@@ -424,7 +437,7 @@ test('normal generation defaults to ten target pages and 
leaves the next task un
     put('translations/scope/ja.json', JSON.stringify(scope));
     put('translations/state/ja/docs.json', JSON.stringify({schemaVersion: 1, 
pages: []}));
     put('translations/state/ja/shards.schema.json', 
JSON.stringify({schemaVersion: 1, format: 'page-record-v1'}));
-    const tasks = Array.from({length: 11}, (_, index) => {
+    const tasks = Array.from({length: 14}, (_, index) => {
         const source = `# Guide ${index}\n\nA guide.\n`;
         const sourcePath = `docs/page-${index}.md`;
         put(sourcePath, source);
@@ -438,6 +451,7 @@ test('normal generation defaults to ten target pages and 
leaves the next task un
     git('init', '-q'); git('add', '.');
     git('-c', 'user.name=Test', '-c', '[email protected]', 'commit', 
'-qm', 'eleven pending pages');
     const sourceCommit = git('rev-parse', 'HEAD');
+    tasks[0].activePr = '#21';
     let calls = 0;
     const adapter = {model: 'fixture', usedTokens: 0, translate: async 
({units}) => {
         calls++;
@@ -445,12 +459,18 @@ test('normal generation defaults to ten target pages and 
leaves the next task un
     }};
     const manifest = await 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: [], botDetails: [], blockedTargets: []}, 
ensureFresh: () => {},
+        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: () => {},
         adapter, glossary: require('../../../translations/glossary/ja.json')});
     assert.equal(manifest.candidates.length, 10);
     assert.equal(manifest.failures.length, 0);
     assert.equal(calls, 10);
     assert.equal(loadRecords(root, 'ja').size, 10);
-    assert(!fs.existsSync(path.join(root, tasks[10].targetPath)));
-    assert(!fs.existsSync(path.join(root, stateFileFor(tasks[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]]) {
+        assert(!fs.existsSync(path.join(root, task.targetPath)));
+        assert(!fs.existsSync(path.join(root, stateFileFor(task))));
+    }
+    assert.equal(manifest.replacesPr, undefined);
 });
diff --git a/scripts/i18n/plan-translations.cjs 
b/scripts/i18n/plan-translations.cjs
index 1f2306b1151..03a3a6ee601 100644
--- a/scripts/i18n/plan-translations.cjs
+++ b/scripts/i18n/plan-translations.cjs
@@ -162,7 +162,9 @@ async function plan({report, scope, records, baseline = [], 
active, readCurrent,
             targetHash: page.targetHash, state: page.state, reviewStatus: 
page.reviewStatus,
             estimatedSourceTokens: estimate(source), requiresHumanReview: 
protectedTask,
             sourceChange: page.change,
-            activePr: activeValid ? candidate.pr : null,
+            // An open PR owns this identity until it is merged or closed,
+            // even if its source revision or path has become stale.
+            activePr: candidate?.pr ?? null,
             ...(moveFrom ? {previousDocId: moveFrom.docId, reuseTarget: 
moveFrom.sourceHash === page.sourceHash
                 && !!moveFrom.targetHash && !!moveFrom.targetPath
                 && readCurrent(moveFrom.targetPath) !== null && 
hash(readCurrent(moveFrom.targetPath)) === moveFrom.targetHash,
@@ -200,9 +202,10 @@ async function plan({report, scope, records, baseline = 
[], active, readCurrent,
         && (!filters.path || task.sourcePath.startsWith(filters.path) || 
task.targetPath?.startsWith(filters.path)));
     const selected = [];
     let tokens = 0;
+    const blockedTargets = new Set(active?.blockedTargets ?? []);
     for (const task of selectedTasks) {
         if (selected.length >= limit) break;
-        if (task.activePr || task.requiresHumanReview) continue;
+        if (task.activePr || task.requiresHumanReview || 
blockedTargets.has(task.targetPath)) continue;
         if (tokenBudget !== null && tokens + task.estimatedSourceTokens > 
tokenBudget) continue;
         selected.push(task.id);
         tokens += task.estimatedSourceTokens;
diff --git a/scripts/i18n/simulate-workflow.cjs 
b/scripts/i18n/simulate-workflow.cjs
index 9422b3a3c70..359e14c1092 100644
--- a/scripts/i18n/simulate-workflow.cjs
+++ b/scripts/i18n/simulate-workflow.cjs
@@ -50,13 +50,13 @@ if (args[0] === 'api' && args.some(arg => 
arg.startsWith('repos/apache/doris-web
     process.exit(0);
 } else if (args[0] === 'pr' && args[1] === 'create') {
     const value = flag => args[args.indexOf(flag) + 1];
-    assert(args.includes('--draft'));
+    assert(!args.includes('--draft'));
     assert.equal(value('--base'), 'master');
     assert.equal(value('--repo'), 'apache/doris-website');
     assert.equal(value('--label'), 'translate-ja');
     const body = fs.readFileSync(value('--body-file'), 'utf8');
     assert.match(body, /japanese-translation-automation:v1 
bot-head=[a-f0-9]{40}/);
-    const record = {draft: true, base: value('--base'), head: value('--head'), 
labels: [value('--label')], body,
+    const record = {draft: false, base: value('--base'), head: 
value('--head'), labels: [value('--label')], body,
         url: 'https://example.invalid/apache/doris-website/pull/1'};
     fs.writeFileSync(process.env.I18N_SIM_PR_RECORD, JSON.stringify(record, 
null, 2) + '\\n');
     process.stdout.write(record.url + '\\n');
@@ -68,7 +68,7 @@ if (args[0] === 'api' && args.some(arg => 
arg.startsWith('repos/apache/doris-web
 
 function runNode(root, script, args, env, logDir, expectedStatus = 0) {
     const result = spawnSync(process.execPath, [path.join(root, script), 
...args], {
-        cwd: root, env, encoding: 'utf8', timeout: 300000, maxBuffer: 16 * 
1024 * 1024,
+        cwd: root, env, encoding: 'utf8', timeout: 1200000, maxBuffer: 16 * 
1024 * 1024,
     });
     const name = path.basename(script, '.cjs') + (env.I18N_SIM_PHASE || '');
     fs.writeFileSync(path.join(logDir, `${name}.log`), `${result.stdout || 
''}${result.stderr || ''}`);
@@ -110,7 +110,7 @@ function main() {
         GITHUB_RUN_ID: runId, I18N_PR_AUTH_KIND: 'pat', I18N_MODE: 'validate',
         I18N_VALIDATION_RUN: 'true', I18N_PATH: sourcePath, I18N_MAX_PAGES: 
'1',
         I18N_LIMIT: '20', DORIS_ENABLE_JA: 'true', CODEX_HOME: auth,
-        I18N_SIM_PR_RECORD: path.join(output, 'draft-pr.json')};
+        I18N_SIM_PR_RECORD: path.join(output, 'pull-request.json')};
     runNode(repo, 'scripts/i18n/workflow-prs.cjs', [scan], env, logs);
     runNode(repo, 'scripts/i18n/workflow-scan.cjs', [scan], env, logs);
     const plan = JSON.parse(fs.readFileSync(path.join(scan, 'plan.json'), 
'utf8'));
@@ -128,7 +128,9 @@ function main() {
     const generation = JSON.parse(fs.readFileSync(path.join(scan, 
'generation.json'), 'utf8'));
     assert.equal(generation.candidates.length, 1);
     assert.equal(generation.failures.length, 0);
-    assert.equal(generation.usedTokens, 100000);
+    const requests = generation.candidates.reduce((sum, candidate) => sum + 
candidate.requests, 0);
+    assert(Number.isInteger(requests) && requests > 0);
+    assert.equal(generation.usedTokens, requests * 100000);
     runNode(repo, 'scripts/i18n/workflow-publish.cjs', [scan], env, logs);
     const pr = JSON.parse(fs.readFileSync(env.I18N_SIM_PR_RECORD, 'utf8'));
     assert.equal(pr.head, `i18n/ja-automated-${runId}`);
@@ -146,7 +148,7 @@ function main() {
         sourceCommit: plan.sourceCommit, selected: plan.selected, sourcePath,
         invalidModel: {exitCode: 1, error: failure.failures[0].error, 
usedTokens: failure.usedTokens},
         generation: {candidates: generation.candidates.length, usedTokens: 
generation.usedTokens},
-        draftPr: {url: pr.url, branch: pr.head, commit: branch, files: 
committedFiles, labels: pr.labels}};
+        pullRequest: {url: pr.url, draft: pr.draft, branch: pr.head, commit: 
branch, files: committedFiles, labels: pr.labels}};
     fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify(report, 
null, 2) + '\n');
     process.stdout.write(JSON.stringify(report, null, 2) + '\n');
 }
diff --git a/scripts/i18n/workflow-generate.cjs 
b/scripts/i18n/workflow-generate.cjs
index 35276255640..7ccb6ec49d2 100644
--- a/scripts/i18n/workflow-generate.cjs
+++ b/scripts/i18n/workflow-generate.cjs
@@ -8,7 +8,6 @@ const {hash, identity, languageName, pipelineLocale} = 
require('./state.cjs');
 const {checkedTask, managedPath, safeFile} = 
require('./translation-engine.cjs');
 const {codexAdapter} = require('./codex-adapter.cjs');
 const {prepare, applyCandidates, readState, statePathForRecord, targetFor} = 
require('./translate-docs.cjs');
-const {replaceableBotPr} = require('./workflow-scan.cjs');
 const {markerPrefixFor} = require('./automation-naming.cjs');
 
 const root = path.resolve(__dirname, '../..');
@@ -86,24 +85,23 @@ async function generate({plan, reconciliation, maxPages = 
10, target,
     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');
-    const replaces = replaceableBotPr(plan, reconciliation);
-    assert(reconciliation.botPrs.length === 0 || replaces,
-        'An automated translation PR is already open or was edited after 
generation');
     const scope = JSON.parse(fs.readFileSync(path.join(rootDir, 
`translations/scope/${target.locale}.json`), 'utf8'));
     const state = readState(rootDir, target.locale);
     const blocked = new Set(reconciliation.blockedTargets);
+    const active = new Set((reconciliation.active?.candidates ?? 
[]).map(identity));
     const candidates = [], failures = [], consumed = new Set();
     for (const id of plan.selected) {
         if (candidates.length >= maxPages) break;
         const task = plan.tasks.find(item => item.id === id);
-        if (!task || consumed.has(id) || blocked.has(task.targetPath)) 
continue;
+        if (!task || consumed.has(id) || task.activePr || 
active.has(identity(task))
+            || blocked.has(task.targetPath)) continue;
         consumed.add(id);
         if (!machineOwned(task, state)) {
             failures.push({id, sourcePath: task.sourcePath, error: 'Human 
maintained target requires manual review'});
             continue;
         }
         let partner = pairFor(task, plan.tasks, scope);
-        if (partner && (blocked.has(partner.targetPath) || 
!machineOwned(partner, state)
+        if (partner && (active.has(identity(partner)) || 
blocked.has(partner.targetPath) || !machineOwned(partner, state)
             || candidates.length + 2 > maxPages)) partner = null;
         try {
             checkedTask(plan, id, scope, rootDir);
@@ -125,7 +123,6 @@ async function generate({plan, reconciliation, maxPages = 
10, target,
         locale: target.locale, language: target.language,
         model: adapter.model, glossaryVersion: glossary.version, liveCall: 
adapter.usedTokens > 0,
         liveVerified: false, usedTokens: adapter.usedTokens,
-        replacesPr: replaces ? {number: replaces.number, headSha: 
replaces.headSha} : null,
         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,
diff --git a/scripts/i18n/workflow-prs.cjs b/scripts/i18n/workflow-prs.cjs
index e874bec8ea7..8f90ceee1a7 100644
--- a/scripts/i18n/workflow-prs.cjs
+++ b/scripts/i18n/workflow-prs.cjs
@@ -124,7 +124,7 @@ async function reconcileOpenPrs({scope, language, pulls, 
filesFor, readHead, rea
     }
     const candidates = [...active.values()].filter(candidate => !blocked.has(
         managedPath(scope, {...candidate, targetPath: targetFor(scope, 
candidate)})));
-    return {schemaVersion: 1, active: {schemaVersion: 1, candidates}, 
blockedTargets: ordered(blocked),
+    return {schemaVersion: 1, active: {schemaVersion: 1, candidates, 
blockedTargets: ordered(blocked)}, blockedTargets: ordered(blocked),
         botPrs: botPrs.sort((a, b) => a - b), botDetails, openPrCount: 
pulls.length,
         relevantPrs: relevant.sort((a, b) => a - b), exceptions};
 }
diff --git a/scripts/i18n/workflow-publish.cjs 
b/scripts/i18n/workflow-publish.cjs
index 5b26f5b8fe2..69a7b54c43a 100644
--- a/scripts/i18n/workflow-publish.cjs
+++ b/scripts/i18n/workflow-publish.cjs
@@ -54,6 +54,7 @@ function main() {
     const kind = process.env.I18N_PR_AUTH_KIND;
     assert(kind === 'app' || kind === 'pat', 'Unsupported GitHub write 
identity');
     const manifest = JSON.parse(fs.readFileSync(path.join(output, 
'generation.json'), 'utf8'));
+    assert(!manifest.replacesPr, 'Preserve existing translation PRs; rescan 
with the current generator');
     const locale = pipelineLocale(root);
     const language = languageName(root, locale);
     const scope = JSON.parse(fs.readFileSync(path.join(root, 
`translations/scope/${locale}.json`), 'utf8'));
@@ -81,21 +82,13 @@ function main() {
     // 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'});
-    if (manifest.replacesPr) {
-        const old = JSON.parse(execFileSync('gh', ['pr', 'view', 
String(manifest.replacesPr.number),
-            '--repo', 'apache/doris-website', '--json', 'headRefOid,state'], 
{cwd: root, encoding: 'utf8'}));
-        assert(old.state === 'OPEN' && old.headRefOid === 
manifest.replacesPr.headSha,
-            'Previous robot PR was edited or closed; preserve it and rescan');
-        execFileSync('gh', ['pr', 'close', String(manifest.replacesPr.number),
-            '--repo', 'apache/doris-website'], {cwd: root, stdio: 'inherit'});
-    }
     git('push', 'origin', `HEAD:refs/heads/${branch}`);
     const url = execFileSync('gh', ['pr', 'create', '--repo', 
'apache/doris-website', '--base', 'master',
-        '--head', branch, '--draft', '--label', `translate-${locale}`,
+        '--head', branch, '--label', `translate-${locale}`,
         '--title', `docs(i18n): machine ${language} translation candidates`,
         '--body-file', bodyFile], {cwd: root, encoding: 'utf8'}).trim();
     if (process.env.GITHUB_STEP_SUMMARY) 
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY,
-        `\nOpened draft translation PR: ${url}; ${manifest.candidates.length} 
candidate pages.\n`);
+        `\nOpened translation PR for review: ${url}; 
${manifest.candidates.length} candidate pages.\n`);
     process.stdout.write(`${url}\n`);
 }
 
diff --git a/scripts/i18n/workflow-scan.cjs b/scripts/i18n/workflow-scan.cjs
index 13b5b3f42a1..eea629a629d 100644
--- a/scripts/i18n/workflow-scan.cjs
+++ b/scripts/i18n/workflow-scan.cjs
@@ -47,7 +47,6 @@ function summarize(plan, reconciliation, eligible, mode, 
language) {
         `- Open PRs inspected: ${reconciliation.openPrCount}; relevant: 
${reconciliation.relevantPrs.length}; active candidates: 
${plan.counts.activeCandidates}`,
         `- Selected for a possible next batch: ${plan.selected.length}; 
blocked targets: ${reconciliation.blockedTargets.length}`,
         `- Robot batch PRs already open: ${reconciliation.botPrs.join(', ') || 
'none'}`,
-        `- Untouched stale robot PR eligible for replacement: 
${replaceableBotPr(plan, reconciliation)?.number || 'none'}`,
         `- Exceptions: ${[...exceptionKinds].map(([kind, count]) => 
`${kind}=${count}`).join(', ') || 'none'}`,
         '',
         'This report is read only. Open PR candidates do not count as merged 
translations.',
@@ -55,13 +54,6 @@ function summarize(plan, reconciliation, eligible, mode, 
language) {
     ].join('\n');
 }
 
-function replaceableBotPr(plan, reconciliation) {
-    if (reconciliation.botDetails?.length !== 1 || 
!reconciliation.botDetails[0].untouched) return null;
-    const bot = reconciliation.botDetails[0];
-    return plan.exceptions.some(item => item.kind === 'stale_active_candidate' 
&& item.pr === `#${bot.number}`)
-        ? bot : null;
-}
-
 function main() {
     const output = process.argv[2];
     assert(output && process.argv.length === 3, 'Usage: workflow-scan.cjs 
<output-directory>');
@@ -103,12 +95,11 @@ function main() {
     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,
-        `can_generate=${eligible && plan.selected.length > 0
-            && (reconciliation.botPrs.length === 0 || !!replaceableBotPr(plan, 
reconciliation))}\n`
+        `can_generate=${eligible && plan.selected.length > 0}\n`
         + `source_commit=${plan.sourceCommit}\n`);
 }
 
 if (require.main === module) {
     try {main();} catch (error) {console.error(error.message); 
process.exitCode = 1;}
 }
-module.exports = {relevantPush, changedPaths, summarize, replaceableBotPr, 
rulesFor};
+module.exports = {relevantPush, changedPaths, summarize, rulesFor};
diff --git a/translations/README.md b/translations/README.md
index 668878fa167..252cc7f5517 100644
--- a/translations/README.md
+++ b/translations/README.md
@@ -21,7 +21,7 @@ shows the raw context path.
 The translation workflow's `validate` mode selects one exact source page for a
 live check. Normal `generate` runs produce at most 10 target pages per batch,
 including any reused Dev/4.x partner; the planner can consider up to 20 tasks.
-Automated draft PRs receive the `translate-<locale>` repository label, such as
+Automated PRs receive the `translate-<locale>` repository label, such as
 `translate-ja`. Create that label before enabling publication for a new locale.
 
 Build Check installs dependencies and runs the i18n unit tests before the
@@ -200,11 +200,14 @@ file with the reviewed PR candidates:
 }
 ```
 
-Use `--active-candidates /path/to/open-prs.json`. A matching source hash marks
-the task as active and removes it from `selected`, but does not add coverage.
-A source change makes the PR candidate stale and requeues the page. The planner
-does not query GitHub: a caller that wants complete PR deduplication must 
supply
-all active candidate PRs, including manually opened T09 PRs.
+Use `--active-candidates /path/to/open-prs.json`. An open candidate reserves 
its
+document identity and removes it from `selected`, but does not add coverage.
+A changed source hash or path reports a stale candidate and keeps the document
+reserved until the PR is merged or closed. The input may also include a
+`blockedTargets` array; those paths are excluded before the batch limit is
+applied. The planner does not query GitHub: a caller that wants complete PR
+deduplication must supply all active candidate PRs, including manually opened
+T09 PRs.
 
 The plan also reports `zh_only_change` exceptions when a committed Chinese
 counterpart changed since the T03 baseline while its English fact source did
@@ -304,29 +307,37 @@ review are required before T08 enables automatic 
generation.
 
 ## Asynchronous Japanese discovery and generation
 
-`.github/workflows/japanese-translation.yml` runs a read-only scan after
-relevant master source pushes, each day, and on manual dispatch. It recounts
+`.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
 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`,
 `active-candidates.json` and `open-pr-reconciliation.json` and writes a short
 job summary. Open PR candidates stay pending until merged. A changed source
-hash makes an older PR candidate stale and queues the page again. A PR whose
+hash makes an older PR candidate stale, but that document remains reserved
+until its PR is merged or closed. The workflow reports it for maintainer
+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 `dry-run` and accepts optional plugin, version and
+Manual dispatch defaults to `generate` and accepts optional plugin, version and
 source path filters. `validate` mode requires one exact English source path
-ending in `.md` or `.mdx`; it can call the model and create a draft PR for one
+ending in `.md` or `.mdx`; it can call the model and create a regular PR for 
one
 page while ongoing automation remains disabled. It is the first live acceptance
 path 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.
-Pushes are checked against the current registered source
-identities before any generation. The daily full scan recovers missed or
-cancelled pushes. Japanese target or state-only commits do not trigger the
-push listener. The workflow does not participate in website builds or release
-deployment.
+`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.
+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
+builds or release deployment.
 
 Ongoing generation stays off unless both repository variables
 `I18N_AUTO_TRANSLATE_ENABLED=true` and `I18N_LIVE_VERIFIED=true` are set. The
@@ -336,7 +347,7 @@ snapshots in OSS as the Apache Doris code-review runner. 
The repository's
 selected auth snapshot if Codex refreshes it. `I18N_CODEX_MODEL` is optional
 and defaults to `gpt-6-sol`. A direct OpenAI API key is not used by this
 workflow; the separate T07 manual tool still supports the Responses API.
-To open a draft PR, configure one GitHub write identity. The preferred App path
+To open a PR, configure one GitHub write identity. The preferred App path
 uses `I18N_GITHUB_APP_ID` and `I18N_GITHUB_APP_PRIVATE_KEY`; install the App on
 `apache/doris-website` with repository Contents and Pull requests write
 permissions. If an App cannot be installed, a maintainer with repository write
@@ -347,11 +358,11 @@ must store the secret. The resulting commits and PR use 
that person's identity.
 When both credentials are present, the App takes precedence. The workflow
 does not use `GITHUB_TOKEN` to create PRs because those PR checks can require
 manual approval. Set the enablement variables only after a one-page `validate`
-run and its App- or PAT-created draft PR have passed the required checks.
+run and its App- or PAT-created PR have passed the required checks.
 Missing write credentials leave manual validation as an artifact-only run;
 missing OSS credentials leave discovery available without model calls.
 
-When enabled, one run produces at most two page candidates. The T08 planner
+When enabled, one run produces at most 10 target page candidates. The T08 
planner
 and Codex adapter do not reject work based on an estimated or accumulated
 token allowance. The adapter still records actual usage for the run summary.
 Document length, chunk size, call timeout and the model's own context limits
@@ -363,11 +374,15 @@ only if its OSS source has not changed since download.
 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 draft PR with
-machine review status and never merges it. It keeps one active robot batch PR;
-if its source becomes stale and its head still matches the robot's recorded
-commit, the next run closes that stale draft and opens a fresh batch. A human
-commit on the branch stops replacement and is reported for manual handling.
+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.
+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
+branches; unresolved target/state records block their targets before the
+planner applies its batch limit. Generation rechecks PR reservations for both
+primary and reused Dev/4.x targets. Each batch writes separate page state
+shards where enabled, so different documents do not rewrite a shared ledger.
 The PR body lists the source commit, source links, source hashes, model,
 glossary version, failures and validation. The normal PR checks remain the
 merge gate.
@@ -387,7 +402,9 @@ reconciliation and full planning against a GitHub CLI mock, 
then runs the
 translation engine with a schema-shaped Codex mock. It checks that an invalid
 model unit fails validation, that a 100,000-token usage report does not stop a
 valid candidate, and that publication pushes only managed Japanese files to a
-local bare Git repository and records a mock draft PR. `report.json` and step
+local bare Git repository and records a mock regular PR with its label. Each
+local step has a 20-minute timeout to accommodate the full native scan.
+`report.json` and step
 logs remain in the output directory. No real model call or GitHub write occurs.
 
 ## Validation


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to