fix(ui): prevent generated locale rebase conflicts (#109393)

* ci(ui): make locale refresh bot-owned

* test(ui): keep locale gate coverage scoped

* fix(ci): preserve generated PR merge policy

* fix(ci): wait for generated PR head convergence

* fix(ci): retry transient guard API failures

* fix(ci): enforce locale isolation across CI events

* fix(ci): harden generated PR publication

* fix(ci): validate release gate merge tree

* fix(ci): allow trusted main locale output
This commit is contained in:
Peter Steinberger
2026-07-16 18:48:20 -07:00
committed by GitHub
parent 172c63dc23
commit 067635cb51
26 changed files with 817 additions and 556 deletions
@@ -43,6 +43,10 @@ inputs:
description: Whether stale inputs or owned-path overlap defer to a successor run or fail.
required: false
default: defer
auto-merge:
description: Enable squash auto-merge; false rejects an inherited auto-merge request.
required: false
default: "false"
runs:
using: composite
@@ -70,6 +74,7 @@ runs:
GENERATED_PATHS: ${{ inputs.generated-paths }}
INVALIDATION_PATHS: ${{ inputs.invalidation-paths }}
OVERLAP_POLICY: ${{ inputs.overlap-policy }}
AUTO_MERGE: ${{ inputs.auto-merge }}
run: |
set -euo pipefail
export GH_PROMPT_DISABLED=1
@@ -93,6 +98,13 @@ runs:
exit 1
;;
esac
case "${AUTO_MERGE}" in
true | false) ;;
*)
echo "Generated PR publication auto-merge must be 'true' or 'false'." >&2
exit 1
;;
esac
generated_paths=()
while IFS= read -r generated_path; do
@@ -321,6 +333,7 @@ runs:
if [[ -n "${final_pr_url}" ]]; then
final_pr_head="${final_pr_record#*$'\t'}"
if [[ "${final_pr_head}" = "${published_commit}" ]]; then
published_pr_url="${final_pr_url}"
echo "Generated pull request: ${final_pr_url}" >> "${GITHUB_STEP_SUMMARY}"
return 0
fi
@@ -343,6 +356,99 @@ runs:
echo "::error::Generated branch has no open same-repository pull request."
return 1
}
read_auto_merge_record_for_head() {
local attempt auto_merge_record observed_head
local expected_head="$1"
local pr_url="$2"
for attempt in 1 2 3; do
auto_merge_record="$(
timeout --signal=TERM --kill-after=10s 60s \
gh pr view "${pr_url}" --repo "${GITHUB_REPOSITORY}" \
--json autoMergeRequest,headRefOid \
--jq '[.headRefOid, (.autoMergeRequest.mergeMethod // "")] | @tsv'
)"
observed_head="${auto_merge_record%%$'\t'*}"
if [[ "${observed_head}" = "${expected_head}" ]]; then
printf '%s\n' "${auto_merge_record}"
return 0
fi
if [[ "${attempt}" -eq 3 ]]; then
return 1
fi
echo "::notice::Generated pull request head has not converged yet; rechecking." >&2
sleep "${attempt}"
done
}
ensure_auto_merge_compatible() {
local attempt auto_merge_method auto_merge_record existing_pr_head existing_pr_record
local existing_pr_url
local expected_head="$1"
for attempt in 1 2 3; do
existing_pr_record="$(find_open_pr)"
existing_pr_url="${existing_pr_record%%$'\t'*}"
if [[ -z "${existing_pr_url}" ]]; then
return 0
fi
existing_pr_head="${existing_pr_record#*$'\t'}"
if [[ "${existing_pr_head}" = "${expected_head}" ]]; then
break
fi
if [[ "${attempt}" -eq 3 ]]; then
echo "::error::Generated pull request head does not match the observed automation branch."
return 1
fi
echo "::notice::Generated pull request head has not converged yet; rechecking."
sleep "${attempt}"
done
if ! auto_merge_record="$(
read_auto_merge_record_for_head "${expected_head}" "${existing_pr_url}"
)"; then
echo "::error::Generated pull request moved during auto-merge compatibility validation."
return 1
fi
auto_merge_method="${auto_merge_record#*$'\t'}"
if [[ "${AUTO_MERGE}" != "true" && -n "${auto_merge_method}" ]]; then
echo "::error::Generated pull request still has auto-merge enabled while publication opted out."
return 1
fi
if [[ -n "${auto_merge_method}" && "${auto_merge_method}" != "SQUASH" ]]; then
echo "::error::Generated pull request already uses incompatible ${auto_merge_method} auto-merge."
return 1
fi
}
enable_auto_merge() {
local auto_merge_method auto_merge_record current_remote_head
if [[ "${AUTO_MERGE}" != "true" || -z "${published_pr_url:-}" ]]; then
return 0
fi
if ! auto_merge_record="$(
read_auto_merge_record_for_head "${published_commit}" "${published_pr_url}"
)"; then
current_remote_head="$(read_remote_head)"
if [[ "${current_remote_head}" != "${published_commit}" ]]; then
echo "Generated pull request moved before auto-merge reconciliation." \
>> "${GITHUB_STEP_SUMMARY}"
return 0
fi
echo "::error::Generated pull request head did not converge for auto-merge reconciliation."
return 1
fi
auto_merge_method="${auto_merge_record#*$'\t'}"
if [[ "${auto_merge_method}" = "SQUASH" ]]; then
echo "Squash auto-merge already enabled for generated pull request: ${published_pr_url}" \
>> "${GITHUB_STEP_SUMMARY}"
return 0
fi
if [[ -n "${auto_merge_method}" ]]; then
echo "::error::Generated pull request already uses incompatible ${auto_merge_method} auto-merge."
return 1
fi
timeout --signal=TERM --kill-after=10s 60s \
gh pr merge "${published_pr_url}" --repo "${GITHUB_REPOSITORY}" \
--auto --squash --match-head-commit "${published_commit}"
echo "Enabled squash auto-merge for exact generated head: ${published_pr_url}" \
>> "${GITHUB_STEP_SUMMARY}"
}
source_commit="$(git rev-parse HEAD)"
git config user.name "github-actions[bot]"
@@ -388,6 +494,7 @@ runs:
# Lease the exact observed automation head so a cancelled run cannot overwrite newer output.
remote_head="$(read_remote_head)"
ensure_auto_merge_compatible "${remote_head}"
push_log="${RUNNER_TEMP}/generated-pr-push.log"
if ! push_generated_branch "${remote_head}"; then
current_remote_head="$(read_remote_head)"
@@ -455,3 +562,4 @@ runs:
fi
exit 1
fi
enable_auto_merge
+30 -5
View File
@@ -120,6 +120,7 @@ jobs:
run_format_check: ${{ steps.manifest.outputs.run_format_check }}
compatibility_target: ${{ steps.manifest.outputs.compatibility_target }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
strict_control_ui_i18n: ${{ steps.changed_scope.outputs.strict_control_ui_i18n }}
run_ui_tests: ${{ steps.manifest.outputs.run_ui_tests }}
run_native_i18n: ${{ steps.manifest.outputs.run_native_i18n }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
@@ -248,16 +249,34 @@ jobs:
id: diff_base
env:
EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha || '' }}
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
RELEASE_GATE: ${{ inputs.release_gate }}
run: |
set -euo pipefail
base_sha="$EVENT_BASE_SHA"
head_sha="$(git rev-parse HEAD)"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
# A long-lived PR event can retain an old base SHA. The tested merge
# commit's first parent is the exact target tree for this run.
base_sha="$(node scripts/lib/merge-head-diff-base.mjs \
--base "$base_sha" --head HEAD --prefer-first-parent)"
fi
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_GATE" = "true" ]; then
merge_ref="refs/remotes/origin/release-gate-merge"
timeout --signal=TERM --kill-after=10s 120s git fetch \
--no-tags --no-recurse-submodules --depth=2 origin \
"+refs/pull/${PULL_REQUEST_NUMBER}/merge:${merge_ref}"
release_gate_head="$(git rev-parse "${merge_ref}^2")"
target_head="$(git rev-parse HEAD)"
if [ "$release_gate_head" != "$target_head" ]; then
echo "release_gate pull request head ${release_gate_head} does not match target ${target_head}" >&2
exit 1
fi
base_sha="$(git rev-parse "${merge_ref}^1")"
head_sha="$(git rev-parse "$merge_ref")"
fi
echo "sha=$base_sha" >> "$GITHUB_OUTPUT"
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
- name: Validate historical release target
id: historical_target
@@ -319,17 +338,23 @@ jobs:
- name: Detect changed scopes
id: changed_scope
if: github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true'
if: (github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true') || (github.event_name == 'workflow_dispatch' && inputs.release_gate)
shell: bash
env:
OPENCLAW_ALLOW_RELEASE_GENERATED_MIX: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ steps.diff_base.outputs.sha }}"
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
else
elif [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ steps.diff_base.outputs.sha }}"
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
else
BASE="${{ steps.diff_base.outputs.sha }}"
HEAD_SHA="${{ steps.diff_base.outputs.head_sha }}"
node scripts/ci-changed-scope.mjs --base "$BASE" --head "$HEAD_SHA"
fi
- name: Build CI manifest
@@ -1232,9 +1257,9 @@ jobs:
name: control-ui-i18n
needs: [preflight]
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
# Locale refresh runs separately on main and nightly. Keep drift visible on
# automatic runs without blocking unrelated work; release CI stays strict.
continue-on-error: ${{ github.event_name != 'workflow_dispatch' }}
# Source PR drift stays advisory because the post-merge bot owns repair.
# Generated locale PRs and release CI must pass the strict catalog gate.
continue-on-error: ${{ github.event_name != 'workflow_dispatch' && needs.preflight.outputs.strict_control_ui_i18n != 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 10
steps:
@@ -296,6 +296,7 @@ jobs:
head-branch: automation/control-ui-locale-refresh
commit-message: "chore(ui): refresh control ui locales"
pr-title: "chore(ui): refresh control ui locales"
auto-merge: "true"
generated-paths: ui/src/i18n
invalidation-paths: |
ui/src/i18n/locales/en.ts
+2 -2
View File
@@ -60,7 +60,7 @@ Standalone Periphery workflows enforce zero dead-code findings for the iOS and m
1. `runner-admission` waits only for canonical `main` pushes; a newer push cancels the run before Blacksmith registration.
2. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
4. `build-artifacts` and the advisory `control-ui-i18n` check overlap with the fast Linux lanes. Generated locale drift stays visible while the standalone refresh workflow repairs it in the background.
4. `build-artifacts` and the advisory `control-ui-i18n` check overlap with the fast Linux lanes. Source PRs exclude generated locale snapshots; the standalone refresh workflow repairs and auto-merges an isolated generated PR in the background. Canonical `release/YYYY.M.PATCH` branches may include release-prep locale repairs with the other generated release output.
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
6. `openclaw/ci-gate` waits for every selected lane. Admission, preflight, and security must succeed; downstream jobs may skip only when the manifest did not select them. A failed or canceled selected lane fails the aggregate.
@@ -148,7 +148,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
## Manual dispatches
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI i18n. Control UI locale parity is advisory on automatic PR and `main` runs because the standalone refresh workflow repairs generated drift in the background; it is blocking on manual CI and therefore on Full Release Validation. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI i18n. Control UI locale parity is advisory on automatic PR and `main` runs because the standalone refresh workflow repairs generated drift in the background; it is blocking on manual CI and therefore on Full Release Validation. Release prep runs the same locale sync before the Code SHA is frozen, then verifies the strict zero-fallback state. Standalone manual CI dispatches run Android only with `include_android=true` (the `release_gate` input also forces Android); the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
PR max-lines checks derive the baseline from the checked-out synthetic merge tree and verify its head parent against the event head. Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref; the max-lines baseline is compared with the target's merge base against the default-branch head resolved for that run. The `release_gate` input is an exact-SHA maintainer fallback for capacity-stalled PR CI: it requires `target_ref` to be a full commit SHA that matches the dispatched branch head and `pull_request_number` to identify the open PR whose merge tree is validated.
+1 -1
View File
@@ -212,7 +212,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
- Run `pnpm check:test-types` before release preflight so test TypeScript stays covered outside the faster local `pnpm check` gate.
- Run `pnpm check:architecture` before release preflight so the broader import cycle and architecture boundary checks are green outside the faster local gate.
- Run `pnpm build && pnpm ui:build` before `pnpm release:check` so the expected `dist/*` release artifacts and Control UI bundle exist for the pack validation step.
- Run `pnpm release:prep` after the root version bump and before tagging. It runs every deterministic release generator that commonly drifts after a version/config/API change: plugin versions, npm shrinkwraps, plugin inventory, base config schema, bundled channel config metadata, config docs baseline, plugin SDK exports, and plugin SDK API baseline. `pnpm release:check` re-runs those guards in check mode (plus a plugin SDK surface budget check) and reports every generated drift failure in one pass before running package release checks.
- Run `pnpm release:prep` after the root version bump and before tagging. It runs every deterministic release generator that commonly drifts after a version/config/API change: plugin versions, npm shrinkwraps, plugin inventory, base config schema, bundled channel config metadata, config docs baseline, plugin SDK exports, plugin SDK API baseline, and Control UI locale bundles. `pnpm release:check` re-runs those guards in check mode (including the strict zero-fallback locale gate plus the plugin SDK surface budget) and reports every generated drift failure in one pass before running package release checks.
- Plugin version sync updates the publishable `@openclaw/ai` runtime package, official plugin package versions, and existing `openclaw.compat.pluginApi` floors to the OpenClaw release version by default. Treat that field as the plugin SDK/runtime API floor, not just a copy of the package version: for plugin-only releases that intentionally remain compatible with older OpenClaw hosts, keep the floor at the oldest supported host API and document that choice in the plugin release proof.
- Run the manual `Full Release Validation` workflow before release approval to kick off all pre-release test boxes from one entrypoint. It accepts a branch, tag, or full commit SHA, dispatches manual `CI`, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full runs always include exhaustive live/E2E and Docker release-path soak; `run_release_soak=true` is retained for an explicit beta soak. Package Acceptance provides the canonical package Telegram E2E during candidate validation, avoiding a second concurrent live poller.
+3 -1
View File
@@ -9,7 +9,9 @@ read_when:
`Full Release Validation` is the release product-validation umbrella. Most work
happens in child workflows so a failed box can be rerun without restarting the
whole release.
whole release. Run release preparation before freezing the Code SHA; it
refreshes Control UI locale output when the background bot has not landed it
yet, then enforces the same strict zero-fallback check used by release CI.
Freeze the product-complete pre-changelog commit as the **Code SHA**, then run:
-1
View File
@@ -2010,7 +2010,6 @@
"ui:i18n:baseline": "node --import tsx scripts/control-ui-i18n-verify.ts baseline",
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
"ui:i18n:resolve-conflicts": "node --import tsx scripts/control-ui-i18n-resolve-conflicts.ts",
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
"ui:i18n:verify": "node --import tsx scripts/control-ui-i18n-verify.ts verify",
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
+1 -1
View File
@@ -48,7 +48,7 @@ const DEPRECATION_HYGIENE_PATH_RE =
const CANVAS_A2UI_NATIVE_RESOURCE_PATH_RE =
/^(?:pnpm-lock\.yaml$|apps\/shared\/OpenClawKit\/Sources\/OpenClawKit\/Resources\/CanvasA2UI\/|extensions\/canvas\/(?:package\.json$|scripts\/bundle-a2ui\.mjs$|src\/host\/a2ui(?:\/(?:index\.html|a2ui\.bundle\.js|\.bundle\.hash)$|-app\/))|scripts\/(?:bundle-a2ui|sync-native-a2ui)\.mjs$)/u;
const CONTROL_UI_I18N_VERIFY_PATH_RE =
/^(?:package\.json$|ui\/src\/|scripts\/(?:control-ui-i18n(?:-(?:report|resolve-conflicts|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
/^(?:package\.json$|ui\/src\/|scripts\/(?:control-ui-i18n(?:-(?:report|verify))?\.ts|lib\/control-ui-i18n-[^/]+\.ts)$|test\/scripts\/control-ui-i18n[^/]*\.test\.ts$)/u;
const CORE_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.core.json";
const EXTENSIONS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.extensions.json";
const SCRIPTS_OXLINT_TS_CONFIG = "config/tsconfig/oxlint.scripts.json";
+11
View File
@@ -27,6 +27,17 @@ export type ChangedScopeArgs = {
mergeHeadFirstParent: boolean;
};
export class ControlUiGeneratedArtifactsMixedError extends Error {}
export function assertControlUiGeneratedArtifactsIsolated(
changedPaths: string[],
branchName?: string,
): void;
export function resolveAllowedGeneratedMixBranch(
env?: Readonly<Record<string, string | undefined>>,
branchName?: string,
): string;
export function shouldStrictControlUiI18n(changedPaths: string[] | null): boolean;
export function detectChangedScope(changedPaths: string[]): ChangedScope;
export function shouldRunNativeI18n(changedPaths: string[]): boolean;
export function detectNodeFastScope(changedPaths: string[]): NodeFastScope;
+90 -3
View File
@@ -55,7 +55,12 @@ const WINDOWS_TEST_SCOPE_RE =
const WINDOWS_DAEMON_SCOPE_RE =
/^src\/daemon\/(?:schtasks(?:[-.][^/]+)?|runtime-hints\.windows-paths(?:\.test)?|test-helpers\/schtasks-(?:base-mocks|fixtures))\.ts$/;
const CONTROL_UI_I18N_SCOPE_RE =
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-(?:resolve-conflicts|verify))?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
/^(ui\/src\/i18n\/|scripts\/(?:control-ui-i18n(?:-verify)?\.ts|lib\/control-ui-i18n-(?:config|raw-copy)\.ts)$|\.github\/workflows\/control-ui-locale-refresh\.yml$)/;
const CONTROL_UI_HARD_GENERATED_I18N_RE =
/^(?:ui\/src\/i18n\/locales\/(?!en(?:-agents)?\.ts$)[^/]+\.ts|ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks\.json|[^/]+\.(?:meta\.json|tm\.jsonl)))$/;
const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/;
export class ControlUiGeneratedArtifactsMixedError extends Error {}
const CONTROL_UI_TEST_SCOPE_RE =
/^(ui\/|test\/vitest\/vitest\.shared\.config\.ts$|scripts\/ensure-playwright-chromium\.mjs$)/;
const NATIVE_I18N_SCOPE_RE =
@@ -181,6 +186,77 @@ export function detectChangedScope(changedPaths) {
};
}
/**
* Generated Control UI locale snapshots belong in their isolated automation PR.
* Mixing them into a source PR recreates deterministic rebase conflicts.
* @param {string[]} changedPaths
*/
export function assertControlUiGeneratedArtifactsIsolated(changedPaths, branchName = "") {
if (branchName === "main" || RELEASE_BRANCH_RE.test(branchName)) {
return;
}
const generatedPaths = changedPaths.filter((filePath) =>
CONTROL_UI_HARD_GENERATED_I18N_RE.test(filePath),
);
if (generatedPaths.length === 0) {
return;
}
const sourcePaths = changedPaths.filter(
(filePath) => !CONTROL_UI_HARD_GENERATED_I18N_RE.test(filePath),
);
if (sourcePaths.length === 0) {
return;
}
throw new ControlUiGeneratedArtifactsMixedError(
[
"Control UI generated locale artifacts must be isolated from source changes.",
"Commit English/source changes only; the locale refresh workflow owns generated bundles and metadata.",
...generatedPaths.map((filePath) => `- generated: ${filePath}`),
...sourcePaths.map((filePath) => `- source: ${filePath}`),
].join("\n"),
);
}
export function shouldStrictControlUiI18n(changedPaths) {
return (
changedPaths === null ||
changedPaths.some((filePath) => CONTROL_UI_HARD_GENERATED_I18N_RE.test(filePath))
);
}
function resolveChangedBranchName() {
const githubBranch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
if (githubBranch) {
return githubBranch;
}
try {
return execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim();
} catch {
return "";
}
}
export function resolveAllowedGeneratedMixBranch(
env = process.env,
branchName = resolveChangedBranchName(),
) {
if (env.GITHUB_ACTIONS === "true" && env.OPENCLAW_ALLOW_RELEASE_GENERATED_MIX !== "true") {
return "";
}
if (RELEASE_BRANCH_RE.test(branchName)) {
return branchName;
}
if (
env.GITHUB_ACTIONS === "true" &&
env.GITHUB_EVENT_NAME === "push" &&
env.GITHUB_REF === "refs/heads/main" &&
branchName === "main"
) {
return branchName;
}
return "";
}
export function shouldRunNativeI18n(changedPaths) {
return (
!Array.isArray(changedPaths) ||
@@ -352,6 +428,11 @@ export function writeGitHubOutput(
"utf8",
);
appendFileSync(outputPath, `run_control_ui_i18n=${scope.runControlUiI18n}\n`, "utf8");
appendFileSync(
outputPath,
`strict_control_ui_i18n=${shouldStrictControlUiI18n(changedPaths)}\n`,
"utf8",
);
appendFileSync(outputPath, `run_ui_tests=${scope.runUiTests}\n`, "utf8");
appendFileSync(outputPath, `run_native_i18n=${runNativeI18n}\n`, "utf8");
const changedPathsJson = JSON.stringify(changedPaths);
@@ -409,6 +490,7 @@ if (isDirectRun()) {
writeGitHubOutput(EMPTY_SCOPE, process.env.GITHUB_OUTPUT, undefined, undefined, false, []);
process.exit(0);
}
assertControlUiGeneratedArtifactsIsolated(changedPaths, resolveAllowedGeneratedMixBranch());
writeGitHubOutput(
detectChangedScope(changedPaths),
process.env.GITHUB_OUTPUT,
@@ -417,7 +499,12 @@ if (isDirectRun()) {
shouldRunNativeI18n(changedPaths),
changedPaths,
);
} catch {
writeGitHubOutput(FULL_SCOPE, process.env.GITHUB_OUTPUT, undefined, undefined, true, null);
} catch (error) {
if (error instanceof ControlUiGeneratedArtifactsMixedError) {
console.error(error.message);
process.exitCode = 1;
} else {
writeGitHubOutput(FULL_SCOPE, process.env.GITHUB_OUTPUT, undefined, undefined, true, null);
}
}
}
@@ -1,294 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { isDeepStrictEqual } from "node:util";
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
const GENERATED_ASSET_CONFLICT_RE =
/^ui\/src\/i18n\/\.i18n\/(?:catalog-fallbacks|raw-copy-baseline)\.json$|^ui\/src\/i18n\/\.i18n\/[^/]+\.(?:meta\.json|tm\.jsonl)$/u;
const GENERATED_LOCALE_PATHS = new Set(
CONTROL_UI_LOCALE_ENTRIES.map((entry) => `ui/src/i18n/locales/${entry.fileName}`),
);
const GENERATED_LOCALE_PATH_RE = /^ui\/src\/i18n\/locales\/[^/]+\.ts$/u;
const GENERATED_LOCALE_HEADER = "// Generated locale bundle for Control UI translations.\n";
const TRANSLATION_MEMORY_RE = /^ui\/src\/i18n\/\.i18n\/[^/]+\.tm\.jsonl$/u;
const GIT_OUTPUT_MAX_BYTES = 64 * 1024 * 1024;
type TranslationMemoryEntry = Record<string, unknown> & { cache_key: string };
export function isControlUiGeneratedI18nPath(filePath: string): boolean {
return GENERATED_ASSET_CONFLICT_RE.test(filePath) || GENERATED_LOCALE_PATHS.has(filePath);
}
export function isControlUiGeneratedI18nConflictPath(
filePath: string,
stages: readonly (string | null)[],
): boolean {
return (
isControlUiGeneratedI18nPath(filePath) ||
(GENERATED_LOCALE_PATH_RE.test(filePath) &&
stages.some((contents) => contents?.startsWith(GENERATED_LOCALE_HEADER)))
);
}
function parseTranslationMemory(raw: string, label: string): TranslationMemoryEntry[] {
return raw
.split("\n")
.filter((line) => line.trim())
.map((line, index) => {
const parsed = JSON.parse(line) as unknown;
if (
!parsed ||
typeof parsed !== "object" ||
Array.isArray(parsed) ||
!("cache_key" in parsed) ||
typeof parsed.cache_key !== "string" ||
!parsed.cache_key.trim()
) {
throw new Error(`${label}:${index + 1} has no cache_key`);
}
return parsed as TranslationMemoryEntry;
});
}
function indexTranslationMemory(raw: string, label: string): Map<string, TranslationMemoryEntry> {
const indexed = new Map<string, TranslationMemoryEntry>();
for (const entry of parseTranslationMemory(raw, label)) {
if (indexed.has(entry.cache_key)) {
throw new Error(`${label} has duplicate cache_key ${entry.cache_key}`);
}
indexed.set(entry.cache_key, entry);
}
return indexed;
}
export function mergeControlUiTranslationMemory(
base: string,
ours: string,
theirs: string,
): string {
const baseEntries = indexTranslationMemory(base, "stage 1 translation memory");
const oursEntries = indexTranslationMemory(ours, "stage 2 translation memory");
const theirsEntries = indexTranslationMemory(theirs, "stage 3 translation memory");
const cacheKeys = new Set([
...baseEntries.keys(),
...oursEntries.keys(),
...theirsEntries.keys(),
]);
const merged = new Map<string, TranslationMemoryEntry>();
for (const cacheKey of cacheKeys) {
const baseEntry = baseEntries.get(cacheKey);
const oursEntry = oursEntries.get(cacheKey);
const theirsEntry = theirsEntries.get(cacheKey);
let resolved: TranslationMemoryEntry | undefined;
if (isDeepStrictEqual(oursEntry, theirsEntry)) {
resolved = oursEntry;
} else if (isDeepStrictEqual(oursEntry, baseEntry)) {
resolved = theirsEntry;
} else if (isDeepStrictEqual(theirsEntry, baseEntry)) {
resolved = oursEntry;
} else {
// Both sides changed the same cache key. During rebase, stage 3 is the
// replayed branch; keep its deliberate value or deletion.
resolved = theirsEntry;
}
if (resolved) {
merged.set(cacheKey, resolved);
}
}
const ordered = [...merged.values()].toSorted((left, right) =>
left.cache_key.localeCompare(right.cache_key),
);
return ordered.length === 0
? ""
: `${ordered.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
}
export function resolveControlUiGeneratedConflict(
filePath: string,
base: string | null,
ours: string | null,
theirs: string | null,
): string | null {
if (theirs === null) {
return null;
}
if (!TRANSLATION_MEMORY_RE.test(filePath)) {
return theirs;
}
const merged = mergeControlUiTranslationMemory(base ?? "", ours ?? "", theirs);
return ours === null && base !== null && !merged ? null : merged;
}
function runCapture(cwd: string, executable: string, args: string[]): string {
const result = spawnSync(executable, args, {
cwd,
encoding: "utf8",
maxBuffer: GIT_OUTPUT_MAX_BYTES,
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `${executable} ${args.join(" ")} failed`);
}
return result.stdout;
}
function runInherited(cwd: string, executable: string, args: string[]): void {
const result = spawnSync(executable, args, { cwd, stdio: "inherit" });
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`${executable} ${args.join(" ")} failed with status ${result.status}`);
}
}
function runPnpm(cwd: string, args: string[]): void {
if (process.platform === "win32") {
runInherited(cwd, process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", "pnpm", ...args]);
return;
}
runInherited(cwd, "pnpm", args);
}
function readIndexStage(cwd: string, stage: 1 | 2 | 3, filePath: string): string | null {
const result = spawnSync("git", ["show", `:${stage}:${filePath}`], {
cwd,
encoding: "utf8",
maxBuffer: GIT_OUTPUT_MAX_BYTES,
});
if (result.error) {
throw result.error;
}
return result.status === 0 ? result.stdout : null;
}
function listUnmerged(cwd: string): string[] {
return runCapture(cwd, "git", ["diff", "--name-only", "--diff-filter=U", "-z"])
.split("\0")
.filter(Boolean);
}
function writeResolvedFile(root: string, filePath: string, contents: string): void {
const absolutePath = path.join(root, filePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, contents, "utf8");
}
function resolveGeneratedConflict(root: string, filePath: string): void {
const base = readIndexStage(root, 1, filePath);
const ours = readIndexStage(root, 2, filePath);
const theirs = readIndexStage(root, 3, filePath);
if (ours === null && theirs === null) {
throw new Error(`cannot read either conflict stage for ${filePath}`);
}
const resolved = resolveControlUiGeneratedConflict(filePath, base, ours, theirs);
if (resolved === null) {
rmSync(path.join(root, filePath), { force: true });
return;
}
writeResolvedFile(root, filePath, resolved);
}
function assertNoRecordedFallbacks(root: string): void {
const assetsDir = path.join(root, "ui", "src", "i18n", ".i18n");
const failures = readdirSync(assetsDir)
.filter((fileName) => fileName.endsWith(".meta.json"))
.flatMap((fileName) => {
const meta = JSON.parse(readFileSync(path.join(assetsDir, fileName), "utf8")) as {
fallbackKeys?: unknown;
};
return Array.isArray(meta.fallbackKeys) && meta.fallbackKeys.length > 0
? [`${fileName}: ${meta.fallbackKeys.length}`]
: [];
});
if (failures.length > 0) {
throw new Error(`generated locales still contain fallbacks:\n${failures.join("\n")}`);
}
}
function main(): void {
const root = runCapture(process.cwd(), "git", ["rev-parse", "--show-toplevel"]).trim();
const conflicts = listUnmerged(root);
if (conflicts.length === 0) {
throw new Error("no unmerged files found");
}
const formerGeneratedLocales = new Set(
conflicts.filter((filePath) => {
if (GENERATED_LOCALE_PATHS.has(filePath) || !GENERATED_LOCALE_PATH_RE.test(filePath)) {
return false;
}
const stages = ([1, 2, 3] as const).map((stage) => readIndexStage(root, stage, filePath));
return isControlUiGeneratedI18nConflictPath(filePath, stages);
}),
);
const unsupported = conflicts.filter(
(filePath) => !isControlUiGeneratedI18nPath(filePath) && !formerGeneratedLocales.has(filePath),
);
if (unsupported.length > 0) {
throw new Error(
`resolve and stage non-generated conflicts first:\n${unsupported.map((filePath) => `- ${filePath}`).join("\n")}`,
);
}
for (const filePath of conflicts) {
if (formerGeneratedLocales.has(filePath)) {
// A removed or renamed locale is absent from the active config. Its old
// generated bundle must stay deleted even when the replayed branch edited it.
rmSync(path.join(root, filePath), { force: true });
} else {
resolveGeneratedConflict(root, filePath);
}
}
// Sync first so removed English keys are pruned from generated locales before
// baseline validation; otherwise stale replayed locale keys block regeneration.
runInherited(root, process.execPath, [
"--import",
"tsx",
"scripts/control-ui-i18n.ts",
"sync",
"--write",
]);
runPnpm(root, ["ui:i18n:baseline"]);
assertNoRecordedFallbacks(root);
runPnpm(root, ["ui:i18n:check"]);
runInherited(root, "git", [
"add",
"-A",
"--",
"ui/src/i18n/.i18n/catalog-fallbacks.json",
"ui/src/i18n/.i18n/raw-copy-baseline.json",
":(glob)ui/src/i18n/.i18n/*.meta.json",
":(glob)ui/src/i18n/.i18n/*.tm.jsonl",
...GENERATED_LOCALE_PATHS,
...formerGeneratedLocales,
]);
const remaining = listUnmerged(root);
if (remaining.length > 0) {
throw new Error(
`unmerged files remain:\n${remaining.map((filePath) => `- ${filePath}`).join("\n")}`,
);
}
process.stdout.write(
`control-ui-i18n: resolved, regenerated, verified, and staged ${conflicts.length} generated conflict(s)\n`,
);
}
function isCliEntrypoint(): boolean {
const entrypoint = process.argv[1];
return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href);
}
if (isCliEntrypoint()) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+48 -54
View File
@@ -13,35 +13,6 @@ export type CatalogFallbackBaseline = {
version: number;
};
function fallbackPairs(baseline: CatalogFallbackBaseline): Set<string> {
return new Set(
Object.entries(baseline.fallbacks).flatMap(([key, locales]) =>
locales.map((locale) => `${key}\u0000${locale}`),
),
);
}
export function assertScopedCatalogFallbackUpdate(
current: CatalogFallbackBaseline,
next: CatalogFallbackBaseline,
resolvedLocale: string,
) {
if (current.version !== next.version || current.sourceHash !== next.sourceHash) {
throw new Error("scoped locale sync cannot update a stale catalog fallback baseline");
}
const currentPairs = fallbackPairs(current);
const nextPairs = fallbackPairs(next);
const added = [...nextPairs].filter((pair) => !currentPairs.has(pair));
const unrelatedRemovals = [...currentPairs].filter(
(pair) => !nextPairs.has(pair) && !pair.endsWith(`\u0000${resolvedLocale}`),
);
if (added.length > 0 || unrelatedRemovals.length > 0) {
throw new Error(
`scoped locale sync for ${resolvedLocale} found unrelated catalog fallback drift; run pnpm ui:i18n:baseline first`,
);
}
}
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales");
const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n");
@@ -153,7 +124,11 @@ export function analyzeControlUiCatalogs(
return { errors, fallbacks };
}
async function buildCatalogFallbackBaseline(): Promise<CatalogFallbackBaseline> {
async function buildCatalogFallbackBaseline(
options: {
allowCatalogDrift?: boolean;
} = {},
): Promise<CatalogFallbackBaseline> {
const sourceRaw = await readFile(SOURCE_LOCALE_PATH, "utf8");
const sourceMap = await loadLocaleMap(SOURCE_LOCALE_PATH, "en");
if (!sourceMap) {
@@ -171,7 +146,7 @@ async function buildCatalogFallbackBaseline(): Promise<CatalogFallbackBaseline>
}
const analysis = analyzeControlUiCatalogs(sourceFlat, localeFlats);
if (analysis.errors.length > 0) {
if (analysis.errors.length > 0 && !options.allowCatalogDrift) {
throw new Error(
[
"control-ui catalog verification failed.",
@@ -182,6 +157,11 @@ async function buildCatalogFallbackBaseline(): Promise<CatalogFallbackBaseline>
.join("\n"),
);
}
if (analysis.errors.length > 0) {
process.stdout.write(
`control-ui-i18n: catalog: tolerated_errors=${analysis.errors.length} during scoped sync\n`,
);
}
return {
fallbacks: analysis.fallbacks,
@@ -190,29 +170,38 @@ async function buildCatalogFallbackBaseline(): Promise<CatalogFallbackBaseline>
};
}
function printCatalogFallbackSummary(baseline: CatalogFallbackBaseline) {
const fallbackPairCount = Object.values(baseline.fallbacks).reduce(
(total, locales) => total + locales.length,
0,
);
process.stdout.write(
`control-ui-i18n: catalog: fallback_keys=${Object.keys(baseline.fallbacks).length} fallback_pairs=${fallbackPairCount}\n`,
);
}
async function verifyControlUiSourceCatalogShape() {
const sourceMap = await loadLocaleMap(SOURCE_LOCALE_PATH, "en");
if (!sourceMap) {
throw new Error("ui/src/i18n/locales/en.ts does not export en");
}
const sourceFlat = flattenControlUiCatalog(sourceMap, "en");
process.stdout.write(`control-ui-i18n: source: keys=${sourceFlat.size}\n`);
}
export async function syncControlUiCatalogFallbackBaseline(options: {
allowCatalogDrift?: boolean;
checkOnly: boolean;
resolvedLocale?: string;
write: boolean;
}) {
const baseline = await buildCatalogFallbackBaseline();
const baseline = await buildCatalogFallbackBaseline({
allowCatalogDrift: options.allowCatalogDrift,
});
const expected = `${JSON.stringify(baseline, null, 2)}\n`;
const current = existsSync(FALLBACK_BASELINE_PATH)
? await readFile(FALLBACK_BASELINE_PATH, "utf8")
: "";
if (!options.checkOnly && options.write && current !== expected) {
if (options.resolvedLocale) {
let currentBaseline: CatalogFallbackBaseline;
try {
currentBaseline = JSON.parse(current) as CatalogFallbackBaseline;
assertScopedCatalogFallbackUpdate(currentBaseline, baseline, options.resolvedLocale);
} catch (error) {
throw new Error(
`cannot refresh catalog fallback metadata after scoped locale sync: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
await mkdir(I18N_ASSETS_DIR, { recursive: true });
await writeFile(FALLBACK_BASELINE_PATH, expected, "utf8");
}
@@ -224,13 +213,7 @@ export async function syncControlUiCatalogFallbackBaseline(options: {
].join("\n"),
);
}
const fallbackPairCount = Object.values(baseline.fallbacks).reduce(
(total, locales) => total + locales.length,
0,
);
process.stdout.write(
`control-ui-i18n: catalog: fallback_keys=${Object.keys(baseline.fallbacks).length} fallback_pairs=${fallbackPairCount}\n`,
);
printCatalogFallbackSummary(baseline);
}
export async function verifyRuntimeLocaleConfig() {
@@ -262,12 +245,23 @@ export async function verifyRuntimeLocaleConfig() {
}
}
export async function verifyControlUiCatalogs(options: { checkOnly: boolean; write: boolean }) {
export async function verifyControlUiGeneratedCatalogs(options: {
checkOnly: boolean;
write: boolean;
}) {
await verifyRuntimeLocaleConfig();
await syncControlUiRawCopyBaseline(options);
await syncControlUiCatalogFallbackBaseline(options);
}
async function verifyControlUiContributorCatalogs(options: { checkOnly: boolean; write: boolean }) {
await verifyRuntimeLocaleConfig();
await syncControlUiRawCopyBaseline(options);
// Foreign catalogs may be stale after an English rename, deletion, or
// placeholder change. The post-merge locale workflow owns that repair.
await verifyControlUiSourceCatalogShape();
}
function usage(): never {
console.error("Usage: node --import tsx scripts/control-ui-i18n-verify.ts <verify|baseline>");
process.exit(2);
@@ -278,7 +272,7 @@ async function main() {
if ((command !== "verify" && command !== "baseline") || rest.length > 0) {
usage();
}
await verifyControlUiCatalogs({
await verifyControlUiContributorCatalogs({
checkOnly: command === "verify",
write: command === "baseline",
});
+54 -11
View File
@@ -11,7 +11,7 @@ import { formatErrorMessage } from "../src/infra/errors.ts";
import { formatDurationCompact } from "../src/infra/format-time/format-duration.ts";
import {
syncControlUiCatalogFallbackBaseline,
verifyControlUiCatalogs,
verifyControlUiGeneratedCatalogs,
verifyRuntimeLocaleConfig,
} from "./control-ui-i18n-verify.ts";
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
@@ -345,6 +345,24 @@ export function findPlaceholderMismatches(
return mismatches;
}
export function filterPlaceholderCompatibleTranslations(
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
): Map<string, string> {
return new Map(
[...translatedFlat].filter(([key, translated]) => {
const source = sourceFlat.get(key);
return (
source !== undefined &&
compareStringArrays(
extractTranslationPlaceholders(source),
extractTranslationPlaceholders(translated),
)
);
}),
);
}
function assertPlaceholderParity(
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
@@ -1125,6 +1143,23 @@ type SyncOutcome = {
wrote: boolean;
};
export function assertNoControlUiFallbacks(
outcomes: ReadonlyArray<Pick<SyncOutcome, "fallbackCount" | "locale">>,
) {
const fallbackLocales = outcomes.filter((outcome) => outcome.fallbackCount > 0);
if (fallbackLocales.length === 0) {
return;
}
throw new Error(
[
"control-ui-i18n generated locales still contain English fallbacks.",
...fallbackLocales.map(
(outcome) => `${outcome.locale}: ${outcome.fallbackCount} fallback keys`,
),
].join("\n"),
);
}
async function syncLocale(
entry: LocaleEntry,
options: { allowTranslate: boolean; checkOnly: boolean; force: boolean; write: boolean },
@@ -1139,6 +1174,9 @@ async function syncLocale(
const existingPath = localeFilePath(entry);
const existingMap = (await loadLocaleMap(existingPath, entry.exportName)) ?? {};
const existingFlat = flattenTranslations(existingMap);
// Placeholder changes invalidate the old translation even when the key stays
// stable. Treat it as pending so the locale bot can repair source-only PRs.
const reusableExistingFlat = filterPlaceholderCompatibleTranslations(sourceFlat, existingFlat);
const previousMeta = await loadMeta(metaPath(entry));
const glossaryFilePath = glossaryPath(entry);
const glossary = await loadGlossary(glossaryFilePath);
@@ -1148,7 +1186,7 @@ async function syncLocale(
allowTranslate,
cacheKeyFor: (key, textHash) => cacheKey(key, textHash, entry.locale),
entry,
existingFlat,
existingFlat: reusableExistingFlat,
force: options.force,
hashText,
previousMeta,
@@ -1302,7 +1340,7 @@ async function syncLocale(
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.command === "check") {
await verifyControlUiCatalogs({
await verifyControlUiGeneratedCatalogs({
checkOnly: true,
write: false,
});
@@ -1357,19 +1395,24 @@ async function main() {
if (args.command === "sync" && args.write) {
await syncControlUiCatalogFallbackBaseline({
// A scoped matrix worker can observe unsynced sibling locales. The final
// aggregate sync still rebuilds and validates the complete catalog.
allowCatalogDrift: Boolean(args.localeFilter),
checkOnly: false,
resolvedLocale: args.localeFilter ?? undefined,
write: true,
});
}
if (args.command === "check" && changed.length > 0) {
throw new Error(
[
"control-ui-i18n drift detected.",
"Run `node --import tsx scripts/control-ui-i18n.ts sync --write` and commit the results.",
].join("\n"),
);
if (args.command === "check") {
assertNoControlUiFallbacks(outcomes);
if (changed.length > 0) {
throw new Error(
[
"control-ui-i18n drift detected.",
"Run `node --import tsx scripts/control-ui-i18n.ts sync --write` and commit the results.",
].join("\n"),
);
}
}
if (args.command === "sync" && !args.write && changed.length > 0) {
+1
View File
@@ -98,6 +98,7 @@ export function githubApi(
options?: {
fetchImpl?: typeof fetch;
responseMaxBodyBytes?: number;
retryDelaysMs?: readonly number[];
timeoutMs?: number;
},
): { request(path: string, options?: Record<string, unknown>): Promise<unknown> };
+42 -25
View File
@@ -1,9 +1,13 @@
import { setTimeout as wait } from "node:timers/promises";
import { readBoundedResponseText } from "../lib/bounded-response.mjs";
export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024;
export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024;
export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000;
const githubApiRetryStatuses = new Set([502, 503, 504]);
const githubApiRetryDelaysMs = [1_000, 2_000, 4_000];
export function guardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
const eventHeadSha = event?.pull_request?.head?.sha;
const eventAfterSha = event?.after;
@@ -230,6 +234,7 @@ function combineAbortSignals(signals) {
export function createGitHubApi(token, options = {}) {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS;
const retryDelaysMs = options.retryDelaysMs ?? githubApiRetryDelaysMs;
const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES;
const baseHeaders = {
accept: "application/vnd.github+json",
@@ -238,8 +243,9 @@ export function createGitHubApi(token, options = {}) {
"x-github-api-version": "2022-11-28",
};
const request = async (path, requestOptions = {}) => {
const method = requestOptions.method ?? "GET";
const method = (requestOptions.method ?? "GET").toUpperCase();
const timeoutController = new AbortController();
const requestSignal = combineAbortSignals([requestOptions.signal, timeoutController.signal]);
let timeout;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
@@ -249,32 +255,43 @@ export function createGitHubApi(token, options = {}) {
timeout.unref?.();
});
const operationPromise = (async () => {
const response = await fetchImpl(`https://api.github.com${path}`, {
...requestOptions,
signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]),
headers: { ...baseHeaders, ...requestOptions.headers },
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
let errorText;
try {
errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, {
signal: timeoutController.signal,
timeoutPromise,
});
} catch (bodyError) {
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
for (let attempt = 0; ; attempt += 1) {
const response = await fetchImpl(`https://api.github.com${path}`, {
...requestOptions,
signal: requestSignal,
headers: { ...baseHeaders, ...requestOptions.headers },
});
if (response.status === 204) {
return null;
}
const error = new Error(`${response.status} ${response.statusText}: ${errorText}`);
error.status = response.status;
throw error;
if (!response.ok) {
if (
(method === "GET" || method === "HEAD") &&
githubApiRetryStatuses.has(response.status) &&
attempt < retryDelaysMs.length
) {
await response.body?.cancel().catch(() => {});
await wait(retryDelaysMs[attempt], undefined, { signal: requestSignal });
continue;
}
let errorText;
try {
errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, {
signal: timeoutController.signal,
timeoutPromise,
});
} catch (bodyError) {
errorText = bodyError instanceof Error ? bodyError.message : String(bodyError);
}
const error = new Error(`${response.status} ${response.statusText}: ${errorText}`);
error.status = response.status;
throw error;
}
return await readBoundedGitHubJson(response, responseMaxBodyBytes, {
signal: timeoutController.signal,
timeoutPromise,
});
}
return await readBoundedGitHubJson(response, responseMaxBodyBytes, {
signal: timeoutController.signal,
timeoutPromise,
});
})();
operationPromise.catch(() => {});
try {
@@ -62,7 +62,12 @@ export function findTrustedSecuritySensitiveGuardActor(options: {
}): Promise<{ login: string; reason: string } | null>;
export function githubApi(
token: string,
options?: { fetchImpl?: typeof fetch; responseMaxBodyBytes?: number; timeoutMs?: number },
options?: {
fetchImpl?: typeof fetch;
responseMaxBodyBytes?: number;
retryDelaysMs?: readonly number[];
timeoutMs?: number;
},
): { request(path: string, options?: Record<string, unknown>): Promise<unknown> };
export function readBoundedGitHubErrorText(
response: Response,
+7
View File
@@ -84,6 +84,13 @@ const releaseTasks = [
scopes: ["plugin-sdk"],
check: pnpmCommand("plugin-sdk:surface:check"),
},
{
id: "control-ui-i18n",
name: "Control UI locale bundles",
scopes: ["version"],
fix: pnpmCommand("ui:i18n:sync"),
check: pnpmCommand("ui:i18n:check"),
},
];
const selectedTasks = releaseTasks.filter((task) => taskMatchesScopes(task, parsedArgs.scopes));
const shouldCheckMacosVersions = parsedArgs.scopes.has("all") || parsedArgs.scopes.has("version");
+4 -2
View File
@@ -822,11 +822,13 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
"test/scripts/ci-workflow-guards.test.ts",
],
],
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
[
"scripts/ci-changed-scope.mjs",
["src/scripts/ci-changed-scope.test.ts", "test/scripts/control-ui-i18n.test.ts"],
],
["scripts/periphery-intersection.mjs", ["test/scripts/periphery-intersection.test.ts"]],
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/control-ui-i18n-resolve-conflicts.ts", ["test/scripts/control-ui-i18n.test.ts"]],
["scripts/apple-app-i18n.ts", ["test/scripts/apple-app-i18n.test.ts"]],
[
"scripts/native-app-i18n.ts",
+1 -1
View File
@@ -851,7 +851,6 @@ describe("detectChangedScope", () => {
for (const scriptPath of [
"scripts/control-ui-i18n.ts",
"scripts/control-ui-i18n-resolve-conflicts.ts",
"scripts/control-ui-i18n-verify.ts",
"scripts/lib/control-ui-i18n-raw-copy.ts",
]) {
@@ -1047,6 +1046,7 @@ describe("detectChangedScope", () => {
run_fast_install_smoke: "false",
run_full_install_smoke: "false",
run_control_ui_i18n: "false",
strict_control_ui_i18n: "false",
run_ui_tests: "false",
run_native_i18n: "false",
changed_paths_json: "[]",
+1 -1
View File
@@ -886,7 +886,7 @@ describe("scripts/changed-lanes", () => {
});
it("routes Control UI i18n tooling changes through keyless catalog verification", () => {
const result = detectChangedLanes(["scripts/control-ui-i18n-resolve-conflicts.ts"]);
const result = detectChangedLanes(["scripts/control-ui-i18n-verify.ts"]);
const plan = createChangedCheckPlan(result);
expect(shouldRunControlUiI18nVerify(result.paths)).toBe(true);
+238 -1
View File
@@ -505,11 +505,16 @@ function runDependencyCheckFixture(options: { historicalTarget: boolean; scripts
function runGeneratedPublisherScenario(
baseChangePath: "a" | "b" | null,
options: {
autoMerge?: boolean;
existingAutoMergeMethod?: "MERGE" | "REBASE" | "SQUASH";
existingPr?: boolean;
expectFailure?: boolean;
failGeneratedPush?: boolean;
mergeGeneratedPush?: boolean;
noGeneratedChange?: boolean;
overlapPolicy?: string;
stalePrHeadOnce?: boolean;
stalePrViewHeadOnce?: boolean;
updateSource?: boolean;
} = {},
) {
@@ -523,7 +528,9 @@ function runGeneratedPublisherScenario(
const fakeBin = path.join(root, "bin");
const runnerTemp = path.join(root, "runner-temp");
const prState = path.join(root, "pr-open");
const mergeCalls = path.join(root, "merge-calls");
const stalePrHeadOnce = path.join(root, "stale-pr-head-once");
const stalePrViewHeadOnce = path.join(root, "stale-pr-view-head-once");
const summary = path.join(root, "summary.md");
mkdirSync(generatedDir, { recursive: true });
@@ -534,6 +541,9 @@ function runGeneratedPublisherScenario(
if (options.stalePrHeadOnce) {
writeFileSync(stalePrHeadOnce, "", "utf8");
}
if (options.stalePrViewHeadOnce) {
writeFileSync(stalePrViewHeadOnce, "", "utf8");
}
runGit(root, ["init", "--bare", origin]);
runGit(root, ["init", "--initial-branch=main", worktree]);
runGit(worktree, ["config", "user.name", "Test Publisher"]);
@@ -576,6 +586,24 @@ function runGeneratedPublisherScenario(
if (!options.noGeneratedChange) {
writeFileSync(path.join(generatedDir, "a.txt"), "desired-a\n", "utf8");
}
if (options.failGeneratedPush) {
writeExecutable(path.join(origin, "hooks", "pre-receive"), [
"#!/bin/sh",
'rm -f "$0"',
"exit 1",
]);
}
if (options.mergeGeneratedPush) {
writeExecutable(path.join(origin, "hooks", "post-receive"), [
"#!/bin/sh",
"while read -r old_head new_head ref; do",
' if [ "$ref" = "refs/heads/automation/locale" ]; then',
' git update-ref refs/heads/main "$new_head"',
' git update-ref -d refs/heads/automation/locale "$new_head"',
" fi",
"done",
]);
}
writeExecutable(path.join(fakeBin, "timeout"), [
"#!/usr/bin/env bash",
@@ -610,6 +638,21 @@ function runGeneratedPublisherScenario(
' printf "%s\\n" "https://github.com/openclaw/openclaw/pull/1"',
" ;;",
" pr:edit) exit 0 ;;",
" pr:view)",
' [[ -n "${GH_TOKEN:-}" ]]',
' [[ -f "$FAKE_PR_STATE" ]]',
' if [[ -f "$FAKE_STALE_PR_VIEW_HEAD_ONCE" ]]; then',
' head="0000000000000000000000000000000000000000"',
' rm -f "$FAKE_STALE_PR_VIEW_HEAD_ONCE"',
" else",
' head="$(git --git-dir="$FAKE_ORIGIN" rev-parse refs/heads/automation/locale)"',
" fi",
' printf "%s\\t%s\\n" "$head" "$FAKE_AUTO_MERGE_METHOD"',
" ;;",
" pr:merge)",
' [[ "$GH_TOKEN" == "test-token" ]]',
' printf "%s\\n" "$*" >> "$FAKE_MERGE_CALLS"',
" ;;",
' *) printf "unexpected gh call: %s\\n" "$*" >&2; exit 2 ;;',
"esac",
]);
@@ -625,9 +668,13 @@ function runGeneratedPublisherScenario(
...process.env,
BASE_BRANCH: "main",
COMMIT_MESSAGE: "chore(test): refresh generated output",
AUTO_MERGE: String(options.autoMerge ?? false),
FAKE_AUTO_MERGE_METHOD: options.existingAutoMergeMethod ?? "",
FAKE_ORIGIN: origin,
FAKE_MERGE_CALLS: mergeCalls,
FAKE_PR_STATE: prState,
FAKE_STALE_HEAD_ONCE: stalePrHeadOnce,
FAKE_STALE_PR_VIEW_HEAD_ONCE: stalePrViewHeadOnce,
GENERATED_PATHS: "generated",
INVALIDATION_PATHS: "source",
OVERLAP_POLICY: options.overlapPolicy ?? "defer",
@@ -673,7 +720,14 @@ function runGeneratedPublisherScenario(
generatedB: branchExists
? runGit(root, ["--git-dir", origin, "show", `${branchRef}:generated/b.txt`])
: "",
mainGeneratedA: runGit(root, [
"--git-dir",
origin,
"show",
"refs/heads/main:generated/a.txt",
]),
mainHead: runGit(root, ["--git-dir", origin, "rev-parse", "refs/heads/main"]),
mergeCalls: existsSync(mergeCalls) ? readFileSync(mergeCalls, "utf8") : "",
publishOutput,
summary: readFileSync(summary, "utf8"),
};
@@ -772,6 +826,35 @@ describe("ci workflow guards", () => {
expect(validationStep.run).toContain(
"release_gate cannot be combined with historical_target_tag",
);
const diffBaseStep = preflightSteps.find(
(step: WorkflowStep) => step.name === "Resolve exact diff base",
);
expect(diffBaseStep.env).toMatchObject({
PULL_REQUEST_NUMBER: "${{ inputs.pull_request_number }}",
RELEASE_GATE: "${{ inputs.release_gate }}",
});
expect(diffBaseStep.run).toContain("refs/pull/${PULL_REQUEST_NUMBER}/merge");
expect(diffBaseStep.run).toContain('release_gate_head="$(git rev-parse "${merge_ref}^2")"');
expect(diffBaseStep.run).toContain(
"release_gate pull request head ${release_gate_head} does not match target ${target_head}",
);
expect(diffBaseStep.run).toContain('base_sha="$(git rev-parse "${merge_ref}^1")"');
expect(diffBaseStep.run).toContain('head_sha="$(git rev-parse "$merge_ref")"');
expect(diffBaseStep.run).toContain('echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"');
const changedScopeStep = preflightSteps.find(
(step: WorkflowStep) => step.name === "Detect changed scopes",
);
expect(changedScopeStep.if).toContain(
"github.event_name == 'workflow_dispatch' && inputs.release_gate",
);
expect(changedScopeStep.env?.OPENCLAW_ALLOW_RELEASE_GENERATED_MIX).toContain(
"github.event_name == 'workflow_dispatch'",
);
expect(changedScopeStep.run).toContain('elif [ "${{ github.event_name }}" = "pull_request" ]');
expect(changedScopeStep.run).toContain('HEAD_SHA="${{ steps.diff_base.outputs.head_sha }}"');
expect(changedScopeStep.run).toContain(
'node scripts/ci-changed-scope.mjs --base "$BASE" --head "$HEAD_SHA"',
);
expect(workflow.jobs.preflight.permissions).toEqual({ contents: "read" });
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
"OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}",
@@ -1123,7 +1206,13 @@ describe("ci workflow guards", () => {
required: false,
default: "defer",
});
expect(publishAction.inputs["auto-merge"]).toEqual({
description: "Enable squash auto-merge; false rejects an inherited auto-merge request.",
required: false,
default: "false",
});
expect(actionPublishStep.env.OVERLAP_POLICY).toBe("${{ inputs.overlap-policy }}");
expect(actionPublishStep.env.AUTO_MERGE).toBe("${{ inputs.auto-merge }}");
expect(actionPublishStep.run).toContain('case "${OVERLAP_POLICY}" in');
expect(actionPublishStep.run).toContain("defer | fail");
expect(actionPublishStep.run).toContain("GIT_TERMINAL_PROMPT=0");
@@ -1192,7 +1281,19 @@ describe("ci workflow guards", () => {
expect(actionPublishStep.run).toContain('--base "${BASE_BRANCH}"');
expect(actionPublishStep.run).toContain('--head "${HEAD_BRANCH}"');
expect(actionPublishStep.run).toContain('--body-file "${body_file}"');
expect(actionPublishStep.run).toContain("ensure_auto_merge_compatible");
expect(actionPublishStep.run).toContain("enable_auto_merge");
expect(actionPublishStep.run).not.toContain("disable_existing_auto_merge");
expect(actionPublishStep.run).not.toContain("--disable-auto");
expect(actionPublishStep.run).toContain("--json autoMergeRequest");
expect(actionPublishStep.run).not.toContain('GH_TOKEN="${CONTENTS_TOKEN}"');
expect(actionPublishStep.run).toContain(
'--auto --squash --match-head-commit "${published_commit}"',
);
expect(actionPublishStep.run).not.toContain('HEAD:"${BASE_BRANCH}"');
expect(readFileSync(".github/workflows/ci.yml", "utf8")).toContain(
"OPENCLAW_ALLOW_RELEASE_GENERATED_MIX",
);
for (const [
ownerWorkflow,
@@ -1273,6 +1374,9 @@ describe("ci workflow guards", () => {
".github/actions/publish-generated-pr/action.yml",
);
expect(publishStep.with).not.toHaveProperty("overlap-policy");
expect(publishStep.with["auto-merge"]).toBe(
automationBranch.includes("control-ui") ? "true" : undefined,
);
expect(publishStep.with["pr-body"]).toContain("## What Problem This Solves");
expect(publishStep.with["pr-body"]).toContain("## Evidence");
expect(publishStep.with["pr-body"]).toContain("${{ needs.resolve-base.outputs.sha }}");
@@ -1280,6 +1384,137 @@ describe("ci workflow guards", () => {
}
});
it.skipIf(process.platform === "win32")(
"enables auto-merge for the exact generated pull request head",
() => {
const result = runGeneratedPublisherScenario(null, { autoMerge: true });
expect(result.branchExists).toBe(true);
expect(result.mergeCalls).toContain("pr merge https://github.com/openclaw/openclaw/pull/1");
expect(result.mergeCalls).toContain("--auto --squash --match-head-commit");
expect(result.summary).toContain("Enabled squash auto-merge for exact generated head");
},
);
it.skipIf(process.platform === "win32")(
"waits for the published pull request head before enabling auto-merge",
() => {
const result = runGeneratedPublisherScenario(null, {
autoMerge: true,
stalePrViewHeadOnce: true,
});
expect(result.mergeCalls).toContain("--auto --squash --match-head-commit");
expect(result.publishOutput).toContain(
"Generated pull request head has not converged yet; rechecking",
);
},
);
it.skipIf(process.platform === "win32")(
"preserves inherited auto-merge while replacing a generated pull request head",
() => {
const result = runGeneratedPublisherScenario(null, {
autoMerge: true,
existingAutoMergeMethod: "SQUASH",
existingPr: true,
});
expect(result.generatedA).toBe("desired-a");
expect(result.mergeCalls).toBe("");
expect(result.summary).toContain(
"Squash auto-merge already enabled for generated pull request",
);
},
);
it.skipIf(process.platform === "win32")(
"accepts inherited auto-merge completing immediately after publication",
() => {
const result = runGeneratedPublisherScenario(null, {
autoMerge: true,
existingAutoMergeMethod: "SQUASH",
existingPr: true,
mergeGeneratedPush: true,
});
expect(result.branchExists).toBe(false);
expect(result.mainGeneratedA).toBe("desired-a");
expect(result.mergeCalls).toBe("");
expect(result.summary).toContain(
"Generated output was merged before pull request reconciliation",
);
},
);
it.skipIf(process.platform === "win32")(
"waits for the existing pull request head before replacing it",
() => {
const result = runGeneratedPublisherScenario(null, {
autoMerge: true,
existingAutoMergeMethod: "SQUASH",
existingPr: true,
stalePrHeadOnce: true,
});
expect(result.generatedA).toBe("desired-a");
expect(result.publishOutput).toContain(
"Generated pull request head has not converged yet; rechecking",
);
},
);
it.skipIf(process.platform === "win32")(
"refuses to replace an auto-merge-enabled head when publication opts out",
() => {
const result = runGeneratedPublisherScenario(null, {
autoMerge: false,
existingAutoMergeMethod: "SQUASH",
existingPr: true,
expectFailure: true,
});
expect(result.generatedA).toBe("stale-pr-a");
expect(result.mergeCalls).toBe("");
expect(result.publishOutput).toContain("auto-merge enabled while publication opted out");
},
);
it.skipIf(process.platform === "win32")(
"does not mutate inherited auto-merge when generated publication fails",
() => {
const result = runGeneratedPublisherScenario(null, {
autoMerge: true,
existingAutoMergeMethod: "SQUASH",
existingPr: true,
expectFailure: true,
failGeneratedPush: true,
});
expect(result.generatedA).toBe("stale-pr-a");
expect(result.mergeCalls).toBe("");
expect(result.summary).not.toContain("auto-merge");
},
);
it.skipIf(process.platform === "win32")(
"rejects an incompatible inherited auto-merge method without mutating it",
() => {
const result = runGeneratedPublisherScenario(null, {
autoMerge: true,
existingAutoMergeMethod: "MERGE",
existingPr: true,
expectFailure: true,
});
expect(result.generatedA).toBe("stale-pr-a");
expect(result.mergeCalls).toBe("");
expect(result.publishOutput).toContain(
"Generated pull request already uses incompatible MERGE auto-merge",
);
},
);
it.skipIf(process.platform === "win32")(
"defers a newer owned snapshot even when the desired diff is disjoint",
() => {
@@ -3036,7 +3271,9 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
);
expect(localeJob.needs).toEqual(["preflight"]);
expect(localeJob.if).toBe("needs.preflight.outputs.run_control_ui_i18n == 'true'");
expect(localeJob["continue-on-error"]).toBe("${{ github.event_name != 'workflow_dispatch' }}");
expect(localeJob["continue-on-error"]).toBe(
"${{ github.event_name != 'workflow_dispatch' && needs.preflight.outputs.strict_control_ui_i18n != 'true' }}",
);
expect(localeStep.run).toBe("pnpm ui:i18n:check");
expect(readFileSync(".github/workflows/full-release-validation.yml", "utf8")).toContain(
'dispatch_and_wait ci.yml "$dispatch_run_name"',
+130 -142
View File
@@ -7,19 +7,19 @@ import { pathToFileURL } from "node:url";
import * as ts from "typescript";
import { describe, expect, it } from "vitest";
import {
isControlUiGeneratedI18nConflictPath,
isControlUiGeneratedI18nPath,
mergeControlUiTranslationMemory,
resolveControlUiGeneratedConflict,
} from "../../scripts/control-ui-i18n-resolve-conflicts.ts";
assertControlUiGeneratedArtifactsIsolated,
resolveAllowedGeneratedMixBranch,
shouldStrictControlUiI18n,
} from "../../scripts/ci-changed-scope.mjs";
import {
analyzeControlUiCatalogs,
assertScopedCatalogFallbackUpdate,
flattenControlUiCatalog,
} from "../../scripts/control-ui-i18n-verify.ts";
import {
appendBoundedProcessOutput,
assertNoControlUiFallbacks,
buildBatchPrompt,
filterPlaceholderCompatibleTranslations,
parseTranslationBatchReply,
runProcess,
shouldReuseExistingTranslation,
@@ -27,6 +27,99 @@ import {
import { collectControlUiRawCopyFromSource } from "../../scripts/lib/control-ui-i18n-raw-copy.ts";
import { createTempDirTracker } from "../helpers/temp-dir.js";
describe("control-ui-i18n generated ownership", () => {
it("keeps generated locale snapshots out of source PRs", () => {
expect(() =>
assertControlUiGeneratedArtifactsIsolated([
"ui/src/i18n/locales/en.ts",
"ui/src/i18n/locales/de.ts",
"ui/src/i18n/.i18n/de.meta.json",
]),
).toThrow("Control UI generated locale artifacts must be isolated from source changes");
expect(() =>
assertControlUiGeneratedArtifactsIsolated([
"ui/src/i18n/locales/de.ts",
"ui/src/i18n/.i18n/catalog-fallbacks.json",
"ui/src/i18n/.i18n/de.meta.json",
"ui/src/i18n/.i18n/de.tm.jsonl",
]),
).not.toThrow();
expect(() =>
assertControlUiGeneratedArtifactsIsolated([
"ui/src/i18n/locales/de.ts",
"ui/src/i18n/.i18n/glossary.de.json",
]),
).toThrow("Control UI generated locale artifacts must be isolated from source changes");
expect(() =>
assertControlUiGeneratedArtifactsIsolated([
"ui/src/i18n/.i18n/catalog-fallbacks.json",
"ui/src/i18n/.i18n/raw-copy-baseline.json",
]),
).toThrow("Control UI generated locale artifacts must be isolated from source changes");
expect(() =>
assertControlUiGeneratedArtifactsIsolated([
"ui/src/i18n/locales/en.ts",
"ui/src/i18n/.i18n/raw-copy-baseline.json",
]),
).not.toThrow();
expect(() =>
assertControlUiGeneratedArtifactsIsolated(
["package.json", "ui/src/i18n/locales/de.ts"],
"release/2026.7.3",
),
).not.toThrow();
expect(() =>
assertControlUiGeneratedArtifactsIsolated(
["package.json", "ui/src/i18n/locales/de.ts"],
"main",
),
).not.toThrow();
expect(shouldStrictControlUiI18n(["ui/src/i18n/locales/de.ts"])).toBe(true);
expect(shouldStrictControlUiI18n(["ui/src/i18n/locales/en.ts"])).toBe(false);
expect(shouldStrictControlUiI18n(null)).toBe(true);
});
it("allows generated release output on trusted release and main runs only", () => {
const trustedActions = {
GITHUB_ACTIONS: "true",
OPENCLAW_ALLOW_RELEASE_GENERATED_MIX: "true",
};
expect(
resolveAllowedGeneratedMixBranch(
{
...trustedActions,
GITHUB_EVENT_NAME: "push",
GITHUB_REF: "refs/heads/main",
},
"main",
),
).toBe("main");
expect(
resolveAllowedGeneratedMixBranch(
{
...trustedActions,
GITHUB_EVENT_NAME: "pull_request",
GITHUB_REF: "refs/pull/1/merge",
},
"main",
),
).toBe("");
expect(resolveAllowedGeneratedMixBranch(trustedActions, "release/2026.7.3")).toBe(
"release/2026.7.3",
);
expect(resolveAllowedGeneratedMixBranch({ GITHUB_ACTIONS: "true" }, "release/2026.7.3")).toBe(
"",
);
});
});
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
@@ -65,111 +158,6 @@ async function waitForChildClose(
}
describe("control-ui-i18n process runner", () => {
it("recognizes only generated conflict paths", () => {
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/catalog-fallbacks.json")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/raw-copy-baseline.json")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/de.meta.json")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/de.tm.jsonl")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/de.ts")).toBe(true);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/.i18n/glossary.de.json")).toBe(false);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/en.ts")).toBe(false);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/en-agents.ts")).toBe(false);
expect(isControlUiGeneratedI18nPath("ui/src/i18n/locales/unknown.ts")).toBe(false);
expect(
isControlUiGeneratedI18nConflictPath("ui/src/i18n/locales/removed.ts", [
"// Generated locale bundle for Control UI translations.\nexport const removed = {};\n",
null,
]),
).toBe(true);
expect(
isControlUiGeneratedI18nConflictPath("ui/src/i18n/locales/en-agents.ts", [
"// Agent-specific English strings stay split from the oversized source locale.\n",
]),
).toBe(false);
});
it("uses the replayed entry when both sides change the same cache key", () => {
const base = [{ cache_key: "shared", translated: "base" }];
const ours = [
{ cache_key: "b", translated: "upstream-only" },
{ cache_key: "shared", translated: "upstream" },
];
const theirs = [
{ cache_key: "a", translated: "branch-only" },
{ cache_key: "shared", translated: "branch" },
];
const merged = mergeControlUiTranslationMemory(
`${base.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
`${ours.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
`${theirs.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
)
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cache_key: string; translated: string });
expect(merged).toEqual([
{ cache_key: "a", translated: "branch-only" },
{ cache_key: "b", translated: "upstream-only" },
{ cache_key: "shared", translated: "branch" },
]);
});
it("preserves one-sided translation-memory changes and deletions", () => {
const base = [
{ cache_key: "branch-change", translated: "base" },
{ cache_key: "branch-delete", translated: "base" },
{ cache_key: "upstream-change", translated: "base" },
{ cache_key: "upstream-delete", translated: "base" },
];
const ours = [
{ cache_key: "branch-change", translated: "base" },
{ cache_key: "branch-delete", translated: "base" },
{ cache_key: "upstream-add", translated: "upstream" },
{ cache_key: "upstream-change", translated: "upstream" },
];
const theirs = [
{ cache_key: "branch-add", translated: "branch" },
{ cache_key: "branch-change", translated: "branch" },
{ cache_key: "upstream-change", translated: "base" },
{ cache_key: "upstream-delete", translated: "base" },
];
const encode = (entries: Array<{ cache_key: string; translated: string }>) =>
`${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
const merged = mergeControlUiTranslationMemory(encode(base), encode(ours), encode(theirs))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cache_key: string; translated: string });
expect(merged).toEqual([
{ cache_key: "branch-add", translated: "branch" },
{ cache_key: "branch-change", translated: "branch" },
{ cache_key: "upstream-add", translated: "upstream" },
{ cache_key: "upstream-change", translated: "upstream" },
]);
});
it("preserves replayed-side generated-file deletion before regeneration", () => {
expect(
resolveControlUiGeneratedConflict(
"ui/src/i18n/.i18n/de.tm.jsonl",
'{"cache_key":"stale"}\n',
'{"cache_key":"stale"}\n',
null,
),
).toBeNull();
});
it("preserves stage-2 translation-memory deletion when no records survive", () => {
expect(
resolveControlUiGeneratedConflict(
"ui/src/i18n/.i18n/de.tm.jsonl",
'{"cache_key":"a"}\n{"cache_key":"b"}\n',
null,
'{"cache_key":"a"}\n',
),
).toBeNull();
});
it("builds a deterministic fallback list without accepting catalog drift", () => {
const source = flattenControlUiCatalog(
{ group: { first: "First {count}", second: "Second" } },
@@ -215,36 +203,6 @@ describe("control-ui-i18n process runner", () => {
);
});
it("allows scoped sync to remove only that locale's approved fallbacks", () => {
const current = {
fallbacks: { "group.second": ["de", "fr"] },
sourceHash: "source",
version: 1,
};
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["fr"] } },
"de",
),
).not.toThrow();
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["de"] } },
"de",
),
).toThrow("unrelated catalog fallback drift");
expect(() =>
assertScopedCatalogFallbackUpdate(
current,
{ ...current, fallbacks: { "group.second": ["de", "es", "fr"] } },
"de",
),
).toThrow("unrelated catalog fallback drift");
});
it("finds raw text and attributes split by template interpolation", () => {
const source =
'const jsx = <button aria-label="Archive" />; const view = html`<button title="Delete ${name}">Delete ${name}</button>`;';
@@ -285,7 +243,7 @@ describe("control-ui-i18n process runner", () => {
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("catalog:");
expect(result.stdout).toContain("source:");
expect(result.stdout).not.toContain("provider=openai");
expect(result.stdout).not.toContain("provider=anthropic");
});
@@ -316,6 +274,21 @@ describe("control-ui-i18n process runner", () => {
).toEqual(new Map([["configView.viewPendingChange", "Pending change ({count})"]]));
});
it("makes placeholder-incompatible existing copy pending for bot repair", () => {
const reusable = filterPlaceholderCompatibleTranslations(
new Map([
["changed", "Waiting for {total}"],
["same", "Waiting for {count}"],
]),
new Map([
["changed", "Warten auf {count}"],
["same", "Warten auf {count}"],
]),
);
expect([...reusable]).toEqual([["same", "Warten auf {count}"]]);
});
it("feeds the exact validation failure back into a retry prompt", () => {
const items = [
{
@@ -347,6 +320,21 @@ describe("control-ui-i18n process runner", () => {
expect(fallbacks).toEqual([]);
});
it("makes the strict gate reject recorded English fallbacks", () => {
expect(() =>
assertNoControlUiFallbacks([
{ fallbackCount: 0, locale: "de" },
{ fallbackCount: 2, locale: "fr" },
]),
).toThrow("fr: 2 fallback keys");
expect(() =>
assertNoControlUiFallbacks([
{ fallbackCount: 0, locale: "de" },
{ fallbackCount: 0, locale: "fr" },
]),
).not.toThrow();
});
it("refreshes recorded fallback copy when sync is forced without a provider", () => {
expect(
shouldReuseExistingTranslation({
@@ -670,6 +670,34 @@ describe("dependency guard script", () => {
}
});
it("retries transient GitHub API failures within the request timeout", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(new Response("unicorn", { status: 503, statusText: "Unavailable" }))
.mockResolvedValueOnce(Response.json({ ok: true }));
await expect(
githubApi("token", { fetchImpl, retryDelaysMs: [0] }).request(
"/repos/openclaw/openclaw/pulls/1/files",
),
).resolves.toEqual({ ok: true });
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("does not retry non-idempotent GitHub API requests", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("unicorn", { status: 503, statusText: "Unavailable" }));
await expect(
githubApi("token", { fetchImpl, retryDelaysMs: [0] }).request(
"/repos/openclaw/openclaw/issues/1/comments",
{ method: "POST", body: "{}" },
),
).rejects.toMatchObject({ status: 503 });
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("bounds successful GitHub API response bodies", async () => {
const request = githubApi("token", {
responseMaxBodyBytes: 64,
+4
View File
@@ -17,6 +17,7 @@ const CHECK_COMMANDS = [
"pnpm plugin-sdk:check-exports",
"pnpm plugin-sdk:api:check",
"pnpm plugin-sdk:surface:check",
"pnpm ui:i18n:check",
];
const FIX_COMMANDS = [
"node --import tsx scripts/sync-plugin-versions.ts",
@@ -27,6 +28,7 @@ const FIX_COMMANDS = [
"pnpm config:docs:gen",
"pnpm plugin-sdk:sync-exports",
"pnpm plugin-sdk:api:gen",
"pnpm ui:i18n:sync",
];
const tempDirs = new Set<string>();
@@ -208,9 +210,11 @@ describe("scripts/release-preflight.mjs", () => {
"node --import tsx scripts/sync-plugin-versions.ts",
"node scripts/generate-npm-shrinkwrap.mjs --changed",
"node scripts/generate-plugin-inventory-doc.mjs --write",
"pnpm ui:i18n:sync",
"node --import tsx scripts/sync-plugin-versions.ts --check",
"node scripts/generate-npm-shrinkwrap.mjs --all --check",
"node scripts/generate-plugin-inventory-doc.mjs --check",
"pnpm ui:i18n:check",
].toSorted(),
);
expect(result.stdout).toContain("(version, jobs=4)");
+1 -5
View File
@@ -316,10 +316,6 @@ describe("scripts/test-projects changed-target routing", () => {
mode: "targets",
targets: ["test/scripts/control-ui-i18n.test.ts", "src/scripts/control-ui-i18n.test.ts"],
});
expect(resolveChangedTestTargetPlan(["scripts/control-ui-i18n-resolve-conflicts.ts"])).toEqual({
mode: "targets",
targets: ["test/scripts/control-ui-i18n.test.ts"],
});
});
it("routes top-level scripts through conventional owner tests", () => {
@@ -1436,7 +1432,7 @@ describe("scripts/test-projects changed-target routing", () => {
it("keeps CI, dependency, and docs tooling edits on owner tests", () => {
expect(resolveChangedTestTargetPlan(["scripts/ci-changed-scope.mjs"])).toEqual({
mode: "targets",
targets: ["src/scripts/ci-changed-scope.test.ts"],
targets: ["src/scripts/ci-changed-scope.test.ts", "test/scripts/control-ui-i18n.test.ts"],
});
expect(resolveChangedTestTargetPlan(["scripts/check-dependency-pins.mjs"])).toEqual({
+5 -5
View File
@@ -10,12 +10,12 @@ This directory owns Control UI-specific guidance that should not live in the rep
- `scripts/control-ui-i18n.ts`
- `ui/src/i18n/lib/types.ts`
- `ui/src/i18n/lib/registry.ts`
- Contributor flow: update English strings and locale wiring, run keyless `pnpm ui:i18n:baseline`, and commit `en.ts` plus changed baseline files. The command records intentional runtime fallbacks without rewriting foreign-language bundles.
- Rebase/merge conflicts: resolve and stage source conflicts first, then run `pnpm ui:i18n:resolve-conflicts`. It combines translation-memory entries from both sides, regenerates every derived artifact, runs the strict check, and stages the generated result. Provider auth is required when the resolved keys are not already cached. Never hand-merge generated locale files.
- `pnpm ui:i18n:verify` is deterministic and keyless. `pnpm lint` and the changed-check UI lane run it. It validates catalog shape, English-key coverage or explicit fallback listing, orphan keys, placeholder parity, canonical ordering, runtime locale wiring, and raw-copy baseline drift.
- Translation flow: the `control-ui-locale-refresh` workflow translates after merge and opens a generated PR. `pnpm ui:i18n:sync` remains the authenticated maintainer path; do not run it without provider auth when new keys exist. `pnpm ui:i18n:check` is the strict generated-output/release gate.
- Contributor flow: update English strings and locale wiring, run keyless `pnpm ui:i18n:baseline`, and commit source files plus any changed raw-copy baseline. Do not include foreign bundles, catalog fallback metadata, locale metadata, or translation memory in a source PR; CI rejects mixed source/generated diffs outside canonical `release/YYYY.M.PATCH` branches.
- `pnpm ui:i18n:verify` is deterministic and keyless. `pnpm lint` and the changed-check UI lane run it. It validates English catalog shape, runtime locale wiring, and raw-copy baseline drift; foreign catalog parity belongs to the post-merge bot and strict generated-output gate.
- Translation flow: the serialized `control-ui-locale-refresh` workflow translates after merge, opens an isolated generated PR, and enables auto-merge for its exact head. `pnpm ui:i18n:sync` remains the authenticated maintainer/release repair path; do not run it without provider auth when new keys exist.
- `pnpm release:prep` runs the locale sync before release freeze, then `pnpm ui:i18n:check` remains the strict generated-output/release gate with zero fallbacks.
- Prioritization report: `pnpm ui:i18n:report [--surface <name>] [--locale <locale>] [--top <n>]` shows current hardcoded-copy focus areas and locale fallback metadata. It is not a drift gate; use `pnpm ui:i18n:check` for that.
- If locale outputs drift, regenerate them. Do not manually translate or hand-maintain generated locale files by default.
- If locale outputs drift, let the workflow reconcile them or run release prep. Do not manually translate, merge, or hand-maintain generated locale files.
## Scope