From d17a17684bf97b3fcf2bc393bc4bbd8aaaef11a7 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sat, 26 Sep 2026 01:17:51 -0700 Subject: [PATCH] Reduce redundant CI runs, pnpm uploads, and fixture startups (#23145) * Reduce redundant CI runs, store uploads, and fixture processes * Avoid repeating draft-independent mobile checks on readiness --------- Co-authored-by: m4air --- .../install-node-dependencies/action.yml | 28 ++- .github/workflows/bun-profile-tests.yml | 46 ++++- .github/workflows/mobile.yml | 1 - .github/workflows/pr-test-loc.yml | 1 - .github/workflows/pr.yml | 44 +++-- config/scripts/build-orcad.mjs | 11 +- config/scripts/bun-profile-change-scope.mjs | 127 ++++++++++++++ .../scripts/bun-profile-change-scope.test.mjs | 151 ++++++++++++++++ config/scripts/bun-profile-test-paths.mjs | 24 +++ .../ci-dependency-download-cache.test.mjs | 25 ++- config/scripts/ci-pnpm-store-benchmark.mjs | 154 ++++++++++++++++ .../install-node-dependencies-action.test.mjs | 50 +++++- ...ile-release-shell-switch-workflow.test.mjs | 10 +- config/scripts/orcad-entry-build.mjs | 11 +- config/scripts/pr-code-change-scope.test.mjs | 7 +- config/scripts/pr-ready-check-gate.test.mjs | 66 +++++++ config/scripts/pr-ready-check-reuse.mjs | 99 +++++++++++ config/scripts/pr-ready-check-reuse.test.mjs | 166 ++++++++++++++++++ .../scripts/pr-workflow-parallelism.test.mjs | 13 +- config/scripts/run-bun-profile-tests.mjs | 26 +-- docs/reference/ci-runner-efficiency.md | 37 ++++ .../ssh-relay-upload-stage-commands.test.ts | 65 ++++++- 22 files changed, 1089 insertions(+), 73 deletions(-) create mode 100644 config/scripts/bun-profile-change-scope.mjs create mode 100644 config/scripts/bun-profile-change-scope.test.mjs create mode 100644 config/scripts/bun-profile-test-paths.mjs create mode 100644 config/scripts/ci-pnpm-store-benchmark.mjs create mode 100644 config/scripts/pr-ready-check-gate.test.mjs create mode 100644 config/scripts/pr-ready-check-reuse.mjs create mode 100644 config/scripts/pr-ready-check-reuse.test.mjs diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 30c98070f31..6aecef6e152 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -50,8 +50,9 @@ runs: uses: actions/setup-node@v6 with: node-version-file: package.json - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false - name: Setup requested Node.js id: requested-node @@ -59,8 +60,31 @@ runs: uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: pnpm + cache: ${{ github.event_name != 'pull_request' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} + package-manager-cache: false + + # PR-local stores compete with reusable build caches for the repository quota. + - name: Resolve pnpm download store + id: pnpm-store + if: github.event_name == 'pull_request' + shell: bash + env: + LOCKFILE_HASH: ${{ hashFiles(inputs.cache-dependency-path) }} + run: | + test -n "$LOCKFILE_HASH" + cache_path="$(pnpm store path --silent)" + test -n "$cache_path" + printf 'path=%s\n' "$cache_path" >> "$GITHUB_OUTPUT" + printf 'arch=%s\n' "$(node -p 'require("node:os").arch()')" >> "$GITHUB_OUTPUT" + + # Match setup-node's key and path so existing default-branch stores remain reusable. + - name: Restore pnpm download store without saving + if: github.event_name == 'pull_request' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} - name: Validate native runtime shell: bash diff --git a/.github/workflows/bun-profile-tests.yml b/.github/workflows/bun-profile-tests.yml index af021fb59d9..48eb3442ba7 100644 --- a/.github/workflows/bun-profile-tests.yml +++ b/.github/workflows/bun-profile-tests.yml @@ -3,17 +3,17 @@ name: Bun profile persistence on: pull_request: paths: - - 'src/main/persistence/**' - - 'src/main/sqlite/**' - - 'src/main/worker-thread-entry-path.ts' - - 'src/main/orcad/**' - - 'src/main/daemon/pty-subprocess/**' - - 'src/main/providers/**' - - 'src/shared/**' + - 'src/**' - 'config/**' + - 'native/**' + - 'tests/**' + - 'resources/**' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - 'tsconfig.json' + - '.npmrc' + - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' - '.github/workflows/bun-profile-tests.yml' workflow_dispatch: @@ -26,7 +26,33 @@ concurrency: cancel-in-progress: true jobs: + changes: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + should_run: ${{ steps.scope.outputs.should_run }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 2 + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + - name: Detect Bun build and test inputs + id: scope + shell: bash + run: | + # Compare the tested merge with its base, retaining both sides of renames. + if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/bun-changes"; then + node config/scripts/bun-profile-change-scope.mjs "$RUNNER_TEMP/bun-changes" + else + echo 'should_run=true' >> "$GITHUB_OUTPUT" + fi + persistence: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} strategy: fail-fast: false matrix: @@ -56,6 +82,9 @@ jobs: node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018 linux_glibc_floor: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} strategy: fail-fast: false matrix: @@ -78,6 +107,9 @@ jobs: - run: pnpm test:bun:profile --artifact linux_musl: + needs: changes + # Missing/failed detection runs the full matrix; manual runs remain unconditional. + if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index aae50234bf9..097ad813962 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review paths: - 'mobile/**' # Mobile launch contracts exercise the real host dispatcher and durable receipt store. diff --git a/.github/workflows/pr-test-loc.yml b/.github/workflows/pr-test-loc.yml index fb2934d1f73..340c6c7b70d 100644 --- a/.github/workflows/pr-test-loc.yml +++ b/.github/workflows/pr-test-loc.yml @@ -6,7 +6,6 @@ on: - opened - synchronize - reopened - - ready_for_review concurrency: group: pr-test-loc-${{ github.event.pull_request.number }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 781617d0116..1e21b888454 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,4 +1,5 @@ name: PR Checks +run-name: 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}' on: pull_request: @@ -27,23 +28,28 @@ jobs: # Reuse one lightweight checkout for detection and the always-required guards. runs-on: ubuntu-slim timeout-minutes: 5 + permissions: + contents: read + actions: read outputs: should_run: ${{ steps.filter.outputs.should_run }} - native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} + reused_run_id: ${{ steps.readiness.outputs.run_id }} + # A proven success masks required work only; advisory routing still uses the full diff. + native_cache_changed: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} - mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }} - static_analysis: ${{ steps.filter.outputs.static_analysis }} - typecheck: ${{ steps.filter.outputs.typecheck }} - git_compatibility: ${{ steps.filter.outputs.git_compatibility }} - codex_index_heal_contract: ${{ steps.filter.outputs.codex_index_heal_contract }} - xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }} - shell_contracts: ${{ steps.filter.outputs.shell_contracts }} - test: ${{ steps.filter.outputs.test }} - orcad_browser: ${{ steps.filter.outputs.orcad_browser }} - cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }} - managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }} - package: ${{ steps.filter.outputs.package }} - package_windows: ${{ steps.filter.outputs.package_windows }} + mobile_web_app: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }} + static_analysis: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }} + typecheck: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }} + git_compatibility: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }} + codex_index_heal_contract: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }} + xterm_patch_sync: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }} + shell_contracts: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }} + test: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }} + orcad_browser: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.orcad_browser }} + cross-version-wire: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }} + managed_hook_node18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }} + package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }} + package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }} e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }} test_files: ${{ steps.e2e_filter.outputs.test_files }} ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }} @@ -66,6 +72,7 @@ jobs: /config/scripts/check-readme-local-links.mjs /config/scripts/pr-code-change-scope.mjs /config/scripts/pr-e2e-source-routing.mjs + /config/scripts/pr-ready-check-reuse.mjs sparse-checkout-cone-mode: false persist-credentials: false @@ -79,6 +86,15 @@ jobs: - name: Check README local links run: node config/scripts/check-readme-local-links.mjs + # Readiness changes eligibility for advisory tests, not the already-tested source. + - name: Find identical successful required checks + id: readiness + if: github.event.action == 'ready_for_review' + env: + GH_TOKEN: ${{ github.token }} + PR_CHECK_WORKFLOW_SHA: ${{ github.workflow_sha }} + run: node config/scripts/pr-ready-check-reuse.mjs + - name: Classify changed paths id: filter env: diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs index b1d3430c4d1..b015f52ea2e 100644 --- a/config/scripts/build-orcad.mjs +++ b/config/scripts/build-orcad.mjs @@ -5,7 +5,8 @@ import { build } from 'esbuild' import { buildOrcadEntry, externalNativeAddons, - ORCAD_EXTERNAL_MODULES + ORCAD_EXTERNAL_MODULES, + ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' import { createRequire } from 'node:module' import { @@ -44,14 +45,14 @@ const OUT_DIR = process.env.ORCAD_OUT_DIR // Why beside orcad.js: the watcher runs in a forked child so a native @parcel/watcher // fault crashes that child instead of the server, and `resolveWatcherProcessEntryPath` // looks for it in the app root. A deployment has no desktop out/main to fall back to. -const WATCHER_ENTRY = join(ROOT, 'src/main/ipc/parcel-watcher-process-entry.ts') +const WATCHER_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.watcher) const WATCHER_OUT_FILE = join(OUT_DIR, 'parcel-watcher-process-entry.js') // Why beside orcad.js: orcad forks the terminal daemon so PTYs outlive the runtime process, // and `getDaemonEntryPath()` probes the app root for this exact filename. Without it every // orcad restart would SIGKILL every running terminal. -const DAEMON_ENTRY = join(ROOT, 'src/main/daemon/daemon-entry.ts') +const DAEMON_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.daemon) const DAEMON_OUT_FILE = join(OUT_DIR, 'daemon-entry.js') -const PTY_GATE_ENTRY = join(ROOT, 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts') +const PTY_GATE_ENTRY = join(ROOT, ORCAD_CHILD_ENTRY_POINTS.ptyGate) const PTY_GATE_OUT_FILE = join(OUT_DIR, 'windows-bun-pty-gate-entry.js') const OUT_FILE = join(OUT_DIR, 'orcad.js') const BUILD_TARGET = process.env.ORCAD_BUILD_TARGET @@ -179,7 +180,7 @@ const childResults = await Promise.all([ buildForkedChild(PTY_GATE_ENTRY, PTY_GATE_OUT_FILE), ...['writer', 'backup'].map((role) => buildForkedChild( - join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`), + join(ROOT, ORCAD_CHILD_ENTRY_POINTS[role]), join(OUT_DIR, `profile-state-${role}-worker-entry.js`) ) ) diff --git a/config/scripts/bun-profile-change-scope.mjs b/config/scripts/bun-profile-change-scope.mjs new file mode 100644 index 00000000000..c30c4a282ac --- /dev/null +++ b/config/scripts/bun-profile-change-scope.mjs @@ -0,0 +1,127 @@ +import { build } from 'esbuild' +import { appendFileSync, globSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + externalNativeAddons, + ORCAD_CHILD_ENTRY_POINTS, + ORCAD_ENTRY_POINT +} from './orcad-entry-build.mjs' +import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' + +const ROOT = resolve(import.meta.dirname, '../..') +const BUILD_SCRIPTS = [ + 'config/scripts/build-orcad-bun.mjs', + 'config/scripts/build-orcad.mjs', + 'config/scripts/build-windows-process-tree-relay-addon.mjs', + 'config/scripts/run-bun-profile-tests.mjs', + 'config/vitest.config.ts', + 'config/scripts/happy-dom-offscreen-canvas.ts', + 'config/scripts/happy-dom-mutation-observer-retention.ts', + 'config/scripts/vitest-host-ports-setup.ts' +] +const ALWAYS_FILES = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + '.npmrc', + '.pnpmfile.cjs', + 'tsconfig.json', + '.github/workflows/bun-profile-tests.yml', + 'config/scripts/bun-profile-change-scope.mjs', + 'config/scripts/bun-profile-change-scope.test.mjs' +]) +const ALWAYS_PREFIXES = [ + '.github/actions/install-node-dependencies/', + // These areas also contain worker paths and fixtures opened without an import. + 'src/main/persistence/', + 'src/main/sqlite/', + 'src/main/orcad/', + 'src/main/daemon/pty-subprocess/', + 'src/main/providers/', + 'config/patches/', + 'config/tsconfig', + 'native/', + 'resources/licenses/ripgrep/' +] + +export function discoverBunProfileTests(root = ROOT) { + const selectors = bunProfileTestPaths({ artifact: true }) + return globSync( + ['src/**/*.test.{ts,tsx}', 'config/scripts/**/*.test.{ts,mjs}', 'tests/e2e/**/*.unit.test.ts'], + { cwd: root } + ) + .map((file) => file.replaceAll('\\', '/')) + .filter((file) => selectors.some((selector) => file.includes(selector))) + .sort() +} + +export async function collectBunProfileInputs({ root = ROOT, entryPoints } = {}) { + const entries = entryPoints ?? [ + ORCAD_ENTRY_POINT, + ...Object.values(ORCAD_CHILD_ENTRY_POINTS), + ...BUILD_SCRIPTS, + ...discoverBunProfileTests(root) + ] + const result = await build({ + absWorkingDir: root, + entryPoints: entries, + bundle: true, + write: false, + outdir: resolve(root, '.bun-profile-scope'), + platform: 'node', + format: 'esm', + splitting: true, + packages: 'external', + loader: { '.svg': 'empty', '.png': 'empty', '.webp': 'empty', '.css': 'empty' }, + plugins: [externalNativeAddons], + metafile: true, + logLevel: 'silent' + }) + if (result.warnings.length > 0) { + throw new Error(result.warnings.map((warning) => warning.text).join('\n')) + } + return new Set( + Object.keys(result.metafile.inputs).map((file) => + file.replaceAll('\\', '/').replace(/\?.*$/, '') + ) + ) +} + +export async function classifyBunProfileChanges(changedFiles, collect = collectBunProfileInputs) { + if (changedFiles.length === 0) { + return { shouldRun: true, reason: 'No complete changed-file evidence' } + } + const selectors = bunProfileTestPaths({ artifact: true }) + const forced = changedFiles.find( + (file) => + ALWAYS_FILES.has(file) || + ALWAYS_PREFIXES.some((prefix) => file.startsWith(prefix)) || + selectors.some((selector) => file.includes(selector)) + ) + if (forced) { + return { shouldRun: true, reason: `Build or CI input changed: ${forced}` } + } + try { + const inputs = await collect() + const matched = changedFiles.find((file) => inputs.has(file)) + return { + shouldRun: Boolean(matched), + reason: matched ? `Runtime or test dependency changed: ${matched}` : 'No Bun inputs changed' + } + } catch (error) { + return { shouldRun: true, reason: `Dependency graph unavailable: ${String(error)}` } + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) + const result = await classifyBunProfileChanges(changedFiles) + console.log(result.reason) + const output = `should_run=${String(result.shouldRun)}\n` + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, output) + } else { + process.stdout.write(output) + } +} diff --git a/config/scripts/bun-profile-change-scope.test.mjs b/config/scripts/bun-profile-change-scope.test.mjs new file mode 100644 index 00000000000..4b6d1647ffc --- /dev/null +++ b/config/scripts/bun-profile-change-scope.test.mjs @@ -0,0 +1,151 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + classifyBunProfileChanges, + collectBunProfileInputs, + discoverBunProfileTests +} from './bun-profile-change-scope.mjs' +import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' +import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' + +const temporaryDirs = [] +afterEach(() => { + for (const root of temporaryDirs.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function moduleTree(files) { + const root = mkdtempSync(join(tmpdir(), 'bun-profile-scope-')) + temporaryDirs.push(root) + for (const [file, source] of Object.entries(files)) { + const path = join(root, file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, source) + } + return root +} + +it('follows static imports, re-exports, dynamic imports and require without executing source', async () => { + const root = moduleTree({ + 'entry.ts': `import './first'; export * from './exports'; import('./dynamic'); require('./required'); throw Error('never execute')`, + 'first.ts': `import './nested/leaf'`, + 'exports.ts': 'export const value = 1', + 'dynamic.ts': 'export const value = 2', + 'required.ts': 'module.exports = 3', + 'nested/leaf.ts': 'export const value = 4', + 'unrelated.ts': 'throw Error("unrelated")' + }) + const inputs = await collectBunProfileInputs({ root, entryPoints: ['entry.ts'] }) + expect([...inputs].sort()).toEqual([ + 'dynamic.ts', + 'entry.ts', + 'exports.ts', + 'first.ts', + 'nested/leaf.ts', + 'required.ts' + ]) +}) + +it('runs the matrix when a dependency is deleted or graph analysis fails', async () => { + const root = moduleTree({ 'entry.ts': `import './deleted'` }) + const result = await classifyBunProfileChanges(['deleted.ts'], () => + collectBunProfileInputs({ root, entryPoints: ['entry.ts'] }) + ) + expect(result.shouldRun).toBe(true) + expect(result.reason).toContain('Dependency graph unavailable') + expect((await classifyBunProfileChanges([])).shouldRun).toBe(true) +}) + +it.each([ + ['tests/e2e/daemon-running-work-probe.unit.test.ts'], + ['config/scripts/zip-extractor-command.test.mjs'], + ['config/scripts/zip-extractor-command.test.mjs', 'config/scripts/renamed-command.test.mjs'] +])( + 'runs deleted or renamed selected tests even when absent from the graph: %j', + async (...files) => { + expect((await classifyBunProfileChanges(files, async () => new Set())).shouldRun).toBe(true) + } +) + +it.each([ + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + '.npmrc', + 'tsconfig.json', + 'config/tsconfig.node.json', + 'config/patches/node-pty@1.1.0.patch', + 'native/windows-registry/src/addon.cc', + '.github/actions/install-node-dependencies/action.yml', + '.github/workflows/bun-profile-tests.yml', + 'src/main/persistence/profile-state/new-worker.ts' +])('always selects build, native and dynamically opened inputs: %s', async (file) => { + expect((await classifyBunProfileChanges([file], async () => new Set())).shouldRun).toBe(true) +}) + +describe('the actual Bun build and profile-test dependency graph', () => { + let inputs + beforeAll(async () => { + inputs = await collectBunProfileInputs() + }, 60_000) + + it.each([ + 'config/scripts/ci-shard-timings.json', + 'config/scripts/mobile-web-app-terminal-render.test.mjs', + 'src/main/ssh/ssh-relay-upload-stage-commands.test.ts', + 'src/main/menu/register-app-menu.ts' + ])('skips unrelated work: %s', async (file) => { + expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(false) + }) + + it.each([ + ...Object.values(ORCAD_CHILD_ENTRY_POINTS), + 'src/shared/keybindings/definitions-core-1.ts', + 'src/main/runtime/orca-runtime.ts', + 'src/main/windows/windows-process-table.ts', + 'src/main/worker-thread-entry-path.ts', + 'config/scripts/zip-extractor-command.mjs', + 'config/scripts/windows-process-tree-gyp-rebuild.mjs', + 'config/scripts/profile-state-worker-smoke.mjs', + 'config/scripts/vitest-host-ports-setup.ts', + 'tests/e2e/daemon-running-work-probe.unit.test.ts' + ])('retains the full matrix for a real runtime, worker or test input: %s', async (file) => { + expect(inputs.has(file)).toBe(true) + expect((await classifyBunProfileChanges([file], async () => inputs)).shouldRun).toBe(true) + }) + + it('retains all selected tests and uses the same selectors as the Bun runner', () => { + const tests = discoverBunProfileTests() + expect(tests.length).toBeGreaterThan(80) + expect(tests.every((file) => inputs.has(file))).toBe(true) + expect( + bunProfileTestPaths().every((selector) => tests.some((file) => file.includes(selector))) + ).toBe(true) + const runner = readFileSync(new URL('./run-bun-profile-tests.mjs', import.meta.url), 'utf8') + expect(runner).toContain('testArgs.length > 0 ? testArgs : bunProfileTestPaths({ artifact })') + }) +}) + +it('keeps all ten platform jobs and runs them when detection is skipped or fails', () => { + const workflow = parse( + readFileSync(new URL('../../.github/workflows/bun-profile-tests.yml', import.meta.url), 'utf8') + ) + expect(workflow.on).toHaveProperty('workflow_dispatch') + expect(workflow.jobs.changes.if).toBe("github.event_name == 'pull_request'") + expect(workflow.jobs.changes.steps[0].with['fetch-depth']).toBe(2) + expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false) + const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope') + expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD') + let count = 0 + for (const jobName of ['persistence', 'linux_glibc_floor', 'linux_musl']) { + const job = workflow.jobs[jobName] + expect(job.needs).toBe('changes') + expect(job.if).toBe("${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}") + count += job.strategy.matrix.os.length + } + expect(count).toBe(10) +}) diff --git a/config/scripts/bun-profile-test-paths.mjs b/config/scripts/bun-profile-test-paths.mjs new file mode 100644 index 00000000000..23c8f423acd --- /dev/null +++ b/config/scripts/bun-profile-test-paths.mjs @@ -0,0 +1,24 @@ +export function bunProfileTestPaths({ artifact = false } = {}) { + return [ + 'src/main/persistence/profile-state', + 'src/main/persistence/loading-store/profile-state', + 'src/main/sqlite', + 'src/main/orcad/orcad-entry.test.ts', + 'src/main/orcad/orcad-push-startup.test.ts', + ...(artifact + ? [ + 'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts', + 'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts', + 'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts', + 'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts', + 'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts', + 'tests/e2e/daemon-running-work-probe.unit.test.ts', + 'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts', + 'src/main/providers/local-pty-bun-artifact.integration.test.ts', + 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', + 'src/main/orcad/orcad-bun-launcher.integration.test.ts', + 'config/scripts/zip-extractor-command.test.mjs' + ] + : []) + ] +} diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index 0860b53ebca..5b516202be7 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -10,8 +10,9 @@ describe('CI dependency download caches', () => { it('scopes desktop stores to the root lockfile and lets mixed installs opt in', () => { expect(action.inputs['cache-dependency-path'].default).toBe('pnpm-lock.yaml') for (const step of action.runs.steps.filter((step) => step.uses === 'actions/setup-node@v6')) { - expect(step.with.cache).toBe('pnpm') + expect(step.with.cache).toBe("${{ github.event_name != 'pull_request' && 'pnpm' || '' }}") expect(step.with['cache-dependency-path']).toBe('${{ inputs.cache-dependency-path }}') + expect(step.with['package-manager-cache']).toBe(false) } const install = action.runs.steps.find((step) => step.name === 'Install dependencies') expect(install.if).toBeUndefined() @@ -27,6 +28,28 @@ describe('CI dependency download caches', () => { 'mobile/pnpm-lock.yaml' ]) }) + + it('restores PR stores with setup-node keys without registering a post-job save', () => { + const resolve = action.runs.steps.find((step) => step.id === 'pnpm-store') + const restore = action.runs.steps.find( + (step) => step.name === 'Restore pnpm download store without saving' + ) + expect(resolve.if).toBe("github.event_name == 'pull_request'") + expect(restore.if).toBe(resolve.if) + expect(restore.uses).toBe('actions/cache/restore@v5') + expect(restore.with.path).toBe('${{ steps.pnpm-store.outputs.path }}') + expect(restore.with.key).toBe( + 'node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }}' + ) + expect(restore.with['restore-keys']).toBeUndefined() + expect(resolve.env.LOCKFILE_HASH).toBe('${{ hashFiles(inputs.cache-dependency-path) }}') + expect(action.runs.steps.indexOf(resolve)).toBeLessThan(action.runs.steps.indexOf(restore)) + expect(action.runs.steps.indexOf(restore)).toBeLessThan( + action.runs.steps.findIndex((step) => step.name === 'Install dependencies') + ) + const saves = action.runs.steps.filter((step) => step.uses === 'actions/cache/save@v5') + expect(saves).toEqual([]) + }) }) describe('release install targets', () => { diff --git a/config/scripts/ci-pnpm-store-benchmark.mjs b/config/scripts/ci-pnpm-store-benchmark.mjs new file mode 100644 index 00000000000..8536eafe1d5 --- /dev/null +++ b/config/scripts/ci-pnpm-store-benchmark.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { parseArgs } from 'node:util' +import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const { values } = parseArgs({ + options: { samples: { type: 'string', default: '3' }, output: { type: 'string' } } +}) +const samples = Number(values.samples) +assert(Number.isInteger(samples) && samples > 0 && samples <= 10, '--samples must be 1–10') +const repository = resolve(import.meta.dirname, '../..') +const temporary = mkdtempSync(join(tmpdir(), 'orca-ci-pnpm-store-')) +const checkout = join(temporary, 'checkout') +const store = join(temporary, 'store') +const archive = join(temporary, 'store.tar.zst') +const uncompressed = join(temporary, 'store.tar') +const manifests = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'] +const pnpm = resolvePnpmCliInvocation() +const results = [] + +function command(program, args, cwd = checkout) { + const start = performance.now() + const result = runProcessSync({ + program, + args, + cwd, + env: { ...process.env, CI: 'true', ORCA_BACKGROUND_LAUNCH: '1' }, + timeoutMs: 300_000 + }) + assert.equal(result.code, 0, `${program}: ${result.stdout}\n${result.stderr}`) + return { milliseconds: performance.now() - start, stdout: result.stdout.trim() } +} + +function pnpmCommand(args) { + return command(pnpm.command, [...pnpm.prefixArgs, ...args]) +} + +function digest() { + const hash = createHash('sha256') + for (const file of manifests) { + hash.update(readFileSync(join(checkout, file))) + } + return hash.digest('hex') +} + +function install() { + return pnpmCommand(['install', '--frozen-lockfile', '--ignore-scripts', '--store-dir', store]) + .milliseconds +} + +function transferArchive(restore) { + const start = performance.now() + // Windows BSD tar needs a separate zstd process, as in the Actions cache toolkit. + if (process.platform === 'win32') { + if (restore) { + command('zstd', ['-d', '-f', 'store.tar.zst', '-o', 'store.tar'], temporary) + command('tar', ['-xf', 'store.tar', '-C', store], temporary) + } else { + command('tar', ['--format=posix', '-cf', 'store.tar', '-C', store, '.'], temporary) + command('zstd', ['-T0', '-f', 'store.tar', '-o', 'store.tar.zst'], temporary) + } + rmSync(uncompressed) + } else { + command( + 'tar', + restore + ? ['-xf', archive, '--use-compress-program', 'zstd -d', '-C', store] + : ['--format=posix', '-cf', archive, '--use-compress-program', 'zstd -T0', '-C', store, '.'] + ) + } + return performance.now() - start +} + +try { + mkdirSync(checkout) + for (const file of [...manifests, 'native/windows-registry/package.json', 'config/patches']) { + const target = join(checkout, file) + mkdirSync(resolve(target, '..'), { recursive: true }) + cpSync(join(repository, file), target, { recursive: true }) + } + const sourceDigest = digest() + const pnpmVersion = pnpmCommand(['--version']).stdout + const zstdVersion = command('zstd', ['--version']).stdout + const tarVersion = command('tar', ['--version']).stdout + + for (let sample = 0; sample < samples; sample++) { + const policies = sample % 2 === 0 ? ['save', 'restore-only'] : ['restore-only', 'save'] + for (const policy of policies) { + rmSync(join(checkout, 'node_modules'), { recursive: true, force: true }) + rmSync(store, { recursive: true, force: true }) + const installMs = install() + assert.equal(digest(), sourceDigest, 'frozen install changed a manifest') + let archiveMs = 0 + let archiveBytes = 0 + if (policy === 'save') { + archiveMs = transferArchive(false) + archiveBytes = statSync(archive).size + } + const result = { + sample, + policy, + installMs, + archiveMs, + archiveBytes, + totalMs: installMs + archiveMs + } + results.push(result) + console.error(JSON.stringify(result)) + } + } + + // Both policies restore identical bytes on a hit; measure that common cost separately. + rmSync(join(checkout, 'node_modules'), { recursive: true, force: true }) + rmSync(store, { recursive: true, force: true }) + mkdirSync(store) + const restoreMs = transferArchive(true) + const hitInstallMs = install() + assert.equal(digest(), sourceDigest) + const report = { + platform: process.platform, + arch: process.arch, + node: process.version, + pnpm: pnpmVersion, + tar: tarVersion, + zstd: zstdVersion, + sourceDigest, + results, + hit: { restoreMs, installMs: hitInstallMs, totalMs: restoreMs + hitInstallMs }, + scope: + 'Desktop script-free frozen install, fresh isolated store per miss; alternating policy order.', + limit: + 'Measures local install/archive CPU and disk. Excludes GitHub cache transfer and remote service time.' + } + const json = `${JSON.stringify(report, null, 2)}\n` + if (values.output) { + writeFileSync(resolve(values.output), json) + } + console.log(json) +} finally { + rmSync(temporary, { recursive: true, force: true }) +} diff --git a/config/scripts/install-node-dependencies-action.test.mjs b/config/scripts/install-node-dependencies-action.test.mjs index 3b20a0eb07e..3006a0063da 100644 --- a/config/scripts/install-node-dependencies-action.test.mjs +++ b/config/scripts/install-node-dependencies-action.test.mjs @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) const installScript = action.runs.steps.find((step) => step.name === 'Install dependencies').run +const storeScript = action.runs.steps.find((step) => step.id === 'pnpm-store').run function run(command, args, options = {}) { return spawnSync(command, args, { encoding: 'utf8', ...options }) @@ -35,7 +36,10 @@ function createFixture() { ).toBe(0) const pnpm = join(bin, 'pnpm') - writeFileSync(pnpm, '#!/bin/sh\nexit 0\n') + writeFileSync( + pnpm, + '#!/bin/sh\nif [ "$1" = store ]; then printf "%s\\n" "$PNPM_TEST_STORE_PATH"; fi\nexit 0\n' + ) chmodSync(pnpm, 0o755) return { bin, detachedCwd, root, workspace } } @@ -52,6 +56,50 @@ function executeInstallScript(fixture) { } describe('install-node-dependencies action', () => { + it.each(['/home/runner/pnpm store/v11', 'C:\\Users\\runner\\pnpm store\\v11'])( + 'preserves setup-node store path %s and lowercase architecture', + (storePath) => { + const fixture = createFixture() + const output = join(fixture.root, 'github-output') + try { + const result = run('bash', ['-e', '-o', 'pipefail', '-c', storeScript], { + env: { + ...process.env, + GITHUB_OUTPUT: output, + LOCKFILE_HASH: 'lockfile-digest', + PNPM_TEST_STORE_PATH: storePath, + PATH: `${fixture.bin}${delimiter}${process.env.PATH}` + } + }) + expect(result.status, result.stderr || result.stdout).toBe(0) + expect(readFileSync(output, 'utf8')).toBe(`path=${storePath}\narch=${process.arch}\n`) + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } + } + ) + + it.each([ + ['', 'store'], + ['lockfile-digest', ''] + ])('rejects a missing lockfile hash or store path (%s, %s)', (hash, storePath) => { + const fixture = createFixture() + try { + const result = run('bash', ['-e', '-o', 'pipefail', '-c', storeScript], { + env: { + ...process.env, + GITHUB_OUTPUT: join(fixture.root, 'github-output'), + LOCKFILE_HASH: hash, + PNPM_TEST_STORE_PATH: storePath, + PATH: `${fixture.bin}${delimiter}${process.env.PATH}` + } + }) + expect(result.status).toBe(1) + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } + }) + it('skips the lockfile diff when a job container has no Git metadata', () => { const fixture = createFixture() try { diff --git a/config/scripts/mobile-release-shell-switch-workflow.test.mjs b/config/scripts/mobile-release-shell-switch-workflow.test.mjs index 9e22d799cfb..28b94945cc2 100644 --- a/config/scripts/mobile-release-shell-switch-workflow.test.mjs +++ b/config/scripts/mobile-release-shell-switch-workflow.test.mjs @@ -116,8 +116,12 @@ it('leaves the switch out of every other workflow, so only a release can set it' const MOBILE_WORKFLOWS = ['mobile.yml', 'mobile-android-release.yml', 'mobile-ios-release.yml'] /** Paths under which a Metro or Expo build cache lives, in the spellings a workflow would use. */ const BUNDLER_CACHE_PATHS = ['metro-cache', '.expo', 'node_modules/.cache'] -/** The one restored path these workflows compute in a script, and so this test cannot read. */ -const REVIEWED_COMPUTED_PATH = '${{ steps.electron-package-cache.outputs.cache-root }}' +/** Store/archive paths computed by scripts rather than declared in the workflows. */ +const REVIEWED_COMPUTED_PATHS = [ + '${{ steps.electron-package-cache.outputs.cache-root }}', + '${{ steps.pnpm-store.outputs.path }}', + "${{ github.event_name != 'pull_request' && 'pnpm' || '' }} store" +] /** Every step a workflow runs, descending into the repository's own composite actions. */ function stepsIncludingComposites(file) { @@ -175,7 +179,7 @@ describe('what the mobile jobs restore from cache', () => { it('reads every restored path, rather than passing one it cannot evaluate', () => { const computed = MOBILE_CACHE_RESTORES.filter(({ paths }) => paths.includes('${{')) - expect(computed.map(({ paths }) => paths)).toEqual(computed.map(() => REVIEWED_COMPUTED_PATH)) + expect(computed.filter(({ paths }) => !REVIEWED_COMPUTED_PATHS.includes(paths))).toEqual([]) }) it('restores no Metro or Expo build cache, which would decide the shell before the env does', () => { diff --git a/config/scripts/orcad-entry-build.mjs b/config/scripts/orcad-entry-build.mjs index 3ad6f841910..fa2c83050ac 100644 --- a/config/scripts/orcad-entry-build.mjs +++ b/config/scripts/orcad-entry-build.mjs @@ -3,6 +3,15 @@ import { join } from 'node:path' const root = join(import.meta.dirname, '..', '..') +export const ORCAD_ENTRY_POINT = 'src/main/orcad/main.ts' +export const ORCAD_CHILD_ENTRY_POINTS = { + watcher: 'src/main/ipc/parcel-watcher-process-entry.ts', + daemon: 'src/main/daemon/daemon-entry.ts', + ptyGate: 'src/main/daemon/pty-subprocess/windows-bun-pty-gate-entry.ts', + writer: 'src/main/persistence/profile-state/profile-state-writer-worker-entry.ts', + backup: 'src/main/persistence/profile-state/profile-state-backup-worker-entry.ts' +} + export const ORCAD_EXTERNAL_MODULES = [ 'electron', 'node-pty', @@ -32,7 +41,7 @@ const jsoncParserEsm = { export function buildOrcadEntry(outfile) { return build({ - entryPoints: [join(root, 'src/main/orcad/main.ts')], + entryPoints: [join(root, ORCAD_ENTRY_POINT)], bundle: true, platform: 'node', target: 'node18', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 5bbcbeb5c06..9335fa0bd00 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -517,9 +517,12 @@ describe('PR Checks skip wiring', () => { expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs') expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"') - for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) { + expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe( + '${{ steps.filter.outputs.should_run }}' + ) + for (const jobName of ['native_cache_changed', ...expensiveJobs]) { expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe( - `\${{ steps.filter.outputs.${jobName} }}` + `\${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.${jobName} }}` ) } }) diff --git a/config/scripts/pr-ready-check-gate.test.mjs b/config/scripts/pr-ready-check-gate.test.mjs new file mode 100644 index 00000000000..27e5cf99103 --- /dev/null +++ b/config/scripts/pr-ready-check-gate.test.mjs @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { runProcess } from '../../src/shared/child-process/run-process' +import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const gate = workflow.jobs.verify.steps.find((step) => step.name === 'Require successful checks') +const variable = (job) => job.replaceAll('-', '_').toUpperCase() + +function requiredResults(shouldRun) { + return Object.fromEntries( + PR_CHECK_JOBS.flatMap((job) => [ + [variable(job), shouldRun ? 'success' : 'skipped'], + [`${variable(job)}_SHOULD_RUN`, String(shouldRun)] + ]) + ) +} + +async function verify(results) { + return runProcess({ + program: 'bash', + args: ['-c', gate.run], + env: { + ...process.env, + ORCA_BACKGROUND_LAUNCH: '1', + CODE_PATHS: 'success', + SHOULD_RUN: 'true', + ...results + }, + timeoutMs: 10_000 + }) +} + +// The aggregate runs as Bash on Linux; Windows has no required Bash installation. +describe.skipIf(process.platform === 'win32')( + 'readiness reuse through the real aggregate gate', + () => { + it('accepts completed required checks or skips authorized by exact-source evidence', async () => { + expect((await verify(requiredResults(true))).code).toBe(0) + expect((await verify(requiredResults(false))).code).toBe(0) + }) + + it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => { + for (const job of PR_CHECK_JOBS) { + for (const result of ['', 'skipped', 'failure', 'cancelled']) { + const verdict = await verify({ ...requiredResults(true), [variable(job)]: result }) + expect(verdict.code, `${job}: ${result}`).toBe(1) + } + } + }) + + it('requires the detector to succeed even when every downstream job is skipped', async () => { + for (const result of ['', 'skipped', 'failure', 'cancelled']) { + expect((await verify({ ...requiredResults(false), CODE_PATHS: result })).code).toBe(1) + } + }) + + it('rejects unexpected downstream execution when the proven plan requires skips', async () => { + for (const job of PR_CHECK_JOBS) { + const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' }) + expect(verdict.code, job).toBe(1) + } + }) + } +) diff --git a/config/scripts/pr-ready-check-reuse.mjs b/config/scripts/pr-ready-check-reuse.mjs new file mode 100644 index 00000000000..7c319f5554c --- /dev/null +++ b/config/scripts/pr-ready-check-reuse.mjs @@ -0,0 +1,99 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +export function prCheckRunTitle({ number, sourceSha, workflowSha }) { + return `PR ${number} | source ${sourceSha} | workflow ${workflowSha}` +} + +export function reusablePrCheckRun(runs, identity) { + if ( + !Number.isSafeInteger(identity.number) || + identity.number < 1 || + ![identity.sourceSha, identity.workflowSha, identity.headSha].every((sha) => + /^[a-f0-9]{40}$/.test(sha ?? '') + ) + ) { + return undefined + } + return runs.find( + (run) => + Number.isSafeInteger(run.id) && + run.id > 0 && + String(run.id) !== identity.runId && + run.path === '.github/workflows/pr.yml' && + run.event === 'pull_request' && + run.status === 'completed' && + run.conclusion === 'success' && + run.head_sha === identity.headSha && + run.display_title === prCheckRunTitle(identity) + ) +} + +export async function lookupReadyCheckRun(env, event, request = fetch) { + if ( + env.GITHUB_EVENT_NAME !== 'pull_request' || + event.action !== 'ready_for_review' || + event.pull_request?.draft !== false + ) { + return undefined + } + const identity = { + number: event.pull_request?.number, + headSha: event.pull_request?.head?.sha, + sourceSha: env.GITHUB_SHA, + workflowSha: env.PR_CHECK_WORKFLOW_SHA, + runId: env.GITHUB_RUN_ID + } + const repository = env.GITHUB_REPOSITORY + if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') || !env.GH_TOKEN) { + return undefined + } + const url = new URL( + `/repos/${repository}/actions/workflows/pr.yml/runs`, + env.GITHUB_API_URL ?? 'https://api.github.com' + ) + url.search = new URLSearchParams({ + event: 'pull_request', + head_sha: identity.headSha ?? '', + status: 'success', + per_page: '20' + }).toString() + try { + const response = await request(url, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${env.GH_TOKEN}`, + 'User-Agent': 'orca-pr-ready-check-reuse', + 'X-GitHub-Api-Version': '2022-11-28' + }, + signal: AbortSignal.timeout(10_000) + }) + if (!response.ok) { + throw new Error(`Run lookup returned HTTP ${response.status}`) + } + const result = await response.json() + if (!Array.isArray(result.workflow_runs)) { + throw new Error('Run lookup omitted workflow runs') + } + return reusablePrCheckRun(result.workflow_runs, identity) + } catch (error) { + console.log(`No reusable result: ${error.message}; running the required checks.`) + return undefined + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let run + try { + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) + run = await lookupReadyCheckRun(process.env, event) + } catch (error) { + console.log(`No reusable result: ${error.message}; running the required checks.`) + } + appendFileSync(process.env.GITHUB_OUTPUT, `reused=${Boolean(run)}\nrun_id=${run?.id ?? ''}\n`) + console.log( + run + ? `Required checks already passed for this source and workflow: ${run.html_url}` + : 'No identical successful run found; running the required checks.' + ) +} diff --git a/config/scripts/pr-ready-check-reuse.test.mjs b/config/scripts/pr-ready-check-reuse.test.mjs new file mode 100644 index 00000000000..b841aa651fa --- /dev/null +++ b/config/scripts/pr-ready-check-reuse.test.mjs @@ -0,0 +1,166 @@ +import { readFileSync } from 'node:fs' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { PR_CHECK_JOBS } from './pr-code-change-scope.mjs' +import { + lookupReadyCheckRun, + prCheckRunTitle, + reusablePrCheckRun +} from './pr-ready-check-reuse.mjs' + +const identity = { + number: 42, + sourceSha: 'a'.repeat(40), + workflowSha: 'b'.repeat(40), + headSha: 'c'.repeat(40), + runId: '456' +} +const passed = { + id: 123, + path: '.github/workflows/pr.yml', + event: 'pull_request', + status: 'completed', + conclusion: 'success', + head_sha: identity.headSha, + display_title: prCheckRunTitle(identity) +} +const event = { + action: 'ready_for_review', + pull_request: { number: 42, draft: false, head: { sha: identity.headSha } } +} +const env = { + GITHUB_EVENT_NAME: 'pull_request', + GITHUB_SHA: identity.sourceSha, + PR_CHECK_WORKFLOW_SHA: identity.workflowSha, + GITHUB_RUN_ID: identity.runId, + GITHUB_REPOSITORY: 'stablyai/orca', + GH_TOKEN: 'read-only-test-token' +} +const response = (runs) => ({ ok: true, json: async () => ({ workflow_runs: runs }) }) +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + +afterEach(() => vi.restoreAllMocks()) + +describe('ready-for-review required check reuse', () => { + it('reuses a completed success only for the identical PR, merge source and workflow', () => { + expect(reusablePrCheckRun([passed], identity)).toBe(passed) + expect(reusablePrCheckRun([], identity)).toBeUndefined() + for (const key of ['number', 'sourceSha', 'workflowSha', 'headSha', 'runId']) { + const changed = key === 'number' ? 43 : key === 'runId' ? '123' : 'd'.repeat(40) + expect(reusablePrCheckRun([passed], { ...identity, [key]: changed }), key).toBeUndefined() + } + for (const key of ['number', 'sourceSha', 'workflowSha', 'headSha']) { + expect(reusablePrCheckRun([passed], { ...identity, [key]: undefined }), key).toBeUndefined() + } + }) + + it.each([ + { status: 'in_progress' }, + { status: 'queued' }, + { conclusion: 'failure' }, + { conclusion: 'cancelled' }, + { conclusion: 'skipped' }, + { event: 'push' }, + { path: '.github/workflows/other.yml' }, + { display_title: 'Previous workflow without source provenance' }, + { id: -1 } + ])('rejects unusable evidence %j', (change) => { + expect(reusablePrCheckRun([{ ...passed, ...change }], identity)).toBeUndefined() + }) + + it('bounds the read-only lookup to this workflow and PR head', async () => { + const request = vi.fn(async () => response([passed])) + expect(await lookupReadyCheckRun(env, event, request)).toBe(passed) + expect(request).toHaveBeenCalledTimes(1) + const [url, options] = request.mock.calls[0] + expect(url.origin).toBe('https://api.github.com') + expect(url.pathname).toBe('/repos/stablyai/orca/actions/workflows/pr.yml/runs') + expect(Object.fromEntries(url.searchParams)).toEqual({ + event: 'pull_request', + head_sha: identity.headSha, + status: 'success', + per_page: '20' + }) + expect(options.method).toBeUndefined() + expect(options.signal).toBeInstanceOf(AbortSignal) + }) + + it('runs ordinary pushes and draft checks without consulting old results', async () => { + const request = vi.fn() + for (const action of ['opened', 'synchronize', 'reopened', 'converted_to_draft']) { + expect(await lookupReadyCheckRun(env, { ...event, action }, request)).toBeUndefined() + } + expect( + await lookupReadyCheckRun( + env, + { ...event, pull_request: { ...event.pull_request, draft: true } }, + request + ) + ).toBeUndefined() + expect(await lookupReadyCheckRun({ ...env, GH_TOKEN: '' }, event, request)).toBeUndefined() + expect( + await lookupReadyCheckRun({ ...env, GITHUB_EVENT_NAME: 'push' }, event, request) + ).toBeUndefined() + expect(request).not.toHaveBeenCalled() + }) + + it('falls back to full checks when the API is unavailable or evidence is malformed', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + for (const request of [ + async () => ({ ok: false, status: 403 }), + async () => ({ ok: false, status: 429 }), + async () => ({ ok: true, json: async () => ({}) }), + async () => { + throw new Error('Network unavailable') + } + ]) { + expect(await lookupReadyCheckRun(env, event, request)).toBeUndefined() + } + expect(await lookupReadyCheckRun(env, event, async () => response([]))).toBeUndefined() + }) + + it('keeps required skips conditional on proof and leaves advisory routing eligible', () => { + expect(workflow['run-name']).toBe( + 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}' + ) + expect(workflow.on.pull_request.types).toContain('ready_for_review') + const detector = workflow.jobs.code_paths + expect(detector.permissions).toEqual({ contents: 'read', actions: 'read' }) + const readiness = detector.steps.find((step) => step.id === 'readiness') + expect(readiness.if).toBe("github.event.action == 'ready_for_review'") + expect(readiness.env.PR_CHECK_WORKFLOW_SHA).toBe('${{ github.workflow_sha }}') + expect(readiness.run).toBe('node config/scripts/pr-ready-check-reuse.mjs') + expect(detector.steps[0].with['sparse-checkout']).toContain( + '/config/scripts/pr-ready-check-reuse.mjs' + ) + for (const job of ['native_cache_changed', ...PR_CHECK_JOBS]) { + expect(detector.outputs[job]).toBe( + `\${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.${job} }}` + ) + } + expect(detector.outputs.should_run).toBe('${{ steps.filter.outputs.should_run }}') + for (const name of [ + 'test_files', + 'ssh_source_changed', + 'native_ime_source_changed', + 'wsl_source_changed' + ]) { + expect(detector.outputs[name]).toBe(`\${{ steps.e2e_filter.outputs.${name} }}`) + } + expect(detector.steps.find((step) => step.id === 'e2e_filter').if).toBe( + "github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'" + ) + expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}') + expect(workflow.jobs.verify.needs).toEqual(['code_paths', ...PR_CHECK_JOBS]) + }) + + it.each(['pr-test-loc.yml', 'mobile.yml'])( + 'does not rerun draft-independent %s on readiness changes', + (file) => { + const source = readFileSync(`.github/workflows/${file}`, 'utf8') + const independent = parse(source) + expect(independent.on.pull_request.types).toEqual(['opened', 'synchronize', 'reopened']) + expect(source).not.toContain('pull_request.draft') + } + ) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index ffe5141bce0..6aaa4b25018 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -269,11 +269,20 @@ describe('PR workflow parallelism', () => { expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2') expect(steps[pnpmIndex].with.version).toBeUndefined() expect(steps[pnpmIndex].with.install).toBe(false) - expect(steps[nodeIndex].with.cache).toBe('pnpm') + const saveOutsidePrs = "${{ github.event_name != 'pull_request' && 'pnpm' || '' }}" + expect(steps[nodeIndex].with.cache).toBe(saveOutsidePrs) expect(steps[nodeIndex].if).toBe("inputs.node-version == ''") expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''") expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}') - expect(steps[requestedNodeIndex].with.cache).toBe('pnpm') + expect(steps[requestedNodeIndex].with.cache).toBe(saveOutsidePrs) + const restoreIndex = steps.findIndex( + (step) => step.name === 'Restore pnpm download store without saving' + ) + expect(restoreIndex).toBeGreaterThan(requestedNodeIndex) + expect(restoreIndex).toBeLessThan( + steps.findIndex((step) => step.name === 'Install dependencies') + ) + expect(steps[restoreIndex].uses).toBe('actions/cache/restore@v5') }) it('uses the repository package-manager version for every direct pnpm setup', () => { diff --git a/config/scripts/run-bun-profile-tests.mjs b/config/scripts/run-bun-profile-tests.mjs index 8d0388d132b..debd895675b 100644 --- a/config/scripts/run-bun-profile-tests.mjs +++ b/config/scripts/run-bun-profile-tests.mjs @@ -12,6 +12,7 @@ import { } from '../../src/shared/orcad-profile-preflight.ts' import { currentTarget } from './build-orcad-bun.mjs' import { runProcessSync } from './script-child-process.mjs' +import { bunProfileTestPaths } from './bun-profile-test-paths.mjs' const root = resolve(import.meta.dirname, '../..') const target = currentTarget() @@ -69,28 +70,5 @@ run(runtimePath, [ 'run', '--config', 'config/vitest.config.ts', - ...(testArgs.length > 0 - ? testArgs - : [ - 'src/main/persistence/profile-state', - 'src/main/persistence/loading-store/profile-state', - 'src/main/sqlite', - 'src/main/orcad/orcad-entry.test.ts', - 'src/main/orcad/orcad-push-startup.test.ts', - ...(artifact - ? [ - 'src/main/daemon/pty-subprocess/bun-pty-process.integration.test.ts', - 'src/main/daemon/pty-subprocess/bun-pty-job-control.integration.test.ts', - 'src/main/daemon/pty-subprocess/bun-pty-process-suspension.test.ts', - 'src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts', - 'src/main/daemon/pty-subprocess/spawn-file-foreground-rejected-agents.test.ts', - 'tests/e2e/daemon-running-work-probe.unit.test.ts', - 'src/main/daemon/pty-subprocess/windows-bun-pty-gate.integration.test.ts', - 'src/main/providers/local-pty-bun-artifact.integration.test.ts', - 'src/main/providers/agent-foreground-process-git-bash.win32.test.ts', - 'src/main/orcad/orcad-bun-launcher.integration.test.ts', - 'config/scripts/zip-extractor-command.test.mjs' - ] - : []) - ]) + ...(testArgs.length > 0 ? testArgs : bunProfileTestPaths({ artifact })) ]) diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index cc33857ae60..21836ed6f94 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1,5 +1,42 @@ # CI efficiency and runner capacity +## Four follow-up changes + +- Keep the readiness event, but reuse required checks only after an Actions API + lookup proves that the same PR head, tested merge commit, and workflow commit + already completed successfully. A changed base, missing proof, failed lookup, + or still-running check falls back to the full checks. Advisory tests retain + their normal readiness routing. The mobile and line-count workflows have no + draft-dependent work, so they no longer run again when a draft becomes ready. +- Route the Bun matrix using the actual headless build and selected tests' + transitive imports, with conservative inclusion for dynamic workers, native + inputs, fixtures, and toolchain changes. A graph failure runs the full matrix; + manual dispatch still runs all ten platform jobs. The shared test selectors + retain the same 85 files. Unrelated shard timings and mobile-test tooling can + skip the matrix; shared shortcut definitions remain real runtime dependencies + and still run it. Building the graph does not execute the imported modules. +- Restore pnpm stores on PRs using setup-node's existing key and store path, + without publishing more PR-private copies. Non-PR setup-node caching and + native/TypeScript caches keep their existing behavior. A missing main store + still installs with the frozen lockfile. The mixed root/mobile store may miss + repeatedly because the existing main warmer only seeds the root lockfile. +- Batch only the PowerShell quota-fixture reservations within each test, using + the original generated scripts in fresh local scopes. Commands under test + retain separate processes, real file identities, and existing race assertions. + A traced local run confirms 44 PowerShell starts become 24, with all 21 cases + passing. Alternating after/before/after elapsed times were 50.00/59.28/37.00 + seconds on a shared macOS arm64 host; that variance does not justify a precise + percentage or hosted runner-time claim. Test budgets and worker counts are + unchanged. + +The reproducible pnpm-store comparison is +`ORCA_BACKGROUND_LAUNCH=1 node config/scripts/ci-pnpm-store-benchmark.mjs --samples=3`. +On macOS arm64 with BSD tar, three alternating fresh-store pairs eliminated a +median 332,746,995-byte archive per miss. Median install time was 17.33 seconds +before and 16.94 after; the removed archive step alone took 53.51 seconds. +Those local disk/CPU measurements exclude uploads and are not a prediction of +Linux or Windows hosted savings. Restore cost is common to both policies. + ## September 26 verification [PR #23053](https://github.com/stablyai/orca/pull/23053) was merged before its diff --git a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts index 8afb89228d0..a311dfcf908 100644 --- a/src/main/ssh/ssh-relay-upload-stage-commands.test.ts +++ b/src/main/ssh/ssh-relay-upload-stage-commands.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' import { cleanupOwnedRelayUploadStageCommand, parseReservedRelayUploadStage, @@ -84,6 +85,22 @@ function createStage( const reservedOwner = /^\.sftp-namespace-[0-9a-f]{32}$/u.test(stageOwner) ? stageOwner : owner const reservation = runCommand(host, reserveRelayUploadStageCommand(host, pool, reservedOwner)) expect(reservation.status, reservation.stderr).toBe(0) + return populateReservedStage( + pool, + index, + stageOwner === reservedOwner ? undefined : stageOwner, + stale, + state + ) +} + +function populateReservedStage( + pool: string, + index: number, + replacementOwner?: string, + stale = false, + state: 'slot' | 'claim' | 'delete' = 'slot' +): string { const slot = join(pool, `slot-${index}`) const stage = join(pool, `${state}-${index}`) if (stage !== slot) { @@ -91,8 +108,8 @@ function createStage( renameSync(slot, stage) } const marker = join(stage, '.orca-upload-owner') - if (stageOwner !== reservedOwner) { - writeFileSync(marker, stageOwner) + if (replacementOwner !== undefined) { + writeFileSync(marker, replacementOwner) } writeFileSync(join(stage, 'payload', 'relay.js'), `relay-${index}`) if (stale) { @@ -102,16 +119,48 @@ function createStage( return stage } +function createQuotaStages(host: RemoteHostPlatform, pool: string, count: number): void { + if (host.commandDialect !== 'powershell' || count < 2) { + for (let index = 0; index < count; index += 1) { + createStage(host, pool, index) + } + return + } + const command = reserveRelayUploadStageCommand(host, pool, owner) + // Only fixture setup shares a process; each unmodified script gets a fresh local scope. + const batch = powerShellCommand( + [ + "$ErrorActionPreference = 'Stop'", + `$fixtureScript = ${powerShellLiteral(decodePowerShellCommand(command))}`, + '$reservation = [PowerShell]::Create()', + 'try {', + `foreach ($fixtureIndex in 1..${count}) {`, + '$reservation.Commands.Clear()', + '$reservation.Streams.Error.Clear()', + '$null = $reservation.AddScript($fixtureScript, $true)', + '$reservation.Invoke()', + 'if ($reservation.InvocationStateInfo.State -ne "Completed") { throw $reservation.InvocationStateInfo.Reason }', + '}', + '} finally { $reservation.Dispose() }' + ].join('\n') + ) + const result = runCommand(host, batch) + expect(result.status, result.stderr).toBe(0) + const reservations = result.stdout.trim().split(/\r?\n/u) + expect(reservations).toHaveLength(count) + for (const [index, output] of reservations.entries()) { + expect(parseReservedRelayUploadStage(host, pool, owner, output).slotName).toBe(`slot-${index}`) + populateReservedStage(pool, index) + } +} + afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }) } }) -// Why: every case spawns a real interpreter per command, and on non-Windows hosts the PowerShell -// path also forks `/usr/bin/stat` per file-identity lookup — the 8-entry reservation alone measured -// ~50s idle, nearly all of it process-spawn sys time, and the full suite multiplies that under CPU -// contention. Sized for spawn count, not the assertions, which run in microseconds. +// Allow for real interpreter startup and file-identity probes under full-suite contention. const SPAWNED_INTERPRETER_TIMEOUT_MS = 240_000 describe.each([ @@ -126,9 +175,7 @@ describe.each([ (_label, host) => { it.each([0, 1, 7, 8, 9])('bounds reservation with %i occupied entries', (count) => { const pool = createPool() - for (let index = 0; index < Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT); index += 1) { - createStage(host, pool, index) - } + createQuotaStages(host, pool, Math.min(count, RELAY_UPLOAD_STAGE_SLOT_COUNT)) if (count > RELAY_UPLOAD_STAGE_SLOT_COUNT) { mkdirSync(join(pool, 'foreign-extra')) }