From a26006aa6aaa08e8ebfad0c9bde05a0ca8c911f3 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 26 Oct 2026 03:17:34 -0700 Subject: [PATCH] perf(ci): overlap preflight's serial tail; parallel ratchets; allow 2-10 unit shards (#33887) - Plan unit selection starts after setup (overlaps lint) instead of last. - Localization extraction starts with the typecheck; test-file-runners is background. - run-ratchets.mjs runs ratchets in a pool (19s serial -> 8s locally). - pnpm lint uses CI's single-parse check-import-boundaries.mjs. - ORCA_UNIT_FULL_SHARD_COUNT accepts 2-10 (default stays 5). Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .github/workflows/pr.yml | 81 ++++++++++++++++------------ config/scripts/ci-unit-plan.mjs | 5 +- config/scripts/ci-unit-plan.test.mjs | 8 +-- config/scripts/run-lint.mjs | 9 ++-- config/scripts/run-ratchets.mjs | 42 ++++++++++----- 5 files changed, 89 insertions(+), 56 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8245654acae..846a6e29fbf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -220,6 +220,22 @@ jobs: with: bun-version-file: config/.bun-version + # Plan first: its ~10s single-threaded import-graph walk overlaps lint instead of trailing the job. + - name: Plan unit selection + if: '!cancelled()' + id: unit-plan + background: true + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} + ORCA_PULL_REQUEST_DRAFT: ${{ github.event.pull_request.draft }} + # Five shards use fewer runner minutes; the repository variable can request 2 to 10. + ORCA_UNIT_FULL_SHARD_COUNT: ${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '5' }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + node config/scripts/ci-unit-plan.mjs + # Run gate scripts directly to avoid repeated package-manager startup. - name: Check process-host imports if: '!cancelled()' @@ -343,6 +359,27 @@ jobs: if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi pnpm run typecheck:ci --bun-concurrency=2 --bun-threads=2 + # Starts with the compiler so its ~16s pass overlaps the serial checks below. + # Extract sorted evidence to isolated temp paths with one normalized platform-independent AST pass; use preflight’s pinned Bun. + - name: Verify localization extraction + if: '!cancelled()' + id: localization-extraction + background: true + env: + PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} + PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi + # Detection failures run the full check; renames retain the removed input path. + DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" + if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && + scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then + echo "Localization extraction inputs are unchanged." + else + bun config/scripts/verify-localization-extraction.mjs + fi + - name: Check Zustand selector fan-out budget if: needs.code_paths.outputs.static_analysis == 'true' run: node config/scripts/zustand-selector-fanout-benchmark.mjs --check @@ -388,33 +425,21 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: node config/scripts/check-node-runtime-pin.mjs + # A ~7s single-threaded tree walk; it overlaps the remaining checks. - name: Check every test file has a runner - if: needs.code_paths.outputs.static_analysis == 'true' - run: node config/scripts/check-test-file-runners.mjs - - - name: Check root and mobile lock the same shared dependencies - if: needs.code_paths.outputs.static_analysis == 'true' - run: node config/scripts/check-mobile-shared-dependency-versions.mjs - - # Extract sorted evidence to isolated temp paths with one normalized platform-independent AST pass; use preflight’s pinned Bun. - - name: Verify localization extraction if: '!cancelled()' - id: localization-extraction + id: test-file-runners background: true env: PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }} PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - # Detection failures run the full check; renames retain the removed input path. - DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" - if git diff --name-only --no-renames -z "$DIFF_BASE" HEAD > "$RUNNER_TEMP/localization-changes" && - scope="$(node config/scripts/localization-extraction-change-scope.mjs "$RUNNER_TEMP/localization-changes")" && [ "$scope" = false ]; then - echo "Localization extraction inputs are unchanged." - else - bun config/scripts/verify-localization-extraction.mjs - fi + node config/scripts/check-test-file-runners.mjs + + - name: Check root and mobile lock the same shared dependencies + if: needs.code_paths.outputs.static_analysis == 'true' + run: node config/scripts/check-mobile-shared-dependency-versions.mjs - name: Decide whether the orcad terminal smoke runs id: orcad-smoke-scope @@ -498,22 +523,6 @@ jobs: if: needs.code_paths.outputs.static_analysis == 'true' run: node config/scripts/verify-macos-entitlements.mjs - # Planning shares setup and stays off the compiler's critical path. - - name: Plan unit selection - if: '!cancelled()' - id: unit-plan - background: true - env: - PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }} - PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }} - ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }} - ORCA_PULL_REQUEST_DRAFT: ${{ github.event.pull_request.draft }} - # Five shards use fewer runner minutes; the repository variable can still request ten. - ORCA_UNIT_FULL_SHARD_COUNT: ${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '5' }} - run: | - if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi - node config/scripts/ci-unit-plan.mjs - - wait: typecheck - wait: process-host-imports @@ -524,6 +533,8 @@ jobs: - wait: unit-plan + - wait: test-file-runners + - uses: actions/upload-artifact@v7 if: needs.code_paths.outputs.typecheck == 'true' with: diff --git a/config/scripts/ci-unit-plan.mjs b/config/scripts/ci-unit-plan.mjs index fce7526f9c8..3142094e3e8 100644 --- a/config/scripts/ci-unit-plan.mjs +++ b/config/scripts/ci-unit-plan.mjs @@ -8,8 +8,9 @@ import { readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs' export function prepareUnitPlan(env = process.env) { const requestedCount = env.ORCA_UNIT_FULL_SHARD_COUNT ?? String(FULL_SHARD_COUNT) - if (!['5', '10'].includes(requestedCount)) { - throw new Error('ORCA_UNIT_FULL_SHARD_COUNT must be 5 or 10') + // Every consumer reads the count from the plan; ten bounds runner-slot use. + if (!/^([2-9]|10)$/.test(requestedCount)) { + throw new Error('ORCA_UNIT_FULL_SHARD_COUNT must be an integer from 2 to 10') } const fullShardCount = Number(requestedCount) const files = discoverUnitFiles() diff --git a/config/scripts/ci-unit-plan.test.mjs b/config/scripts/ci-unit-plan.test.mjs index f2d14d23e30..8a766b5ad99 100644 --- a/config/scripts/ci-unit-plan.test.mjs +++ b/config/scripts/ci-unit-plan.test.mjs @@ -86,7 +86,7 @@ it.each([ } ) -it.each(['5', '10', 'invalid'])( +it.each(['5', '6', '10', 'invalid', '11'])( 'validates the requested full shard count and keeps fallback coverage: %s', (requestedCount) => { const root = mkdtempSync(join(tmpdir(), 'orca-unit-count-')) @@ -105,9 +105,11 @@ it.each(['5', '10', 'invalid'])( ORCA_SHARD_SOURCE_SHA: 'full-count-fixture' } }) - if (requestedCount === 'invalid') { + if (['invalid', '11'].includes(requestedCount)) { expect(result.code).not.toBe(0) - expect(result.stderr).toContain('ORCA_UNIT_FULL_SHARD_COUNT must be 5 or 10') + expect(result.stderr).toContain( + 'ORCA_UNIT_FULL_SHARD_COUNT must be an integer from 2 to 10' + ) return } expect(result.code, result.stderr).toBe(0) diff --git a/config/scripts/run-lint.mjs b/config/scripts/run-lint.mjs index bd31283ff24..fd1034309a9 100644 --- a/config/scripts/run-lint.mjs +++ b/config/scripts/run-lint.mjs @@ -8,12 +8,11 @@ import { pathToFileURL } from 'node:url' // package.json script names, slowest first so short steps fill in around them. export const LINT_STEPS = [ - 'check:process-host-imports', + 'check:import-boundaries', 'verify:localization-extraction', 'audit:anti-slop', 'audit:code-quality:type-aware', 'check:test-file-runners', - 'check:compat-boundary-imports', 'verify:localization-catalogs', 'verify:localization-coverage', 'check:flaky-test-pattern-ratchet', @@ -48,7 +47,11 @@ export const LINT_STEPS = [ ] // Steps with no package script of their own run as the bare command. -const BARE_COMMANDS = { oxlint: 'oxlint' } +const BARE_COMMANDS = { + oxlint: 'oxlint', + // process-host and compat-boundary imports from one TypeScript parse, as CI runs them. + 'check:import-boundaries': 'bun config/scripts/check-import-boundaries.mjs' +} function parseMaxWorkers(argv) { const argument = argv.find((value) => /^--max-?workers=\d+$/i.test(value)) diff --git a/config/scripts/run-ratchets.mjs b/config/scripts/run-ratchets.mjs index cacea298b9c..eadbf28b3a1 100644 --- a/config/scripts/run-ratchets.mjs +++ b/config/scripts/run-ratchets.mjs @@ -1,5 +1,6 @@ -import { spawnSync } from 'node:child_process' +import { spawn } from 'node:child_process' import { readdirSync } from 'node:fs' +import { availableParallelism } from 'node:os' import path from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' @@ -19,26 +20,41 @@ export function listRatchetScripts(scriptsDir) { .sort() } -function main(root) { +function runRatchet(scriptsDir, name, root) { + return new Promise((resolve) => { + let output = '' + const child = spawn(process.execPath, [path.join(scriptsDir, name)], { cwd: root }) + child.stdout.on('data', (chunk) => (output += chunk)) + child.stderr.on('data', (chunk) => (output += chunk)) + child.on('error', (error) => resolve({ name, ok: false, output: `${output}${error.message}` })) + child.on('close', (code) => resolve({ name, ok: code === 0, output })) + }) +} + +// Each ratchet is a single-threaded scan; half the cores leaves room for the overlapping typecheck. +async function main(root) { const scriptsDir = path.join(root, 'config', 'scripts') + const pending = listRatchetScripts(scriptsDir) const failed = [] - for (const name of listRatchetScripts(scriptsDir)) { - console.log(`\n▶ ${name}`) - const result = spawnSync(process.execPath, [path.join(scriptsDir, name)], { - cwd: root, - stdio: 'inherit' + await Promise.all( + Array.from({ length: Math.max(2, Math.floor(availableParallelism() / 2)) }, async () => { + while (pending.length > 0) { + const result = await runRatchet(scriptsDir, pending.shift(), root) + // Whole per-script blocks, so concurrent ratchets never interleave their lines. + process.stdout.write(`\n▶ ${result.name}\n${result.output}`) + if (!result.ok) { + failed.push(result.name) + } + } }) - if (result.error || result.status !== 0) { - failed.push(name) - } - } + ) if (failed.length > 0) { - console.error(`\nFailed ratchets: ${failed.join(', ')}`) + console.error(`\nFailed ratchets: ${failed.sort().join(', ')}`) return 1 } return 0 } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - process.exit(main(process.cwd())) + process.exitCode = await main(process.cwd()) }