mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
improve(i18n): generate native locales after merge (#111557)
* ci(i18n): move native locale generation post-merge * fix(i18n): allow generated Android companions
This commit is contained in:
@@ -104,6 +104,7 @@ jobs:
|
||||
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 }}
|
||||
strict_native_i18n: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.strict_native_i18n }}
|
||||
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
|
||||
checks_windows_matrix: ${{ steps.manifest.outputs.checks_windows_matrix }}
|
||||
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
|
||||
@@ -1275,14 +1276,25 @@ jobs:
|
||||
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
||||
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
||||
|
||||
- name: Check native app i18n inventory
|
||||
run: pnpm native:i18n:check
|
||||
- name: Verify native app i18n source
|
||||
run: |
|
||||
if node -e 'const scripts = require("./package.json").scripts ?? {}; process.exit(scripts["native:i18n:verify"] ? 0 : 1)'; then
|
||||
pnpm native:i18n:verify
|
||||
else
|
||||
# Historical release targets predate the source/generated split.
|
||||
pnpm native:i18n:check
|
||||
pnpm android:i18n:check
|
||||
pnpm apple:i18n:check
|
||||
fi
|
||||
|
||||
- name: Check Android app i18n resources
|
||||
run: pnpm android:i18n:check
|
||||
|
||||
- name: Check Apple app i18n catalogs
|
||||
run: pnpm apple:i18n:check
|
||||
- name: Check native app generated locale parity
|
||||
if: ${{ needs.preflight.outputs.strict_native_i18n == 'true' }}
|
||||
run: |
|
||||
if node -e 'const scripts = require("./package.json").scripts ?? {}; process.exit(scripts["native:i18n:verify"] ? 0 : 1)'; then
|
||||
pnpm native:i18n:check
|
||||
else
|
||||
echo "Historical target was validated by the legacy native checks."
|
||||
fi
|
||||
|
||||
checks-ui:
|
||||
permissions:
|
||||
|
||||
@@ -72,12 +72,33 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Create generated PR tokens
|
||||
id: tokens
|
||||
uses: ./.github/actions/create-generated-pr-tokens
|
||||
with:
|
||||
contents-client-id: Iv23liOECG0slfuhz093
|
||||
contents-private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
|
||||
pull-request-client-id: Iv23liPJCozR0uHm6P7G
|
||||
pull-request-private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
|
||||
pull-request-contents-permission: write
|
||||
|
||||
- name: Verify repository auto-merge setting
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.tokens.outputs.pull-request-token }}
|
||||
REPOSITORY_NAME: ${{ github.event.repository.name }}
|
||||
REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
auto_merge_allowed="$(
|
||||
gh api graphql \
|
||||
-f query='query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { autoMergeAllowed } }' \
|
||||
-f owner="${REPOSITORY_OWNER}" \
|
||||
-f name="${REPOSITORY_NAME}" \
|
||||
--jq '.data.repository.autoMergeAllowed'
|
||||
)"
|
||||
if [[ "${auto_merge_allowed}" != "true" ]]; then
|
||||
echo "Repository auto-merge must be enabled before generated locale publication." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
refresh:
|
||||
needs: [resolve-base, publisher-preflight]
|
||||
@@ -261,22 +282,13 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
# Every worker observes the same source inventory. Generate it once here
|
||||
# so locale patches remain independent and can be applied sequentially.
|
||||
- name: Refresh shared native inventory
|
||||
# Source PRs own the stable-ID inventory. Locale workers only contribute
|
||||
# translations; derive every platform artifact once after combining them.
|
||||
- name: Refresh native generated artifacts
|
||||
run: node --import tsx scripts/native-app-i18n.ts sync --write
|
||||
|
||||
- name: Refresh Android native resources
|
||||
run: node --import tsx scripts/android-app-i18n.ts sync
|
||||
|
||||
- name: Refresh Apple native resources
|
||||
run: node --import tsx scripts/apple-app-i18n.ts sync-ios --write
|
||||
|
||||
- name: Validate native locale refresh
|
||||
run: |
|
||||
node --import tsx scripts/native-app-i18n.ts check
|
||||
node --import tsx scripts/android-app-i18n.ts check
|
||||
node --import tsx scripts/apple-app-i18n.ts check
|
||||
run: node --import tsx scripts/native-app-i18n.ts check
|
||||
|
||||
- name: Open or update generated locale PR
|
||||
uses: ./.github/actions/publish-generated-pr
|
||||
@@ -289,9 +301,9 @@ jobs:
|
||||
head-branch: automation/native-app-locale-refresh
|
||||
commit-message: "chore(i18n): refresh native locales"
|
||||
pr-title: "chore(i18n): refresh native locales"
|
||||
auto-merge: "true"
|
||||
generated-paths: |
|
||||
apps/.i18n/native
|
||||
apps/.i18n/native-source.json
|
||||
apps/.i18n/apple-translation-contradictions.json
|
||||
apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt
|
||||
apps/android/app/src/main/res/values*/assistant.xml
|
||||
@@ -302,6 +314,7 @@ jobs:
|
||||
apps/ios/ShareExtension/*.lproj/InfoPlist.strings
|
||||
apps/ios/ActivityWidget/*.lproj/InfoPlist.strings
|
||||
invalidation-paths: |
|
||||
apps/.i18n/native-source.json
|
||||
apps/android/app/src/main
|
||||
apps/ios
|
||||
apps/macos/Sources
|
||||
@@ -332,5 +345,3 @@ jobs:
|
||||
|
||||
- [Locale refresh run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
- `node --import tsx scripts/native-app-i18n.ts check`
|
||||
- `node --import tsx scripts/android-app-i18n.ts check`
|
||||
- `node --import tsx scripts/apple-app-i18n.ts check`
|
||||
|
||||
+6
-3
@@ -42,7 +42,7 @@ dispatch.
|
||||
| `check-additional-*` | Boundary check stripes (including prompt snapshot drift), session accessor/transcript reader/SQLite transaction boundaries, extension lint groups, package boundary compile/canary, and runtime topology architecture | Node-relevant changes |
|
||||
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases |
|
||||
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed (PRs and manual dispatch) |
|
||||
| `native-i18n` | Native app, Android, and Apple i18n inventory checks | Native i18n-relevant changes |
|
||||
| `native-i18n` | Verify native source extraction and localization safety on source PRs; enforce full translated/platform-generated parity on generated PRs and manual CI | Native i18n-relevant changes |
|
||||
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
|
||||
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
|
||||
| `macos-node` | Focused macOS TypeScript tests: launchd, Homebrew, runtime paths, packaging scripts, process-group wrapper | macOS-relevant changes |
|
||||
@@ -59,7 +59,7 @@ Standalone Periphery workflows enforce zero dead-code findings for the iOS and m
|
||||
|
||||
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs. Canonical `main` starts immediately, but its concurrency group admits only one complete run and coalesces later pushes into one newest pending run. Node-relevant main pushes also serialize the sole dependency-disk writer and its size maintenance here before downstream jobs may mount the key; Blacksmith may expose a fresh commit only to a later workflow run, so same-run consumers retain the marker-checked local fallback.
|
||||
2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
|
||||
3. `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.
|
||||
3. `build-artifacts` and the locale checks overlap with the fast Linux lanes. Control UI and native app source PRs exclude generated locale snapshots/resources; their serialized refresh workflows repair and auto-merge isolated generated PRs in the background. Source CI still blocks stale source inventories and unsafe localization calls. Generated PRs, manual CI, and release prep enforce full translated/platform-generated parity. Canonical `release/YYYY.M.PATCH` branches may include release-prep locale repairs with the other generated release output.
|
||||
4. 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`.
|
||||
5. `openclaw/ci-gate` waits for every selected lane. 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.
|
||||
|
||||
@@ -154,7 +154,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. 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.
|
||||
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/native app i18n. Automatic source PRs verify native extraction inventory and Android/Apple localization safety without requiring translated or platform-generated output in the same PR. The serialized Native App Locale Refresh workflow rebuilds those artifacts in one isolated PR and enables exact-head auto-merge after required checks pass. Full native parity remains blocking for generated-artifact PRs, manual CI, Full Release Validation, and release prep. Control UI locale parity remains advisory on automatic PR and `main` runs and blocking on manual/release CI. 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.
|
||||
|
||||
@@ -223,6 +223,9 @@ pnpm test # vitest tests
|
||||
pnpm test:changed # cheap smart changed Vitest targets
|
||||
pnpm test:ui # Control UI unit/browser suite
|
||||
pnpm ui:i18n:check # generated Control UI locale parity (release gate)
|
||||
pnpm native:i18n:baseline # update source-owned native extraction inventory
|
||||
pnpm native:i18n:verify # source inventory + Android/Apple localization safety
|
||||
pnpm native:i18n:check # strict translated/platform-generated parity (release gate)
|
||||
pnpm test:channels
|
||||
pnpm test:contracts:channels
|
||||
pnpm check:docs # docs format + lint + broken links
|
||||
|
||||
@@ -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, the Plugin SDK API contract manifest, 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.
|
||||
- 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, the Plugin SDK API contract manifest, and Control UI locale bundles. It also blocks until native app translations and platform-generated locale resources match the source inventory; if they lag, wait for or dispatch `Native App Locale Refresh` before freezing the Code SHA. `pnpm release:check` re-runs those guards in check mode (including the strict locale gates 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.
|
||||
|
||||
|
||||
@@ -1414,8 +1414,10 @@
|
||||
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.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:baseline": "node --import tsx scripts/native-app-i18n.ts baseline --write",
|
||||
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
|
||||
"native:i18n:sync": "node --import tsx scripts/native-app-i18n.ts sync --write",
|
||||
"native:i18n:verify": "node --import tsx scripts/native-app-i18n.ts verify",
|
||||
"android:i18n:check": "node --import tsx scripts/android-app-i18n.ts check",
|
||||
"apple:i18n:check": "node --import tsx scripts/apple-app-i18n.ts check",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
|
||||
+25
-13
@@ -1273,13 +1273,35 @@ export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function checkAndroidAppI18n() {
|
||||
const [sourceFiles, localeStrings, referenceSource] = await Promise.all([
|
||||
export async function verifyAndroidAppI18n() {
|
||||
const [sourceFiles, base, referenceSource] = await Promise.all([
|
||||
readAndroidSource(),
|
||||
Promise.all(LOCALES.map(readStrings)),
|
||||
readStrings("values"),
|
||||
readAndroidResourceReferences(),
|
||||
]);
|
||||
const baseKeys = new Set(base.keys());
|
||||
const problems: Array<readonly [string, string[]]> = [
|
||||
["English syntax", findInvalidResourceSyntax(base)],
|
||||
];
|
||||
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
|
||||
problems.push(["English unused", findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource)]);
|
||||
const uiFindings = sourceFiles.flatMap((file) =>
|
||||
findUnlocalizedAndroidUiLiterals(file.source, file.path),
|
||||
);
|
||||
problems.push([
|
||||
"Unlocalized UI literals",
|
||||
uiFindings.map((finding) => `${finding.path}:${finding.line}:${finding.source}`),
|
||||
]);
|
||||
if (problems.some(([, keys]) => keys.length)) {
|
||||
throw new Error(formatProblems(problems));
|
||||
}
|
||||
process.stdout.write(`android-app-i18n: sourceKeys=${baseKeys.size}\n`);
|
||||
}
|
||||
|
||||
export async function checkAndroidAppI18n() {
|
||||
await verifyAndroidAppI18n();
|
||||
await syncAndroidAppI18n({ check: true });
|
||||
const localeStrings = await Promise.all(LOCALES.map(readStrings));
|
||||
const base = expectDefined(localeStrings[0], "English Android string resources");
|
||||
const translations = localeStrings.slice(1);
|
||||
const baseKeys = new Set(base.keys());
|
||||
@@ -1306,16 +1328,6 @@ export async function checkAndroidAppI18n() {
|
||||
[`${locale} syntax`, findInvalidResourceSyntax(strings)],
|
||||
] as const;
|
||||
});
|
||||
problems.push(["English syntax", findInvalidResourceSyntax(base)]);
|
||||
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
|
||||
problems.push(["English unused", findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource)]);
|
||||
const uiFindings = sourceFiles.flatMap((file) =>
|
||||
findUnlocalizedAndroidUiLiterals(file.source, file.path),
|
||||
);
|
||||
problems.push([
|
||||
"Unlocalized UI literals",
|
||||
uiFindings.map((finding) => `${finding.path}:${finding.line}:${finding.source}`),
|
||||
]);
|
||||
if (problems.some(([, keys]) => keys.length)) {
|
||||
throw new Error(formatProblems(problems));
|
||||
}
|
||||
|
||||
@@ -873,7 +873,7 @@ export async function syncAppleAppI18n(): Promise<{
|
||||
return { build, infoPlistFiles };
|
||||
}
|
||||
|
||||
export async function checkAppleAppI18n() {
|
||||
export async function verifyAppleAppI18n() {
|
||||
await validateRuntimeInterpolationPaths();
|
||||
for (const [sourcePath, contracts] of Object.entries(LOCALIZED_WRAPPER_CONTRACTS)) {
|
||||
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
|
||||
@@ -894,10 +894,6 @@ export async function checkAppleAppI18n() {
|
||||
}
|
||||
}
|
||||
|
||||
const iosBuild = await syncIosCatalog(false);
|
||||
const iosKeys = validateCatalog(IOS_CATALOG_PATH, iosBuild.catalog);
|
||||
const infoPlistFiles = await syncIosInfoPlist(false);
|
||||
|
||||
const macosCatalog = JSON.parse(
|
||||
await readFile(path.join(ROOT, MACOS_CATALOG.path), "utf8"),
|
||||
) as Catalog;
|
||||
@@ -926,10 +922,18 @@ export async function checkAppleAppI18n() {
|
||||
}
|
||||
const macosKeys = validateCatalog(MACOS_CATALOG.path, macosCatalog);
|
||||
|
||||
process.stdout.write(`apple-app-i18n: sourceMacosKeys=${macosKeys}\n`);
|
||||
}
|
||||
|
||||
export async function checkAppleAppI18n() {
|
||||
await verifyAppleAppI18n();
|
||||
const iosBuild = await syncIosCatalog(false);
|
||||
const iosKeys = validateCatalog(IOS_CATALOG_PATH, iosBuild.catalog);
|
||||
const infoPlistFiles = await syncIosInfoPlist(false);
|
||||
|
||||
process.stdout.write(
|
||||
[
|
||||
`apple-app-i18n: iosKeys=${iosKeys}`,
|
||||
`macosKeys=${macosKeys}`,
|
||||
`infoPlistFiles=${infoPlistFiles}`,
|
||||
`translationContradictions=${iosBuild.contradictions.length}`,
|
||||
`locales=${APPLE_I18N_LOCALES.join(",")}`,
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ChangedScopeArgs = {
|
||||
};
|
||||
|
||||
export class ControlUiGeneratedArtifactsMixedError extends Error {}
|
||||
export class NativeGeneratedArtifactsMixedError extends Error {}
|
||||
|
||||
export function assertControlUiGeneratedArtifactsIsolated(
|
||||
changedPaths: string[],
|
||||
@@ -38,6 +39,11 @@ export function resolveAllowedGeneratedMixBranch(
|
||||
branchName?: string,
|
||||
): string;
|
||||
export function shouldStrictControlUiI18n(changedPaths: string[] | null): boolean;
|
||||
export function assertNativeGeneratedArtifactsIsolated(
|
||||
changedPaths: string[],
|
||||
branchName?: string,
|
||||
): void;
|
||||
export function shouldStrictNativeI18n(changedPaths: string[] | null): boolean;
|
||||
export function detectChangedScope(changedPaths: string[]): ChangedScope;
|
||||
export function shouldRunNativeI18n(changedPaths: string[]): boolean;
|
||||
export function detectNodeFastScope(changedPaths: string[]): NodeFastScope;
|
||||
|
||||
@@ -61,10 +61,18 @@ const CONTROL_UI_HARD_GENERATED_I18N_RE =
|
||||
const RELEASE_BRANCH_RE = /^release\/\d{4}\.\d+\.\d+$/;
|
||||
|
||||
export class ControlUiGeneratedArtifactsMixedError extends Error {}
|
||||
export class NativeGeneratedArtifactsMixedError 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 =
|
||||
/^(?:apps\/\.i18n\/|apps\/android\/app\/src\/main\/|apps\/ios\/|apps\/macos\/Sources\/|apps\/shared\/OpenClawKit\/Sources\/|scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.ts$|test\/scripts\/(?:android-app-i18n|apple-app-i18n|native-app-i18n)\.test\.ts$|\.github\/workflows\/(?:ci|native-app-locale-refresh)\.yml$)/;
|
||||
// Android base resources are co-owned: source PRs edit their English content,
|
||||
// while the generator rewrites managed sections. Treat them as generated only
|
||||
// alongside a hard-generated artifact so neither ownership path blocks the other.
|
||||
const NATIVE_COOWNED_GENERATED_I18N_RE =
|
||||
/^apps\/android\/app\/src\/main\/res\/values\/(?:assistant|strings)\.xml$/;
|
||||
const NATIVE_HARD_GENERATED_I18N_RE =
|
||||
/^(?:apps\/\.i18n\/native\/[^/]+\.json|apps\/\.i18n\/apple-translation-contradictions\.json|apps\/android\/app\/src\/main\/java\/ai\/openclaw\/app\/i18n\/NativeStringResources\.kt|apps\/android\/app\/src\/main\/res\/values-[^/]+\/(?:assistant|strings)\.xml|apps\/ios\/Resources\/Localizable\.xcstrings|apps\/ios\/(?:Sources|WatchApp|ShareExtension|ActivityWidget)\/[^/]+\.lproj\/InfoPlist\.strings)$/;
|
||||
const FAST_INSTALL_SMOKE_SCOPE_RE =
|
||||
/^(Dockerfile$|\.npmrc$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|scripts\/ci-changed-scope\.mjs$|scripts\/postinstall-bundled-plugins\.mjs$|scripts\/e2e\/(?:Dockerfile(?:\.qr-import)?|agents-delete-shared-workspace-docker\.sh|gateway-network-docker\.sh)$|extensions\/[^/]+\/(?:package\.json|openclaw\.plugin\.json)$|\.github\/workflows\/install-smoke\.yml$|\.github\/actions\/setup-node-env\/action\.yml$)/;
|
||||
const FULL_INSTALL_SMOKE_SCOPE_RE =
|
||||
@@ -224,6 +232,50 @@ export function shouldStrictControlUiI18n(changedPaths) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Native translations and platform resources are committed by one serialized
|
||||
* automation PR. Source PRs own only source plus the stable-ID inventory.
|
||||
* @param {string[]} changedPaths
|
||||
*/
|
||||
export function assertNativeGeneratedArtifactsIsolated(changedPaths, branchName = "") {
|
||||
if (branchName === "main" || RELEASE_BRANCH_RE.test(branchName)) {
|
||||
return;
|
||||
}
|
||||
const generatedPaths = changedPaths.filter((filePath) =>
|
||||
NATIVE_HARD_GENERATED_I18N_RE.test(filePath),
|
||||
);
|
||||
if (generatedPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
const generatedCompanionPaths = changedPaths.filter((filePath) =>
|
||||
NATIVE_COOWNED_GENERATED_I18N_RE.test(filePath),
|
||||
);
|
||||
const sourcePaths = changedPaths.filter(
|
||||
(filePath) =>
|
||||
!NATIVE_HARD_GENERATED_I18N_RE.test(filePath) &&
|
||||
!NATIVE_COOWNED_GENERATED_I18N_RE.test(filePath),
|
||||
);
|
||||
if (sourcePaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new NativeGeneratedArtifactsMixedError(
|
||||
[
|
||||
"Native generated locale artifacts must be isolated from source changes.",
|
||||
"Commit native source changes and apps/.i18n/native-source.json only; the native locale refresh workflow owns translated and platform-generated artifacts.",
|
||||
...generatedPaths.map((filePath) => `- generated: ${filePath}`),
|
||||
...generatedCompanionPaths.map((filePath) => `- generated companion: ${filePath}`),
|
||||
...sourcePaths.map((filePath) => `- source: ${filePath}`),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldStrictNativeI18n(changedPaths) {
|
||||
return (
|
||||
changedPaths === null ||
|
||||
changedPaths.some((filePath) => NATIVE_HARD_GENERATED_I18N_RE.test(filePath))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveChangedBranchName() {
|
||||
const githubBranch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
|
||||
if (githubBranch) {
|
||||
@@ -435,6 +487,11 @@ export function writeGitHubOutput(
|
||||
);
|
||||
appendFileSync(outputPath, `run_ui_tests=${scope.runUiTests}\n`, "utf8");
|
||||
appendFileSync(outputPath, `run_native_i18n=${runNativeI18n}\n`, "utf8");
|
||||
appendFileSync(
|
||||
outputPath,
|
||||
`strict_native_i18n=${shouldStrictNativeI18n(changedPaths)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const changedPathsJson = JSON.stringify(changedPaths);
|
||||
appendFileSync(
|
||||
outputPath,
|
||||
@@ -490,7 +547,9 @@ if (isDirectRun()) {
|
||||
writeGitHubOutput(EMPTY_SCOPE, process.env.GITHUB_OUTPUT, undefined, undefined, false, []);
|
||||
process.exit(0);
|
||||
}
|
||||
assertControlUiGeneratedArtifactsIsolated(changedPaths, resolveAllowedGeneratedMixBranch());
|
||||
const allowedGeneratedMixBranch = resolveAllowedGeneratedMixBranch();
|
||||
assertControlUiGeneratedArtifactsIsolated(changedPaths, allowedGeneratedMixBranch);
|
||||
assertNativeGeneratedArtifactsIsolated(changedPaths, allowedGeneratedMixBranch);
|
||||
writeGitHubOutput(
|
||||
detectChangedScope(changedPaths),
|
||||
process.env.GITHUB_OUTPUT,
|
||||
@@ -500,7 +559,10 @@ if (isDirectRun()) {
|
||||
changedPaths,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ControlUiGeneratedArtifactsMixedError) {
|
||||
if (
|
||||
error instanceof ControlUiGeneratedArtifactsMixedError ||
|
||||
error instanceof NativeGeneratedArtifactsMixedError
|
||||
) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
|
||||
+38
-19
@@ -47,7 +47,7 @@ type NativeLocaleSyncOptions = {
|
||||
translationsDir?: string;
|
||||
};
|
||||
type NativeI18nCommand = {
|
||||
command: "check" | "sync";
|
||||
command: "baseline" | "check" | "sync" | "verify";
|
||||
locale?: string;
|
||||
write: boolean;
|
||||
};
|
||||
@@ -1229,14 +1229,15 @@ function render(entries: NativeI18nEntry[]): string {
|
||||
}
|
||||
|
||||
async function syncNativeI18n(options: {
|
||||
checkOnly: boolean;
|
||||
checkInventory: boolean;
|
||||
checkLocales: boolean;
|
||||
write: boolean;
|
||||
}): Promise<NativeI18nEntry[]> {
|
||||
const currentInventory = await readNativeI18nInventory();
|
||||
const entries = await collectNativeI18nEntries(currentInventory.entries);
|
||||
const expected = render(entries);
|
||||
const current = currentInventory.raw;
|
||||
if (options.checkOnly) {
|
||||
if (options.checkLocales) {
|
||||
const findings = await checkNativeLocaleArtifacts(currentInventory.entries);
|
||||
for (const finding of findings) {
|
||||
process.stdout.write(`native-app-i18n: advisory=${JSON.stringify(finding)}\n`);
|
||||
@@ -1244,11 +1245,11 @@ async function syncNativeI18n(options: {
|
||||
process.stdout.write(
|
||||
`native-app-i18n: locale-artifacts=${NATIVE_I18N_LOCALES.length} advisories=${findings.length}\n`,
|
||||
);
|
||||
if (current !== expected) {
|
||||
throw new Error(
|
||||
"native app i18n inventory drift detected. Run `pnpm native:i18n:sync` and commit apps/.i18n/native-source.json.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (options.checkInventory && current !== expected) {
|
||||
throw new Error(
|
||||
"native app i18n inventory drift detected. Run `pnpm native:i18n:baseline` and commit apps/.i18n/native-source.json.",
|
||||
);
|
||||
}
|
||||
if (current !== expected && options.write) {
|
||||
await mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
|
||||
@@ -1569,9 +1570,9 @@ export async function syncNativeLocale(
|
||||
|
||||
export function parseNativeI18nCommand(argv: string[]): NativeI18nCommand {
|
||||
const [command, ...args] = argv;
|
||||
if (command !== "check" && command !== "sync") {
|
||||
if (command !== "baseline" && command !== "check" && command !== "sync" && command !== "verify") {
|
||||
throw new Error(
|
||||
"usage: node --import tsx scripts/native-app-i18n.ts check|sync [--write] [--locale <code>]",
|
||||
"usage: node --import tsx scripts/native-app-i18n.ts baseline --write|check|sync [--write] [--locale <code>]|verify",
|
||||
);
|
||||
}
|
||||
let locale: string | undefined;
|
||||
@@ -1606,8 +1607,11 @@ export function parseNativeI18nCommand(argv: string[]): NativeI18nCommand {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (command === "check" && write) {
|
||||
throw new Error("native i18n check does not accept `--write`");
|
||||
if ((command === "check" || command === "verify") && write) {
|
||||
throw new Error(`native i18n ${command} does not accept \`--write\``);
|
||||
}
|
||||
if (command === "baseline" && !write) {
|
||||
throw new Error("native i18n baseline requires `--write`");
|
||||
}
|
||||
return { command, locale, write };
|
||||
}
|
||||
@@ -1615,18 +1619,33 @@ export function parseNativeI18nCommand(argv: string[]): NativeI18nCommand {
|
||||
async function main() {
|
||||
const parsed = parseNativeI18nCommand(process.argv.slice(2));
|
||||
const entries = await syncNativeI18n({
|
||||
checkOnly: parsed.command === "check",
|
||||
write: parsed.command === "sync" && parsed.write,
|
||||
checkInventory:
|
||||
parsed.command === "check" || parsed.command === "verify" || parsed.locale !== undefined,
|
||||
checkLocales: parsed.command === "check",
|
||||
write:
|
||||
(parsed.command === "baseline" || parsed.command === "sync") &&
|
||||
parsed.write &&
|
||||
parsed.locale === undefined,
|
||||
});
|
||||
if (parsed.locale) {
|
||||
await syncNativeLocale(parsed.locale, entries);
|
||||
}
|
||||
if (parsed.command === "sync" && parsed.write) {
|
||||
if (parsed.command === "verify" || parsed.command === "check") {
|
||||
const android = await import("./android-app-i18n.ts");
|
||||
const apple = await import("./apple-app-i18n.ts");
|
||||
if (parsed.command === "verify") {
|
||||
await android.verifyAndroidAppI18n();
|
||||
await apple.verifyAppleAppI18n();
|
||||
} else {
|
||||
await android.checkAndroidAppI18n();
|
||||
await apple.checkAppleAppI18n();
|
||||
}
|
||||
}
|
||||
if (parsed.command === "sync" && parsed.write && !parsed.locale) {
|
||||
// The inventory and native/*.json feed the generated Android/Apple app
|
||||
// artifacts. Regenerate them in the same write; a sync that stops at the
|
||||
// inventory lands stale derived catalogs and turns the repo-wide
|
||||
// android/apple i18n checks red. Lazy imports keep check/locale-only runs
|
||||
// from loading the derived generators.
|
||||
// artifacts. Regenerate them once after a full sync; per-locale workers
|
||||
// only update their independent translation artifact so their patches can
|
||||
// be combined deterministically by the serialized finalizer.
|
||||
const [{ syncAndroidAppI18n }, { syncAppleAppI18n }] = await Promise.all([
|
||||
import("./android-app-i18n.ts"),
|
||||
import("./apple-app-i18n.ts"),
|
||||
|
||||
@@ -91,6 +91,12 @@ const releaseTasks = [
|
||||
fix: pnpmCommand("ui:i18n:sync"),
|
||||
check: pnpmCommand("ui:i18n:check"),
|
||||
},
|
||||
{
|
||||
id: "native-app-i18n",
|
||||
name: "native app generated locale artifacts",
|
||||
scopes: ["version"],
|
||||
check: pnpmCommand("native:i18n:check"),
|
||||
},
|
||||
];
|
||||
const selectedTasks = releaseTasks.filter((task) => taskMatchesScopes(task, parsedArgs.scopes));
|
||||
const shouldCheckMacosVersions = parsedArgs.scopes.has("all") || parsedArgs.scopes.has("version");
|
||||
@@ -148,7 +154,7 @@ if (macosVersionErrors.length !== 0 || checkFailures.length !== 0) {
|
||||
}
|
||||
printCommandFailures(checkFailures);
|
||||
console.error(
|
||||
"\nCorrect manual version metadata first. Run `pnpm release:prep` for intentional generated version/config/API changes, then commit the resulting files.",
|
||||
"\nCorrect manual version metadata first. Run `pnpm release:prep` for intentional generated version/config/API changes, then commit the resulting files. If native locale artifacts lag, wait for or dispatch Native App Locale Refresh before freezing the release SHA.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { assertNativeGeneratedArtifactsIsolated, shouldStrictNativeI18n } =
|
||||
await import("../../scripts/ci-changed-scope.mjs");
|
||||
|
||||
describe("native i18n changed scope", () => {
|
||||
it("keeps generated artifacts in isolated automation PRs", () => {
|
||||
const generatedCompanionPaths = [
|
||||
"apps/android/app/src/main/res/values/strings.xml",
|
||||
"apps/android/app/src/main/res/values/assistant.xml",
|
||||
];
|
||||
const generatedPaths = [
|
||||
"apps/.i18n/native/sv.json",
|
||||
"apps/.i18n/apple-translation-contradictions.json",
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt",
|
||||
"apps/android/app/src/main/res/values-sv/strings.xml",
|
||||
"apps/ios/Resources/Localizable.xcstrings",
|
||||
"apps/ios/WatchApp/sv.lproj/InfoPlist.strings",
|
||||
];
|
||||
|
||||
expect(() => assertNativeGeneratedArtifactsIsolated(generatedPaths)).not.toThrow();
|
||||
expect(() =>
|
||||
assertNativeGeneratedArtifactsIsolated([...generatedPaths, ...generatedCompanionPaths]),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertNativeGeneratedArtifactsIsolated([...generatedPaths, "apps/.i18n/native-source.json"]),
|
||||
).toThrow("Native generated locale artifacts must be isolated from source changes");
|
||||
expect(() =>
|
||||
assertNativeGeneratedArtifactsIsolated(
|
||||
[...generatedPaths, "apps/ios/Sources/RootTabs.swift"],
|
||||
"main",
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertNativeGeneratedArtifactsIsolated([
|
||||
...generatedCompanionPaths,
|
||||
"apps/.i18n/native-source.json",
|
||||
]),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertNativeGeneratedArtifactsIsolated([
|
||||
...generatedPaths,
|
||||
...generatedCompanionPaths,
|
||||
"apps/.i18n/native-source.json",
|
||||
]),
|
||||
).toThrow("Native generated locale artifacts must be isolated from source changes");
|
||||
expect(() =>
|
||||
assertNativeGeneratedArtifactsIsolated([
|
||||
...generatedPaths,
|
||||
...generatedCompanionPaths,
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt",
|
||||
]),
|
||||
).toThrow("Native generated locale artifacts must be isolated from source changes");
|
||||
});
|
||||
|
||||
it("runs strict parity only for manual or generated-artifact checks", () => {
|
||||
expect(shouldStrictNativeI18n(null)).toBe(true);
|
||||
expect(shouldStrictNativeI18n(["apps/.i18n/native/sv.json"])).toBe(true);
|
||||
expect(shouldStrictNativeI18n(["apps/ios/Resources/Localizable.xcstrings"])).toBe(true);
|
||||
expect(
|
||||
shouldStrictNativeI18n(["apps/ios/Sources/RootTabs.swift", "apps/.i18n/native-source.json"]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1049,6 +1049,7 @@ describe("detectChangedScope", () => {
|
||||
strict_control_ui_i18n: "false",
|
||||
run_ui_tests: "false",
|
||||
run_native_i18n: "false",
|
||||
strict_native_i18n: "false",
|
||||
changed_paths_json: "[]",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -925,14 +925,8 @@ describe("ci workflow guards", () => {
|
||||
const nativeArtifactStep = refresh.steps.find(
|
||||
(step: { name?: string }) => step.name === "Prepare locale artifact",
|
||||
);
|
||||
const nativeInventoryStep = nativeFinalize.steps.find(
|
||||
(step: { name?: string }) => step.name === "Refresh shared native inventory",
|
||||
);
|
||||
const nativeAndroidStep = nativeFinalize.steps.find(
|
||||
(step: { name?: string }) => step.name === "Refresh Android native resources",
|
||||
);
|
||||
const nativeAppleStep = nativeFinalize.steps.find(
|
||||
(step: { name?: string }) => step.name === "Refresh Apple native resources",
|
||||
const nativeGeneratedStep = nativeFinalize.steps.find(
|
||||
(step: { name?: string }) => step.name === "Refresh native generated artifacts",
|
||||
);
|
||||
const nativeValidationStep = nativeFinalize.steps.find(
|
||||
(step: { name?: string }) => step.name === "Validate native locale refresh",
|
||||
@@ -1000,23 +994,18 @@ describe("ci workflow guards", () => {
|
||||
expect(refreshStep.env.OPENAI_API_KEY).toBe("${{ secrets.OPENAI_API_KEY }}");
|
||||
expect(nativeArtifactStep.run).toContain("git add -A apps/.i18n/native");
|
||||
expect(nativeArtifactStep.run).not.toContain("native-source.json");
|
||||
expect(nativeInventoryStep.run).toBe(
|
||||
expect(nativeGeneratedStep.run).toBe(
|
||||
"node --import tsx scripts/native-app-i18n.ts sync --write",
|
||||
);
|
||||
expect(nativeAndroidStep.run).toBe("node --import tsx scripts/android-app-i18n.ts sync");
|
||||
expect(nativeAppleStep.run).toBe(
|
||||
"node --import tsx scripts/apple-app-i18n.ts sync-ios --write",
|
||||
expect(nativeValidationStep.run).toBe("node --import tsx scripts/native-app-i18n.ts check");
|
||||
expect(nativeFinalize.steps.map((step: { name?: string }) => step.name)).not.toContain(
|
||||
"Refresh Android native resources",
|
||||
);
|
||||
expect(nativeValidationStep.run).toContain(
|
||||
"node --import tsx scripts/native-app-i18n.ts check",
|
||||
expect(nativeFinalize.steps.map((step: { name?: string }) => step.name)).not.toContain(
|
||||
"Refresh Apple native resources",
|
||||
);
|
||||
expect(nativeValidationStep.run).toContain(
|
||||
"node --import tsx scripts/android-app-i18n.ts check",
|
||||
);
|
||||
expect(nativeValidationStep.run).toContain("node --import tsx scripts/apple-app-i18n.ts check");
|
||||
expect(nativePublishStep.with["generated-paths"].trim().split("\n")).toEqual([
|
||||
"apps/.i18n/native",
|
||||
"apps/.i18n/native-source.json",
|
||||
"apps/.i18n/apple-translation-contradictions.json",
|
||||
"apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt",
|
||||
"apps/android/app/src/main/res/values*/assistant.xml",
|
||||
@@ -1029,6 +1018,8 @@ describe("ci workflow guards", () => {
|
||||
]);
|
||||
expect(nativePublishStep.with["invalidation-paths"]).toContain("scripts/android-app-i18n.ts");
|
||||
expect(nativePublishStep.with["invalidation-paths"]).toContain("scripts/apple-app-i18n.ts");
|
||||
expect(nativePublishStep.with["invalidation-paths"]).toContain("apps/.i18n/native-source.json");
|
||||
expect(nativePublishStep.with["auto-merge"]).toBe("true");
|
||||
expect(controlUiRefreshStep.run).toContain("run_refresh anthropic");
|
||||
expect(controlUiRefreshStep.run).toContain("retrying with OpenAI");
|
||||
expect(controlUiRefreshStep.run).toContain("run_openai_refresh");
|
||||
@@ -1106,7 +1097,7 @@ describe("ci workflow guards", () => {
|
||||
expect(preflight.needs).toBe("resolve-base");
|
||||
expect(preflight.if).toBe("needs.resolve-base.result == 'success'");
|
||||
expect(preflight.strategy).toBeUndefined();
|
||||
expect(preflight.steps).toHaveLength(preflight === controlUiPreflight ? 3 : 2);
|
||||
expect(preflight.steps).toHaveLength(3);
|
||||
const checkoutStep = preflight.steps.find(
|
||||
(step: { uses?: string }) => step.uses === CHECKOUT_V6,
|
||||
);
|
||||
@@ -1122,24 +1113,24 @@ describe("ci workflow guards", () => {
|
||||
"contents-client-id": "Iv23liOECG0slfuhz093",
|
||||
"contents-private-key": "${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}",
|
||||
"pull-request-client-id": MANTIS_GITHUB_APP_CLIENT_ID,
|
||||
...(preflight === controlUiPreflight
|
||||
? { "pull-request-contents-permission": "write" }
|
||||
: {}),
|
||||
"pull-request-contents-permission": "write",
|
||||
"pull-request-private-key": "${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}",
|
||||
});
|
||||
}
|
||||
const controlUiTokensStep = controlUiPreflight.steps.find(
|
||||
(step: { name?: string }) => step.name === "Create generated PR tokens",
|
||||
);
|
||||
const autoMergeSettingStep = controlUiPreflight.steps.find(
|
||||
(step: { name?: string }) => step.name === "Verify repository auto-merge setting",
|
||||
);
|
||||
expect(controlUiTokensStep.id).toBe("tokens");
|
||||
expect(autoMergeSettingStep.env.GH_TOKEN).toBe(
|
||||
"${{ steps.tokens.outputs.pull-request-token }}",
|
||||
);
|
||||
expect(autoMergeSettingStep.run).toContain("autoMergeAllowed");
|
||||
expect(autoMergeSettingStep.run).toContain("Repository auto-merge must be enabled");
|
||||
for (const preflight of [controlUiPreflight, nativePreflight]) {
|
||||
const tokensStep = preflight.steps.find(
|
||||
(step: { name?: string }) => step.name === "Create generated PR tokens",
|
||||
);
|
||||
const autoMergeSettingStep = preflight.steps.find(
|
||||
(step: { name?: string }) => step.name === "Verify repository auto-merge setting",
|
||||
);
|
||||
expect(tokensStep.id).toBe("tokens");
|
||||
expect(autoMergeSettingStep.env.GH_TOKEN).toBe(
|
||||
"${{ steps.tokens.outputs.pull-request-token }}",
|
||||
);
|
||||
expect(autoMergeSettingStep.run).toContain("autoMergeAllowed");
|
||||
expect(autoMergeSettingStep.run).toContain("Repository auto-merge must be enabled");
|
||||
}
|
||||
|
||||
const tokenAction = parse(readFileSync(CREATE_GENERATED_PR_TOKENS_ACTION, "utf8"));
|
||||
const tokenActionSource = readFileSync(CREATE_GENERATED_PR_TOKENS_ACTION, "utf8");
|
||||
@@ -1401,9 +1392,7 @@ 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["auto-merge"]).toBe("true");
|
||||
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 }}");
|
||||
@@ -4179,6 +4168,30 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}"
|
||||
);
|
||||
});
|
||||
|
||||
it("splits native source verification from generated locale parity", () => {
|
||||
const workflow = readCiWorkflow();
|
||||
const localeJob = workflow.jobs["native-i18n"];
|
||||
const sourceStep = localeJob.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Verify native app i18n source",
|
||||
);
|
||||
const parityStep = localeJob.steps.find(
|
||||
(step: WorkflowStep) => step.name === "Check native app generated locale parity",
|
||||
);
|
||||
const packageScripts = JSON.parse(readFileSync("package.json", "utf8")).scripts;
|
||||
|
||||
expect(packageScripts["native:i18n:baseline"]).toContain("baseline --write");
|
||||
expect(packageScripts["native:i18n:verify"]).toContain(" verify");
|
||||
expect(workflow.jobs.preflight.outputs.strict_native_i18n).toContain(
|
||||
"steps.changed_scope.outputs.strict_native_i18n",
|
||||
);
|
||||
expect(sourceStep.run).toContain("pnpm native:i18n:verify");
|
||||
expect(sourceStep.run).toContain("Historical release targets");
|
||||
expect(parityStep.if).toBe("${{ needs.preflight.outputs.strict_native_i18n == 'true' }}");
|
||||
expect(parityStep.run).toContain("pnpm native:i18n:check");
|
||||
expect(parityStep.run).not.toContain("pnpm android:i18n:check");
|
||||
expect(parityStep.run).not.toContain("pnpm apple:i18n:check");
|
||||
});
|
||||
|
||||
it("keeps the hosted plugin-list memory allowance scoped to GitHub-hosted runners", () => {
|
||||
const workflow = readCiWorkflow();
|
||||
const startupMemoryStep = workflow.jobs["build-artifacts"].steps.find(
|
||||
|
||||
@@ -1163,6 +1163,16 @@ describe("native app i18n inventory", () => {
|
||||
});
|
||||
|
||||
it("validates locale refresh arguments before write paths run", () => {
|
||||
expect(parseNativeI18nCommand(["baseline", "--write"])).toEqual({
|
||||
command: "baseline",
|
||||
locale: undefined,
|
||||
write: true,
|
||||
});
|
||||
expect(parseNativeI18nCommand(["verify"])).toEqual({
|
||||
command: "verify",
|
||||
locale: undefined,
|
||||
write: false,
|
||||
});
|
||||
expect(parseNativeI18nCommand(["sync", "--write", "--locale", "sv"])).toEqual({
|
||||
command: "sync",
|
||||
locale: "sv",
|
||||
@@ -1180,5 +1190,9 @@ describe("native app i18n inventory", () => {
|
||||
expect(() => parseNativeI18nCommand(["check", "--locale", "sv"])).toThrow(
|
||||
"requires `sync --write",
|
||||
);
|
||||
expect(() => parseNativeI18nCommand(["baseline"])).toThrow("requires `--write`");
|
||||
expect(() => parseNativeI18nCommand(["verify", "--write"])).toThrow(
|
||||
"does not accept `--write`",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ const CHECK_COMMANDS = [
|
||||
"pnpm plugin-sdk:api:check",
|
||||
"pnpm plugin-sdk:surface:check",
|
||||
"pnpm ui:i18n:check",
|
||||
"pnpm native:i18n:check",
|
||||
];
|
||||
const FIX_COMMANDS = [
|
||||
"node --import tsx scripts/sync-plugin-versions.ts",
|
||||
@@ -215,6 +216,7 @@ describe("scripts/release-preflight.mjs", () => {
|
||||
"node scripts/generate-npm-shrinkwrap.mjs --all --check",
|
||||
"node scripts/generate-plugin-inventory-doc.mjs --check",
|
||||
"pnpm ui:i18n:check",
|
||||
"pnpm native:i18n:check",
|
||||
].toSorted(),
|
||||
);
|
||||
expect(result.stdout).toContain("(version, jobs=4)");
|
||||
|
||||
Reference in New Issue
Block a user