diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 73fbf9d320c..f65a9dcfd99 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -2,10 +2,10 @@ name: Install Node dependencies description: Installs the Node toolchain and repository dependencies for Linux CI jobs. inputs: - ignore-scripts: - description: Skip dependency lifecycle scripts when the job does not need native runtimes. + native-runtime: + description: Native runtime to prepare after the script-free install (none, node, or electron). required: false - default: 'false' + default: none runs: using: composite @@ -22,9 +22,22 @@ runs: node-version-file: package.json cache: pnpm + - name: Validate native runtime + shell: bash + env: + NATIVE_RUNTIME: ${{ inputs.native-runtime }} + run: | + case "$NATIVE_RUNTIME" in + none|node|electron) ;; + *) + echo "::error::native-runtime must be none, node, or electron" + exit 2 + ;; + esac + # pnpm's bundled gyp_main.py is not executable on fresh Linux runners. - name: Use external node-gyp - if: inputs.ignore-scripts != 'true' + if: inputs.native-runtime != 'none' shell: bash run: | npm install -g node-gyp@11.5.0 @@ -40,15 +53,19 @@ runs: - name: Install dependencies shell: bash - env: - IGNORE_SCRIPTS: ${{ inputs.ignore-scripts }} run: | - install_args=(--no-frozen-lockfile --prefer-frozen-lockfile=false --os=current --os=win32 --cpu=current) - if [ "$IGNORE_SCRIPTS" = "true" ]; then - install_args+=(--ignore-scripts) - elif [ "$IGNORE_SCRIPTS" != "false" ]; then - echo "::error::ignore-scripts must be true or false" - exit 2 - fi - pnpm install "${install_args[@]}" + pnpm install \ + --no-frozen-lockfile \ + --prefer-frozen-lockfile=false \ + --os=current \ + --os=win32 \ + --cpu=current \ + --ignore-scripts git diff --exit-code package.json pnpm-lock.yaml + + - name: Prepare native runtime + if: inputs.native-runtime != 'none' + shell: bash + env: + NATIVE_RUNTIME: ${{ inputs.native-runtime }} + run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9ea0ab19477..183339d2918 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,8 +24,6 @@ jobs: persist-credentials: false - uses: ./.github/actions/install-node-dependencies - with: - ignore-scripts: 'true' - name: Lint run: pnpm exec oxlint --format github @@ -73,8 +71,6 @@ jobs: persist-credentials: false - uses: ./.github/actions/install-node-dependencies - with: - ignore-scripts: 'true' - run: pnpm typecheck @@ -89,8 +85,6 @@ jobs: persist-credentials: false - uses: ./.github/actions/install-node-dependencies - with: - ignore-scripts: 'true' - name: Verify Git binary compatibility matrix run: | @@ -144,10 +138,11 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zsh - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node - name: Test real shell contracts run: | - node config/scripts/ensure-native-runtime.mjs --runtime=node pnpm exec vitest run --config config/vitest.config.ts \ src/main/daemon/shell-ready.test.ts \ src/main/daemon/node-pty-fd-leak.test.ts \ @@ -172,7 +167,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: - ignore-scripts: 'true' + native-runtime: node - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs @@ -198,10 +193,6 @@ jobs: with: persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - with: - ignore-scripts: 'true' - - name: Cache electron-builder downloads uses: actions/cache@v5 with: @@ -212,16 +203,17 @@ jobs: restore-keys: | electron-builder-linux- - # Native preparation does not touch the bundle output directories. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + - name: Build package inputs run: | - pnpm run ensure:electron-runtime & - pids=("$!") - status=0 pnpm run build:cli || status=1 scripts=(build:relay build:electron-vite:parallel) + pids=() for script in "${scripts[@]}"; do pnpm run "$script" & pids+=("$!") diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 6e0a356b167..3347e36cf50 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -26,4 +26,10 @@ describe('Electron Vite output contract', () => { expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) }) + + it('isolates renderer entry side effects behind strict facades', () => { + expect(electronViteConfig.renderer?.build?.rollupOptions?.preserveEntrySignatures).toBe( + 'strict' + ) + }) }) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 08d60969a07..1bbfc44d2e7 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -49,15 +49,18 @@ describe('PR workflow parallelism', () => { const shellStep = workflow.jobs.shell_contracts.steps.find( (step) => step.name === 'Test real shell contracts' ) + const shellInstall = workflow.jobs.shell_contracts.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false) expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe( true ) + expect(shellInstall.with['native-runtime']).toBe('node') for (const testFile of nativeShellContractFiles) { expect(shellStep.run).toContain(testFile) } - expect(shellStep.run).toContain('ensure-native-runtime.mjs --runtime=node') }) it('keeps every real-zsh test in the dedicated shell lane', () => { @@ -74,7 +77,6 @@ describe('PR workflow parallelism', () => { ) expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)') - expect(buildStep.run).toContain('pnpm run ensure:electron-runtime &') expect(buildStep.run).toContain('pnpm run "$script" &') expect( workflow.jobs.package.steps.find( @@ -94,41 +96,45 @@ describe('PR workflow parallelism', () => { expect(steps[nodeIndex].with.cache).toBe('pnpm') }) - it('skips lifecycle scripts outside native-runtime test setup', () => { - for (const jobName of [ - 'static_analysis', - 'typecheck', - 'git_compatibility', - 'test', - 'package' - ]) { - const installStep = workflow.jobs[jobName].steps.find( - (step) => step.uses === './.github/actions/install-node-dependencies' - ) - expect(installStep.with['ignore-scripts'], jobName).toBe('true') - } - - const shellInstall = workflow.jobs.shell_contracts.steps.find( + it('restores Electron downloads before preparing the package runtime', () => { + const steps = workflow.jobs.package.steps + const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads') + const installIndex = steps.findIndex( (step) => step.uses === './.github/actions/install-node-dependencies' ) - expect(shellInstall.with).toBeUndefined() + + expect(cacheIndex).toBeLessThan(installIndex) + }) + + it('prepares each native runtime before its consumers start', () => { + const installFor = (jobName) => + workflow.jobs[jobName].steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + + for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) { + expect(installFor(jobName).with, jobName).toBeUndefined() + } + expect(installFor('shell_contracts').with['native-runtime']).toBe('node') + expect(installFor('test').with['native-runtime']).toBe('node') + expect(installFor('package').with['native-runtime']).toBe('electron') + expect( dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if - ).toBe("inputs.ignore-scripts != 'true'") + ).toBe("inputs.native-runtime != 'none'") const dependencyInstall = dependencyAction.runs.steps.find( (step) => step.name === 'Install dependencies' ) - expect(dependencyInstall.env.IGNORE_SCRIPTS).toBe('${{ inputs.ignore-scripts }}') - expect(dependencyInstall.run).toContain( - '--no-frozen-lockfile --prefer-frozen-lockfile=false --os=current --os=win32 --cpu=current' - ) - expect(dependencyInstall.run).toContain('install_args+=(--ignore-scripts)') - expect(workflow.jobs.test.steps.find((step) => step.name === 'Test shard').run).not.toContain( - 'ensure-native-runtime' + expect(dependencyInstall.run).toContain('--no-frozen-lockfile') + expect(dependencyInstall.run).toContain('--ignore-scripts') + const prepareRuntime = dependencyAction.runs.steps.find( + (step) => step.name === 'Prepare native runtime' ) + expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'") + expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"') }) - it('reuses native preparation only after the concurrent runtime gate', () => { + it('reuses native preparation after the dependency action gate', () => { const buildStep = workflow.jobs.package.steps.find( (step) => step.name === 'Build package inputs' ) @@ -136,7 +142,7 @@ describe('PR workflow parallelism', () => { (step) => step.name === 'Package unpacked app' ) - expect(buildStep.run).toContain('pnpm run ensure:electron-runtime &') + expect(buildStep.run).not.toContain('ensure:electron-runtime') expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') }) diff --git a/config/scripts/project-renderer-web-client.mjs b/config/scripts/project-renderer-web-client.mjs index 6aba500330a..77516332523 100644 --- a/config/scripts/project-renderer-web-client.mjs +++ b/config/scripts/project-renderer-web-client.mjs @@ -19,6 +19,36 @@ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) const selectedFiles = new Set(['web-index.html']) const visitedEntries = new Set() +function assertEntryIsolation() { + const entryKeys = new Set( + Object.entries(manifest) + .filter(([, entry]) => entry?.isEntry === true) + .map(([key]) => key) + ) + + for (const sourceEntry of entryKeys) { + const visited = new Set() + const pending = [sourceEntry] + while (pending.length > 0) { + const key = pending.pop() + if (visited.has(key)) { + continue + } + visited.add(key) + const entry = manifest[key] + if (!entry || typeof entry !== 'object') { + throw new Error(`Renderer manifest is missing entry: ${key}`) + } + for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) { + if (entryKeys.has(dependency) && dependency !== sourceEntry) { + throw new Error(`Renderer entry ${sourceEntry} executes entry ${dependency}`) + } + pending.push(dependency) + } + } + } +} + function addOutputPath(outputPath) { if ( typeof outputPath !== 'string' || @@ -107,6 +137,7 @@ async function minifyWebOutput() { ) } +assertEntryIsolation() visitManifestEntry('web-index.html') includeReferencedOutputs() diff --git a/config/scripts/project-renderer-web-client.test.mjs b/config/scripts/project-renderer-web-client.test.mjs index 44f109ca9b9..49bc0da7c5e 100644 --- a/config/scripts/project-renderer-web-client.test.mjs +++ b/config/scripts/project-renderer-web-client.test.mjs @@ -98,4 +98,20 @@ describe('renderer web client projection', () => { expect(result.status).toBe(1) expect(result.stderr).toContain('Renderer manifest is missing entry: web-index.html') }) + + it('rejects renderer entries that execute another entry root', () => { + const root = createRendererFixture() + const manifestPath = join(root, 'out/renderer/.vite/manifest.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + manifest['web-index.html'].dynamicImports.push('index.html') + writeFileSync(manifestPath, JSON.stringify(manifest)) + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: root, + encoding: 'utf8' + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Renderer entry web-index.html executes entry index.html') + }) }) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index fa9205ac920..d5fb6ebd1dc 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -277,6 +277,9 @@ export const electronViteConfig: UserConfig = { // stay listed — overriding input otherwise drops electron-vite's default // renderer entry. rollupOptions: { + // Why: shared chunks must never import an HTML entry whose module mounts + // a different React root. + preserveEntrySignatures: 'strict', input: { index: resolve('src/renderer/index.html'), popout: resolve('src/renderer/popout.html'),