Files
orca/config/scripts/ci-unit-module-cache.mjs
T
Neil 4fc1855bda build: share one pnpm virtual store across worktrees (off in CI and on Windows) (#30254)
* build: share one pnpm virtual store across worktrees, off under CI

Each worktree carried a full node_modules; the global virtual store links
one slot per package version instead. pnpm keeps an explicit setting on
under CI, so a pnpmfile hook turns it off there: CI packages from,
caches and verifies a project-local node_modules.

* fix(build): keep shared electron and windows-process-tree dirs untouched under GVS

Every worktree runs Electron from the one shared electron package, so the
per-repo dist cache must not swap that dist and a foreign-arch install must
not land in it. The relay addon builder now repairs and compiles a private
copy instead of the shared windows-process-tree dir.

* fix(build): accept CLI runtime imports reached through an in-artifact store link

Under the global virtual store node_modules/<pkg> realpaths into a store
outside the repo. The check still rejects anything not reached through a
node_modules link inside the artifact, such as a stray ~/node_modules.

* docs: explain the global virtual store layout and the ~/node_modules hazard

* test: compare CLI closure paths with POSIX separators so the suite passes on Windows

* build: keep Windows on the normal pnpm layout unless ORCA_PNPM_GLOBAL_VIRTUAL_STORE=1

Store links are true symlinks on Windows, and installs without admin or
Developer Mode are unverified; CI still forces the normal layout first.

* fix(build): close the remaining shared-slot gaps under the global virtual store

- verify-skills-cli-runtime resolves each import from the importer's realpath,
  as Node does, so an artifact-local copy can't mask a ~/node_modules load.
- A concurrent sibling install of the shared Electron dist is kept, not swapped out.
- rebuild:node goes through ensure-native-runtime, which builds a shared
  node-pty beside the slot instead of node-gyp cleaning its build/ dir.

* fix(build): keep pnpm rebuild:node a forced rebuild

It replaced pnpm rebuild node-pty, which always rebuilt; without --force it
only rebuilt a node-pty that failed to load.

* docs: note that the store switches must stay set, since pnpm run relinks

* build: move the pnpmfile under config/ to keep the repo root allowlisted

pnpm-workspace.yaml points pnpm at it; the lockfile checksum is unchanged
because the content is.

* fix(build): enable the global virtual store from the pnpmfile, not the workspace file

pnpm exec derives NODE_PATH from the file setting, so even with the store
forced off under CI every child process got the root node_modules on its
NODE_PATH; a Windows rebuild fixture then resolved the real
@orca/windows-registry it deliberately omits. The hook now sets the store
both ways, and ORCA_PNPM_GLOBAL_VIRTUAL_STORE=0/1 is the one switch.

* test(pty): keep F3's concurrent spawns on the node-pty mock

Two concurrent first import('node-pty') calls race vitest's mock registry
and the loser loads the real addon under the mocked linux platform. It
passed only where a platform-agnostic build/Release happened to exist,
so it failed on every shared-store worktree.

* ci: fail PRs whose packages need a dependency they never declared

A new dependency_isolation job installs with the global virtual store
forced on (ORCA_PNPM_FORCE_GLOBAL_VIRTUAL_STORE_IN_CI), HOME and the store
in an empty temp dir, then runs pnpm tc and the undeclared-dependency test.
Postinstall now warns when a node_modules above the shared store, such as
~/node_modules, would mask the same class of bug locally.

* ci: set the isolation job's HOME and store through GITHUB_ENV

Job-level env cannot read the runner context, which made pr.yml invalid.
2026-10-18 20:36:24 -07:00

240 lines
7.5 KiB
JavaScript

import { createHash } from 'node:crypto'
import {
appendFileSync,
existsSync,
globSync,
readFileSync,
realpathSync,
rmSync,
statSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { pathToFileURL } from 'node:url'
import { runProcessSync } from '@orca/process-host'
import { collectNodeServerInputs } from './node-server-change-scope.mjs'
import { workspacePackageManifests } from './workspace-source-exports.mjs'
import { writeAssignment } from './ci-shard-assignment.mjs'
import { sealModuleCache, validateModuleCache } from './ci-unit-module-cache-contents.mjs'
const ROOT = resolve(import.meta.dirname, '../..')
const IDENTITY_REPORT = 'ci-shards/unit-module-cache-identity.json'
const CACHE_REPORT = 'ci-shards/unit-module-cache.json'
const CACHE_FORMAT = '1.0.0-beta.8'
function digestInputs(root, files) {
const hash = createHash('sha256')
for (const file of [...new Set(files)].sort()) {
if (!existsSync(join(root, file))) {
hash.update(`${file}\0missing\0`)
continue
}
const bytes = readFileSync(join(root, file))
hash.update(`${file}\0${bytes.length}\0`).update(bytes)
}
return hash.digest('hex')
}
export async function moduleCachePolicyHash(root = ROOT) {
const runnerScripts = globSync('config/scripts/vitest*.{mjs,ts}', { cwd: root }).filter(
(file) => !file.includes('.test.')
)
const configInputs = await collectNodeServerInputs({
root,
entryPoints: [
'config/vitest.config.ts',
'config/scripts/run-vitest.mjs',
'config/scripts/ci-unit-timing-reporter.mjs',
...runnerScripts
]
})
return digestInputs(root, [
...configInputs,
...workspacePackageManifests(root),
...globSync(['config/patches/**', '**/tsconfig*.json'], {
cwd: root,
exclude: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/out/**', 'mobile/vendor/**']
}).filter((file) => statSync(join(root, file)).isFile()),
'package.json',
'pnpm-lock.yaml',
'pnpm-workspace.yaml',
'.npmrc',
'config/pnpmfile.cjs',
'config/.bun-version',
'config/scripts/ci-unit-module-cache.mjs',
'config/scripts/ci-unit-module-cache-contents.mjs',
'config/scripts/ci-unit-module-cache-trailer.mjs'
])
}
export function moduleCacheIdentity({
root = ROOT,
cacheRoot = process.env.RUNNER_TEMP,
policyHash,
installedLockHash,
versions,
shard,
platform = process.platform,
arch = process.arch,
nodeEnv = process.env.NODE_ENV ?? 'test',
namespace = ''
}) {
if (
!cacheRoot ||
!/^[a-f0-9]{64}$/.test(policyHash ?? '') ||
!/^[a-f0-9]{64}$/.test(installedLockHash ?? '') ||
![versions?.node, versions?.bun, versions?.vitest].every((version) =>
/^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(version ?? '')
) ||
!Number.isInteger(shard?.index) ||
!Number.isInteger(shard?.count) ||
shard.index < 1 ||
shard.index > shard.count
) {
throw new Error('Unit module cache identity is incomplete')
}
const identity = {
format: CACHE_FORMAT,
root: realpathSync(root),
platform,
arch,
versions,
policyHash,
installedLockHash,
nodeEnv,
shard,
namespace
}
const digest = createHash('sha256').update(JSON.stringify(identity)).digest('hex')
return {
...identity,
key: `unit-module-v1-${platform}-${arch}-n${versions.node}-b${versions.bun}-v${versions.vitest}-s${shard.index}of${shard.count}-${digest}`,
path: join(resolve(cacheRoot), 'orca-vitest-module-cache')
}
}
export async function resolveModuleCacheIdentity({
root = ROOT,
cacheRoot = process.env.RUNNER_TEMP,
shard,
namespace = ''
} = {}) {
const require = createRequire(join(root, 'package.json'))
const bun = runProcessSync({ program: 'bun', args: ['--version'], cwd: root, timeoutMs: 10_000 })
if (bun.code !== 0) {
throw new Error('Bun version is unavailable')
}
return moduleCacheIdentity({
root,
cacheRoot,
shard,
namespace,
policyHash: await moduleCachePolicyHash(root),
installedLockHash: createHash('sha256')
.update(readFileSync(join(root, 'node_modules', '.pnpm', 'lock.yaml')))
.digest('hex'),
versions: {
node: process.versions.node,
bun: bun.stdout.trim(),
vitest: require('vitest/package.json').version
}
})
}
export async function prepareModuleCache(identity, { restoreHit = false } = {}) {
const started = performance.now()
try {
if (!restoreHit) {
throw new Error('No exact cache was restored')
}
const manifest = await validateModuleCache({
path: identity.path,
key: identity.key,
format: identity.format,
versions: identity.versions,
lockfileHash: identity.installedLockHash.slice(0, 8)
})
return {
available: true,
entries: manifest.entries.length,
bytes: manifest.totalBytes,
producerSourceSha: manifest.sourceSha,
validationMs: performance.now() - started
}
} catch (error) {
rmSync(identity.path, { recursive: true, force: true })
return {
available: false,
reason: String(error),
validationMs: performance.now() - started
}
}
}
export async function finishModuleCache(identity, { sourceSha } = {}) {
const started = performance.now()
const manifest = await sealModuleCache({
...identity,
sourceSha,
lockfileHash: identity.installedLockHash.slice(0, 8)
})
return {
entries: manifest.entries.length,
bytes: manifest.totalBytes,
sealMs: performance.now() - started
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const phase = process.argv[2]
const output = (values) => {
if (process.env.GITHUB_OUTPUT) {
for (const [key, value] of Object.entries(values)) {
appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`)
}
}
}
if (phase === 'identity') {
try {
const identity = await resolveModuleCacheIdentity({
shard: {
index: Number(process.env.ORCA_UNIT_SHARD_INDEX),
count: Number(process.env.ORCA_UNIT_SHARD_COUNT)
},
namespace: process.env.ORCA_UNIT_CACHE_NAMESPACE ?? ''
})
writeAssignment(IDENTITY_REPORT, identity)
output({ key: identity.key, path: identity.path })
if (process.env.GITHUB_ENV) {
appendFileSync(process.env.GITHUB_ENV, `ORCA_UNIT_MODULE_CACHE_PATH=${identity.path}\n`)
}
} catch (error) {
console.warn(`Unit module cache unavailable: ${String(error)}`)
}
} else {
const identity = JSON.parse(readFileSync(IDENTITY_REPORT, 'utf8'))
if (phase === 'prepare') {
const result = await prepareModuleCache(identity, {
restoreHit: process.env.ORCA_UNIT_CACHE_RESTORE_HIT === 'true'
})
writeAssignment(CACHE_REPORT, { key: identity.key, mode: 'restore', ...result })
console.log(JSON.stringify(result))
} else if (phase === 'seal') {
try {
const result = await finishModuleCache(identity, {
sourceSha: process.env.ORCA_SHARD_SOURCE_SHA ?? process.env.GITHUB_SHA
})
const previous = JSON.parse(readFileSync(CACHE_REPORT, 'utf8'))
writeAssignment(CACHE_REPORT, { ...previous, produced: result })
output({ ready: true })
console.log(JSON.stringify(result))
} catch (error) {
console.warn(`Unit module cache was not saved: ${String(error)}`)
}
} else {
throw new Error(`Unknown unit module cache phase: ${phase}`)
}
}
}