diff --git a/.github/scripts/ci/build-ci.mts b/.github/scripts/ci/build-ci.mts index fd03808f494..61be855e9f9 100644 --- a/.github/scripts/ci/build-ci.mts +++ b/.github/scripts/ci/build-ci.mts @@ -2,9 +2,9 @@ import { spawn } from 'node:child_process'; import { lstatSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; import { isAbsolute, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { ciCacheKeys } from './ci-cache-key.mts'; +import { compiledCiCacheKeys, type CompiledPackageInput } from './ci-cache-key.mts'; -export type CiBuildMode = 'lint' | 'full'; +export type CiBuildMode = 'lint' | 'full' | 'packages' | 'generators'; interface CompiledDirectory { path: string; required: readonly string[]; } export interface CiBuildTask { id: string; @@ -48,7 +48,7 @@ export function ciBuildPlan(root: string, mode: CiBuildMode): readonly CiBuildTa node('catalog', 'site/build/prepare/prepare-catalog.mts', ['packages']), node('solar', 'packages/bake/cli/prepare-solar-geometry.mts', ['catalog']), ]; - if (mode === 'full') tasks.push( + if (mode === 'full' || mode === 'generators') tasks.push( // Hashes icon sources with @cssearth/core/node, so it waits for the packages build. node('icons', 'site/build/prepare/prepare-shell-icons.mts', ['packages']), node('navigation', 'packages/bake/cli/prepare-navigation.mts', ['solar'], ['--catalog-only']), @@ -56,11 +56,16 @@ export function ciBuildPlan(root: string, mode: CiBuildMode): readonly CiBuildTa node('moon-labels', 'site/build/prepare/prepare-moon-labels.mts', ['world']), node('world-presentation', 'site/build/prepare/prepare-world-presentation.mts', ['world']), ); + // Split callers compile before restoring selected inputs. Keep the full generator closure and its + // internal ordering; only the already-completed package prerequisite leaves this phase. + if (mode === 'packages') return tasks.filter(task => task.id === 'packages'); + if (mode === 'generators') return tasks.filter(task => task.id !== 'packages') + .map(task => ({ ...task, after: task.after.filter(id => id !== 'packages') })); return tasks; } /** Receipts list only the small compiled directories, never the installed tree or restored data banks. The cache - * key already names the exact inputs; the listing proves the restore is complete: every file present, none empty, none extra. */ + * receipt names each package's exact inputs; the listing proves the restore is complete: every file present, none empty, none extra. */ function outputInventory(directory: string): string[] { const files: string[] = []; const visit = (relative: string) => { @@ -82,7 +87,7 @@ function outputInventory(directory: string): string[] { export function compiledOutputsValid(root: string, output: CompiledDirectory, digest: string): boolean { try { const directory = resolve(root, output.path), receipt: unknown = JSON.parse(readFileSync(resolve(directory, RECEIPT), 'utf8')); - if (!record(receipt) || receipt.schema !== 2 || receipt.digest !== digest || !Array.isArray(receipt.files)) return false; + if (!record(receipt) || receipt.schema !== 3 || receipt.digest !== digest || !Array.isArray(receipt.files)) return false; const files = receipt.files; if (!files.length || !files.every(safePath)) return false; if (!output.required.every(path => files.includes(path))) return false; @@ -94,7 +99,7 @@ export function compiledOutputsValid(root: string, output: CompiledDirectory, di function recordOutputs(root: string, output: CompiledDirectory, digest: string): void { const directory = resolve(root, output.path), files = outputInventory(directory); for (const path of output.required) if (!files.includes(path) || !lstatSync(resolve(directory, path)).isFile()) throw new TypeError(`Build did not produce ${output.path}/${path}`); - writeFileSync(resolve(directory, RECEIPT), JSON.stringify({ schema: 2, digest, files }) + '\n'); + writeFileSync(resolve(directory, RECEIPT), JSON.stringify({ schema: 3, digest, files }) + '\n'); } async function execute(task: CiBuildTask, root: string): Promise { @@ -105,12 +110,26 @@ async function execute(task: CiBuildTask, root: string): Promise { }); } -export async function buildCi({ root = resolve(import.meta.dirname, '../../..'), mode, cacheHit = false, digest, - packageCacheHit = cacheHit, packageDigest = digest, run = execute, -}: { root?: string; mode: CiBuildMode; cacheHit?: boolean; digest: string; packageCacheHit?: boolean; - packageDigest?: string; run?: (task: CiBuildTask, root: string) => Promise }) { - for (const identity of [digest, packageDigest]) if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(identity)) throw new TypeError(`CI build keys must be exact Git object ids (ci-cache-key.mts); got ${JSON.stringify(identity)}.`); - const plan = ciBuildPlan(root, mode), pending = new Map>(), results: { id: string; cached: boolean; seconds: number }[] = []; +/** Invalid receipts rebuild their reverse dependency closure, including when a dependency's digest matches + * but its restored files are incomplete. Explicit filters select exactly that set; pnpm orders the builds. */ +export function packageRebuildClosure(packages: readonly CompiledPackageInput[], invalid: readonly string[]): string[] { + const selected = new Set(invalid); + let changed = true; + while (changed) { + changed = false; + for (const pkg of packages) if (!selected.has(pkg.name) && pkg.dependencies.some(name => selected.has(name))) { + selected.add(pkg.name); changed = true; + } + } + return packages.filter(pkg => selected.has(pkg.name)).map(pkg => pkg.name); +} + +export async function buildCi({ root = resolve(import.meta.dirname, '../../..'), mode, packages, run = execute, +}: { root?: string; mode: CiBuildMode; packages?: readonly CompiledPackageInput[]; + run?: (task: CiBuildTask, root: string) => Promise }) { + packages ??= (await compiledCiCacheKeys({ root })).packages; + const inputs = new Map(packages.map(pkg => [`${pkg.directory}/dist`, pkg])); + const plan = ciBuildPlan(root, mode), pending = new Map>(), results: { id: string; cached: boolean; seconds: number; rebuilt?: string[] }[] = []; for (const task of plan) { const parents = task.after.map(id => { const parent = pending.get(id); @@ -119,22 +138,33 @@ export async function buildCi({ root = resolve(import.meta.dirname, '../../..'), }); pending.set(task.id, Promise.all(parents).then(async () => { const started = performance.now(); - const identity = task.id === 'packages' ? packageDigest : digest; - const hit = task.id === 'packages' ? packageCacheHit : cacheHit; - const cached = Boolean(hit && task.outputs?.length && task.outputs.every(output => compiledOutputsValid(root, output, identity))); - if (cached && task.id === 'packages') { - // The package dist cache intentionally excludes generated TS sources needed by typecheck/tests. - await run({ id: 'astronomy-data', after: [], command: process.execPath, args: ['packages/astronomy/cli/body-records.mts'] }, root); - } else if (!cached) { - await run(task, root); - for (const output of task.outputs ?? []) recordOutputs(root, output, identity); - } - const result = { id: task.id, cached, seconds: Number(((performance.now() - started) / 1000).toFixed(3)) }; + let rebuilt: string[] | undefined; + if (task.id === 'packages') { + const outputs = task.outputs ?? []; + const invalid = outputs.filter(output => { + const pkg = inputs.get(output.path); + if (!pkg) throw new TypeError(`Missing compiled input identity: ${output.path}`); + return !compiledOutputsValid(root, output, pkg.digest); + }).map(output => inputs.get(output.path)!.name); + rebuilt = packageRebuildClosure(packages, invalid); + if (rebuilt.length) { + await run({ ...task, args: ['-r', ...rebuilt.flatMap(name => ['--filter', name]), 'build'] }, root); + for (const output of outputs) { + const pkg = inputs.get(output.path)!; + if (rebuilt.includes(pkg.name)) recordOutputs(root, output, pkg.digest); + } + } + if (!rebuilt.includes('@cssearth/astronomy') && packages.some(pkg => pkg.name === '@cssearth/astronomy')) { + // Warm dist does not restore generated TS needed by compiler/test consumers. + await run({ id: 'astronomy-data', after: [], command: process.execPath, args: ['packages/astronomy/cli/body-records.mts'] }, root); + } + } else await run(task, root); + const cached = task.id === 'packages' && rebuilt?.length === 0; + const result = { id: task.id, cached, seconds: Number(((performance.now() - started) / 1000).toFixed(3)), ...(rebuilt ? { rebuilt } : {}) }; results.push(result); - console.log(`[build-ci] ${task.id}: ${cached ? 'verified exact cache' : 'ran'} ${result.seconds}s`); + console.log(`[build-ci] ${task.id}: ${cached ? 'verified package receipts' : 'ran'} ${result.seconds}s`); })); } - // Do not return with concurrent generators/compilers still writing after another task fails. const completed = await Promise.allSettled(pending.values()), failure = completed.find(result => result.status === 'rejected'); if (failure?.status === 'rejected') throw failure.reason; return results; @@ -142,16 +172,8 @@ export async function buildCi({ root = resolve(import.meta.dirname, '../../..'), if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { const mode = process.argv[2]; - if (process.argv.length !== 3 || (mode !== 'lint' && mode !== 'full')) throw new TypeError('Usage: node .github/scripts/ci/build-ci.mts lint|full'); - const flag = (name: string, fallback = false) => { - const value = process.env[name]; - if (value === undefined || value === '') return fallback; - if (value !== 'true' && value !== 'false') throw new TypeError(`${name} must be true or false.`); - return value === 'true'; - }; - const hit = flag('CI_BUILD_CACHE_HIT'); - const digest = process.env.CI_BUILD_DIGEST || ciCacheKeys().buildDigest; - const started = performance.now(), results = await buildCi({ mode, digest, cacheHit: hit, - packageCacheHit: flag('CI_PACKAGE_CACHE_HIT', hit), packageDigest: process.env.CI_PACKAGE_DIGEST || digest }); - console.log(JSON.stringify({ mode, seconds: Number(((performance.now() - started) / 1000).toFixed(3)), compiledCacheHits: results.filter(result => result.cached).length })); + if (process.argv.length !== 3 || (mode !== 'lint' && mode !== 'full' && mode !== 'packages' && mode !== 'generators')) + throw new TypeError('Usage: node .github/scripts/ci/build-ci.mts lint|full|packages|generators'); + const started = performance.now(), results = await buildCi({ mode }); + console.log(JSON.stringify({ mode, seconds: Number(((performance.now() - started) / 1000).toFixed(3)), compiledCacheHits: results.filter(result => result.cached).length, results })); } diff --git a/.github/scripts/ci/check-ci.mts b/.github/scripts/ci/check-ci.mts index f158fdb2e61..e8dc0b529ae 100644 --- a/.github/scripts/ci/check-ci.mts +++ b/.github/scripts/ci/check-ci.mts @@ -9,6 +9,7 @@ import {spawn} from 'node:child_process'; import {parse} from 'yaml'; import {requireArray, requireRecord, requireString} from '@cssearth/core'; import {affectedJobNames, ALWAYS_JOBS, classifyAffectedPaths, HEAVY_JOBS, loadCiAreasConfig, localChangedPaths, needsProductionBuild} from './ci-affected.mts'; +import { universeTestMatrix } from './test-shards.mts'; import {evaluateObjectScopeGate} from './object-scope-gate.mts'; interface CiStep {name:string;run:string;env:Record;} @@ -74,18 +75,21 @@ export function readCiSteps(source:string,jobName='universe', substitutions:Reco if(job.strategy!==undefined){ const strategy=requireRecord(job.strategy),matrix=requireRecord(strategy.matrix); if(!supported||Object.keys(strategy).sort().join(',')!=='fail-fast,matrix'||strategy['fail-fast']!==false|| - Object.keys(matrix).join(',')!=='lane'||job['continue-on-error']!==undefined|| + (supported.job==='universe-checks' ? Object.keys(matrix).join(',')!=='include'||matrix.include!=='${{ fromJSON(needs.changes.outputs.test_matrix).include }}' : Object.keys(matrix).join(',')!=='lane')||job['continue-on-error']!==undefined|| job.needs!=='changes'||requireString(job.if).trim().replace(/\s+/gu,' ')!=='${{ '+selectedMatrixCondition(supported)+' }}'|| requireRecord(job.env)[supported.laneEnv]!==MATRIX_LANE) throw new Error('Local CI supports only the explicit maintained lane matrices with fail-fast disabled.'); - const lanes=requireArray(matrix.lane).map(value=>requireString(value)); + const rows=supported.job==='universe-checks' + ? universeTestMatrix(resolve(import.meta.dirname,'../../..'),{packages:substitutions['${{ needs.changes.outputs.test_packages }}']??'all',files:substitutions['${{ needs.changes.outputs.test_files }}']??''}) + : requireArray(matrix.lane).map(value=>({lane:requireString(value),shard:0,total:0})); + const lanes=rows.map(row=>row.shard?`${row.lane}-${row.shard}`:row.lane); if(!lanes.length||new Set(lanes).size!==lanes.length||lanes.some(lane=>!/^[a-z][a-z0-9-]*$/u.test(lane))) throw new Error('Local CI needs unique, nonempty literal matrix lanes.'); - return lanes.flatMap(lane=>{ - const steps=readJobSteps(workflow,job,{...substitutions,[MATRIX_LANE]:lane}); + return rows.flatMap(({lane,shard,total})=>{ + const steps=readJobSteps(workflow,job,{...substitutions,[MATRIX_LANE]:lane,'${{ matrix.shard }}':String(shard),'${{ matrix.total }}':String(total)}); if(!steps.length||steps.some(step=>step.env[supported.laneEnv]!==lane)) throw new Error('Local CI needs executable commands for every matrix lane without lane overrides.'); - return steps.map(step=>({...step,name:`[${lane}] ${step.name}`})); + return steps.map(step=>({...step,name:`[${lane}${shard?` ${shard}/${total}`:''}] ${step.name}`})); }); } if(supported)throw new Error('Local CI needs the maintained lane matrix, not a single replacement job.'); @@ -111,8 +115,9 @@ function readJobSteps(workflow:Record,job:Record // failed prerequisite to report and no parallel job to cancel. if(step.if!==undefined&&CI_ONLY_CONDITIONS.includes(String(step.if).trim()))return []; const cacheGated=step.if!==undefined&&CACHE_HIT_CONDITION.test(String(step.if).trim()); + const laneSelected=step.if==="env.CI_LANE_SKIP != 'true'"&&inherited.CI_LANE_SKIP==='false'; const alwaysRun=step.if!==undefined&&ALWAYS_RUN_CONDITION.test(String(step.if).trim()); - if((step.if!==undefined&&!cacheGated&&!alwaysRun)||step['working-directory']!==undefined||step['continue-on-error']!==undefined||step.shell!==undefined) + if((step.if!==undefined&&!cacheGated&&!alwaysRun&&!laneSelected)||step['working-directory']!==undefined||step['continue-on-error']!==undefined||step.shell!==undefined) throw new Error('Local CI needs explicit support for this step execution policy.'); const result={name:requireString(step.name),run:requireString(step.run),env:{...inherited,...decodeEnvironment(step.env)}}; if(JSON.stringify(result).includes('${{'))throw new Error('Local CI cannot evaluate GitHub expressions.'); diff --git a/.github/scripts/ci/ci-cache-key.mts b/.github/scripts/ci/ci-cache-key.mts index d07bacbddf2..a29b5df9a78 100644 --- a/.github/scripts/ci/ci-cache-key.mts +++ b/.github/scripts/ci/ci-cache-key.mts @@ -4,7 +4,7 @@ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path import { pathToFileURL } from 'node:url'; const CACHE_FORMAT = 'cssearth-ci-inputs@2'; -const BUILD_ENVIRONMENT = ['ASSET_ORIGIN', 'CSSEARTH_PERFORMANCE_SOURCEMAPS', 'NODE_ENV'] as const; +const BUILD_ENVIRONMENT = ['ASSET_ORIGIN', 'CSSEARTH_PERFORMANCE_SOURCEMAPS', 'NODE_ENV', 'CSSEARTH_SKIP_DECLARATIONS'] as const; export interface CacheRuntime { node: string; @@ -82,9 +82,9 @@ function gitKey(root: string, listing: string): string { * outputs never enter the key. Cache consumers must use an exact key and install the frozen lockfile before using its outputs. */ export function ciCacheKeys({ root = resolve(import.meta.dirname, '../../..'), runtime = { node: process.versions.node, platform: process.platform, arch: process.arch, environment: process.env, -} }: { root?: string; runtime?: CacheRuntime } = {}) { +}, inputs }: { root?: string; runtime?: CacheRuntime; inputs?: (path: string) => boolean } = {}) { root = realpathSync(root); - const entries = trackedCacheEntries(root), paths = new Set(entries.map(entry => entry.path)); + const entries = trackedCacheEntries(root).filter(entry => !inputs || inputs(entry.path)), paths = new Set(entries.map(entry => entry.path)); const identity = JSON.stringify([CACHE_FORMAT, toolchain(root, runtime)]); let bytes = 0, missing = 0, configFiles = 0; const states: (readonly unknown[])[] = [], files: { index: number; path: string; executable: boolean }[] = []; @@ -132,137 +132,151 @@ export function ciCacheKeys({ root = resolve(import.meta.dirname, '../../..'), r return { buildDigest: gitKey(root, build), tsconfigDigest: gitKey(root, typecheck), files: entries.length, bytes, missing, configFiles }; } -// The package key covers every tracked file under packages/. The two non-tsup builds read only -// package-local files (astronomy's body-records reads packages/astronomy/data; telescope-cli bundles its command), -// so a change to their scripts or data changes the key without a separate input list to keep current. -const BUILD_CONFIG_FIELDS = new Set(['entry', 'outDir', 'tsconfig', 'format', 'external', 'dts', 'sourcemap', 'clean', 'target', 'splitting']); +export interface CompiledPackageInput { + name: string; + directory: string; + dependencies: string[]; + digest: string; +} + +const canonical = (value: unknown): string => JSON.stringify(value, (_key, child: unknown) => + isRecord(child) ? Object.fromEntries(Object.entries(child).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : child); + +/** Select the frozen external dependency closure, including root build tools and peer-qualified snapshots. + * Unrelated workspace importers are excluded; workspace sources are covered by package digests instead. */ +function lockInputs(lock: unknown, directory: string): unknown { + if (!isRecord(lock) || !isRecord(lock.importers) || !isRecord(lock.packages) || !isRecord(lock.snapshots)) + throw new TypeError('Compiled caches require a pnpm v9 lockfile.'); + const snapshots = lock.snapshots, lockedPackages = lock.packages, lockedImporters = lock.importers; + const selected = new Map(), metadata = new Map(); + const visit = (name: string, version: unknown): void => { + if (typeof version !== 'string') throw new TypeError(`Invalid locked dependency: ${name}`); + if (version.startsWith('link:')) return; + const key = `${name}@${version}`; + if (selected.has(key)) return; + const snapshot: unknown = snapshots[key]; + if (!isRecord(snapshot)) throw new TypeError(`Missing locked snapshot: ${key}`); + selected.set(key, snapshot); + const base = key.split('(')[0]!; + if (!isRecord(lockedPackages[base])) throw new TypeError(`Missing locked package: ${base}`); + metadata.set(base, lockedPackages[base]); + for (const field of ['dependencies', 'optionalDependencies']) { + const dependencies = snapshot[field]; + if (dependencies !== undefined && !isRecord(dependencies)) throw new TypeError(`Invalid snapshot: ${key}`); + for (const [child, resolved] of Object.entries(dependencies ?? {})) visit(child, resolved); + } + }; + const importers = ['.', directory].map(path => { + const importer: unknown = lockedImporters[path]; + if (!isRecord(importer)) throw new TypeError(`Missing locked importer: ${path}`); + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const dependencies = importer[field]; + if (dependencies !== undefined && !isRecord(dependencies)) throw new TypeError(`Invalid importer: ${path}`); + for (const [name, dependency] of Object.entries(dependencies ?? {})) { + if (!isRecord(dependency)) throw new TypeError(`Invalid locked dependency: ${name}`); + visit(name, dependency.version); + } + } + return [path, importer]; + }); + return { policy: Object.fromEntries(Object.entries(lock).filter(([key]) => !['importers', 'packages', 'snapshots'].includes(key))), + importers, packages: Object.fromEntries(metadata), snapshots: Object.fromEntries(selected) }; +} -/** Requires the frozen install, but never scans installed directories. Compiler APIs resolve actual imports; - * package dist imports stand for the package source digest, so cold and restored checkouts use the same key. */ +/** Hash authored bytes, never execute a build config. A package's complete tracked tree is the conservative + * boundary for plugins, generated entry lists and dynamic imports. Literal authored imports extend that boundary. + * Workspace dependency digests propagate topologically; no failure falls back to application/docs inputs. */ export async function compiledCiCacheKeys({ root = resolve(import.meta.dirname, '../../..'), runtime = { node: process.versions.node, platform: process.platform, arch: process.arch, environment: process.env, } }: { root?: string; runtime?: CacheRuntime } = {}) { root = realpathSync(root); - const full = ciCacheKeys({ root, runtime }), tracked = new Set(trackedCacheEntries(root).map(entry => entry.path)); - const compiler = (await import('typescript')).default, { build } = await import('esbuild'); - const identity = JSON.stringify(['compiled-inputs@1', toolchain(root, runtime)]); - const shared = [...tracked].filter(path => isTypecheckCacheInput(path) || ['.github/scripts/ci/build-ci.mts', '.github/scripts/ci/ci-cache-key.mts'].includes(path)); - const packageInputs = new Set([...shared, ...[...tracked].filter(path => path.startsWith('packages/'))]); - const packageManifests = [...tracked].filter(path => /^packages\/[^/]+\/package\.json$/u.test(path)); - if (!packageManifests.length) throw new TypeError('No tracked package manifests for the compiled cache.'); - const packages = packageManifests.map(path => { - const value: unknown = JSON.parse(readFileSync(resolve(root, path), 'utf8')); - if (!isRecord(value) || typeof value.name !== 'string' || !isRecord(value.scripts) || typeof value.scripts.build !== 'string') throw new TypeError(`Invalid build package: ${path}`); - return { directory: dirname(path), name: value.name, script: value.scripts.build }; - }); - const packageNames = packages.map(pkg => pkg.name), fallbackReasons: string[] = []; - const normalizedOptions = (value: unknown): unknown => { - if (typeof value === 'string' && isAbsolute(value)) { - const local = relative(root, value).split(sep).join('/'); - return local !== '..' && !local.startsWith('../') ? `./${local}` : value; - } - if (Array.isArray(value)) return value.map(normalizedOptions); - if (isRecord(value)) return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, child]) => [key, normalizedOptions(child)])); - if (typeof value === 'function' || typeof value === 'symbol') throw new TypeError('Build configuration contains executable option values.'); - return value; - }; - const generatedAstronomy = (path: string) => path.startsWith('packages/astronomy/src/data/generated/'); - const compiledPackage = (path: string) => packages.some(pkg => path.startsWith(`${pkg.directory}/dist/`)); - const input = (absolute: string, selected: Set) => { - const lexical = relative(root, absolute).split(sep).join('/'); - if (compiledPackage(lexical) || generatedAstronomy(lexical)) return; - const real = existsSync(absolute) ? realpathSync(absolute) : absolute; - const local = relative(root, real).split(sep).join('/'); - if (compiledPackage(local) || generatedAstronomy(local)) return; - if (tracked.has(local)) { selected.add(local); return; } - // Frozen dependencies include compiler standard libraries. Resolve workspace symlinks before this check. - if (real.split(sep).includes('node_modules')) return; - throw new TypeError(`Untracked or missing authored compiler input: ${local}`); - }; - const closure = async (directory: string, configPath: string | null, selected: Set, options: string[]) => { - let entries: string[], config: Record; - const cwd = resolve(root, directory); - if (configPath) { - input(configPath, selected); - const syntax = compiler.createSourceFile(configPath, readFileSync(configPath, 'utf8'), compiler.ScriptTarget.Latest, true); - const imports = compiler.preProcessFile(syntax.text, true, true).importedFiles.map(entry => entry.fileName); - // Current configs are plain tsup options plus node:path/node:url. Custom loaders/plugins or imported - // configuration modules need their own read contract; do not guess their hidden filesystem inputs. - if (imports.some(name => !['tsup', 'node:path', 'node:url'].includes(name))) throw new TypeError(`Unsupported build configuration imports: ${configPath}`); - const loaded: unknown = await import(`${pathToFileURL(configPath).href}?ci-input=${gitBlobIds(root, [relative(root, configPath)])[0]}`); - if (!isRecord(loaded) || !isRecord(loaded.default)) throw new TypeError(`Expected plain tsup options: ${configPath}`); - config = loaded.default; - if (Object.keys(config).some(key => !BUILD_CONFIG_FIELDS.has(key))) throw new TypeError(`Unsupported build configuration options: ${configPath}`); - const raw = Array.isArray(config.entry) ? config.entry : isRecord(config.entry) ? Object.values(config.entry) : []; - if (!raw.length || !raw.every((entry): entry is string => typeof entry === 'string')) throw new TypeError(`Invalid compiler entry points: ${configPath}`); - entries = raw.map(entry => resolve(cwd, entry)); - } else { - // The audited telescope builder has one entry and no custom loaders or generation. - config = {}; - entries = [resolve(cwd, 'src/bin.mts')]; + const full = ciCacheKeys({ root, runtime, inputs: isTypecheckCacheInput }), entries = trackedCacheEntries(root); + const tracked = new Set(entries.map(entry => entry.path)); + const compiler = (await import('typescript')).default; + const { parse } = await import('yaml'); + const lock: unknown = parse(readFileSync(resolve(root, 'pnpm-lock.yaml'), 'utf8')); + const identity = canonical(['compiled-inputs@3', toolchain(root, runtime)]); + const shared = entries.filter(({ path }) => !path.includes('/') && path !== 'pnpm-lock.yaml' && isTypecheckCacheInput(path) || + ['.github/scripts/ci/build-ci.mts', '.github/scripts/ci/ci-cache-key.mts'].includes(path)).map(entry => entry.path); + const packages = entries.filter(({ path }) => /^packages\/[^/]+\/package\.json$/u.test(path)).map(({ path }) => { + const manifest: unknown = JSON.parse(readFileSync(resolve(root, path), 'utf8')); + if (!isRecord(manifest) || typeof manifest.name !== 'string' || !isRecord(manifest.scripts) || typeof manifest.scripts.build !== 'string') + throw new TypeError(`Invalid build package: ${path}`); + const dependencies = new Set(); + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + const values = manifest[field]; + if (values !== undefined && !isRecord(values)) throw new TypeError(`Invalid dependencies: ${path}`); + for (const [name, version] of Object.entries(values ?? {})) { + if (typeof version !== 'string') throw new TypeError(`Invalid dependency: ${path}/${name}`); + if (version.startsWith('workspace:')) dependencies.add(name); + } } - options.push(JSON.stringify([directory, normalizedOptions(config)])); - const tsconfig = typeof config.tsconfig === 'string' ? resolve(cwd, config.tsconfig) : resolve(cwd, 'tsconfig.json'); - const read = (path: string) => { input(path, selected); return compiler.sys.readFile(path); }; - const parsedSource = compiler.readConfigFile(tsconfig, read); - if (parsedSource.error) throw new TypeError(compiler.flattenDiagnosticMessageText(parsedSource.error.messageText, '\n')); - const parsed = compiler.parseJsonConfigFileContent(parsedSource.config, { ...compiler.sys, readFile: read }, dirname(tsconfig)); - if (parsed.errors.length) throw new TypeError(compiler.formatDiagnostics(parsed.errors, { getCanonicalFileName: value => value, getCurrentDirectory: () => root, getNewLine: () => '\n' })); - const ambient = parsed.fileNames.filter(path => /\.d\.[cm]?ts$/u.test(path)); - const program = compiler.createProgram([...entries, ...ambient], { ...parsed.options, noEmit: true, incremental: false }); - const workspaceImport = (specifier: string) => packageNames.some(name => specifier === name || specifier.startsWith(`${name}/`)); - for (const source of program.getSourceFiles()) { - input(source.fileName, selected); - const actual = realpathSync(source.fileName), local = relative(root, actual).split(sep).join('/'); - if (actual.split(sep).includes('node_modules') || compiledPackage(local) || generatedAstronomy(local)) continue; - for (const reference of compiler.preProcessFile(source.text, true, true).importedFiles) { - const specifier = reference.fileName; + return { directory: dirname(path), name: manifest.name, dependencies, selected: new Set(shared) }; + }); + if (!packages.length || new Set(packages.map(pkg => pkg.name)).size !== packages.length) throw new TypeError('Invalid compiled workspace packages.'); + const owners = new Map(packages.map(pkg => [pkg.name, pkg])); + const fallbackReasons: string[] = []; + for (const pkg of packages) { + for (const { path } of entries) if (path.startsWith(`${pkg.directory}/`)) pkg.selected.add(path); + const queue = [...pkg.selected], visited = new Set(); + while (queue.length) { + const path = queue.pop()!; + if (visited.has(path)) continue; + visited.add(path); + if (!/\.[cm]?[jt]sx?$/u.test(path) || /\.test\./u.test(path) || !existsSync(resolve(root, path))) continue; + const text = readFileSync(resolve(root, path), 'utf8'); + for (const { fileName: specifier } of compiler.preProcessFile(text, true, true).importedFiles) { + const owner = packages.find(candidate => specifier === candidate.name || specifier.startsWith(`${candidate.name}/`)); + if (owner && owner.name !== pkg.name) { pkg.dependencies.add(owner.name); continue; } if (!specifier.startsWith('.')) continue; - const destination = relative(root, resolve(dirname(source.fileName), specifier)).split(sep).join('/'); - if (generatedAstronomy(destination) || compiledPackage(destination)) continue; - if (!compiler.resolveModuleName(specifier, source.fileName, parsed.options, compiler.sys).resolvedModule) - throw new TypeError(`Unresolved authored compiler input: ${source.fileName} → ${specifier}`); + const base = resolve(dirname(resolve(root, path)), specifier); + const stem = base.replace(/\.[cm]?js$/u, ''); + const candidate = [base, ...['.ts', '.mts', '.cts', '.tsx', '/index.ts', '/index.mts'].map(suffix => stem + suffix)] + .find(file => tracked.has(relativeInside(root, file))) ?? base; + const local = relativeInside(root, candidate); + const relativeOwner = packages.find(other => local.startsWith(`${other.directory}/`)); + if (relativeOwner && relativeOwner.name !== pkg.name) { pkg.dependencies.add(relativeOwner.name); continue; } + if (!tracked.has(local)) { + // Generated sources/dist and unresolved literals stay within the complete owning-package boundary. + fallbackReasons.push(`${pkg.name}: package-local boundary for ${path} → ${specifier}`); + continue; + } + if (!pkg.selected.has(local)) { pkg.selected.add(local); queue.push(local); } } } - const external = config.external ?? []; - if (!Array.isArray(external) || !external.every((value): value is string => typeof value === 'string')) throw new TypeError('Invalid compiler externals.'); - const result = await build({ absWorkingDir: root, entryPoints: entries, bundle: true, write: false, metafile: true, - platform: 'node', format: 'esm', target: 'es2022', tsconfig, outdir: resolve(root, 'output/cache-input-analysis'), - external: [...external, ...packageNames], logLevel: 'silent', plugins: [{ name: 'compiled-input-owners', setup(builder) { - builder.onResolve({ filter: /./ }, args => { - if (workspaceImport(args.path)) return { path: args.path, external: true }; - const local = relative(root, resolve(args.resolveDir, args.path)).split(sep).join('/'); - if (generatedAstronomy(local) || compiledPackage(local)) return { path: args.path, external: true }; - return undefined; - }); - } }] }); - for (const path of Object.keys(result.metafile!.inputs)) input(resolve(root, path), selected); + } + const allInputs = new Set(packages.flatMap(pkg => [...pkg.selected])); + const files = [...allInputs].filter(path => existsSync(resolve(root, path)) && lstatSync(resolve(root, path)).isFile()); + const ids = new Map(gitBlobIds(root, files).map((id, index) => [files[index]!, id])); + const inputState = (path: string): unknown => { + const file = resolve(root, path); + if (!existsSync(file)) return [path, 'deleted']; + const info = lstatSync(file); + if (info.isSymbolicLink()) { + const target = relativeInside(root, realpathSync(file)); + if (!tracked.has(target)) throw new TypeError(`Untracked package symlink target: ${path}`); + return [path, 'symlink', readlinkSync(file), gitBlobIds(root, [target])[0]]; + } + return [path, info.mode & 0o111, ids.get(path)]; }; - const narrowKey = (selected: ReadonlySet, options: readonly string[], upstream = '') => { - const sorted = [...selected].sort(), files: string[] = []; - const states = sorted.map((path): readonly unknown[] => { - const absolute = resolve(root, path), info = lstatSync(absolute); - // Package documentation symlinks point to tracked owners already covered by broad package/config inputs. - if (info.isSymbolicLink()) return ['symlink', readlinkSync(absolute)]; - files.push(path); - return ['file', info.mode & 0o111]; - }); - const ids = new Map(gitBlobIds(root, files).map((id, index) => [files[index]!, id])); - const lines = sorted.map((path, index) => JSON.stringify([path, ...states[index]!, ids.get(path) ?? null]) + '\n'); - return gitKey(root, `${identity}\n${upstream}\n${JSON.stringify(options)}\n${lines.join('')}`); + const finished = new Map(), visiting = new Set(); + const digestPackage = (name: string): CompiledPackageInput => { + const ready = finished.get(name); + if (ready) return ready; + const pkg = owners.get(name); + if (!pkg) throw new TypeError(`Workspace build dependency outside packages: ${name}`); + if (visiting.has(name)) throw new TypeError(`Cyclic compiled workspace dependency: ${name}`); + visiting.add(name); + const dependencies = [...pkg.dependencies].sort(); + const upstream = dependencies.map(dependency => [dependency, digestPackage(dependency).digest]); + const digest = gitKey(root, canonical([identity, [...pkg.selected].sort().map(inputState), lockInputs(lock, pkg.directory), upstream])); + const result = { name, directory: pkg.directory, dependencies, digest }; + finished.set(name, result); visiting.delete(name); + return result; }; - const packageOptions: string[] = []; - let packageDigest = full.buildDigest; - try { - for (const pkg of packages) { - const supported = pkg.script === 'tsup' || pkg.directory === 'packages/astronomy' && pkg.script === 'node cli/body-records.mts && tsup' || pkg.directory === 'packages/telescope-cli' && pkg.script === 'node build.mts'; - if (!supported) throw new TypeError(`Unaudited package build: ${pkg.directory}`); - const config = pkg.directory === 'packages/telescope-cli' ? null : resolve(root, pkg.directory, 'tsup.config.ts'); - await closure(pkg.directory, config, packageInputs, packageOptions); - } - packageDigest = narrowKey(packageInputs, packageOptions); - } catch (error) { fallbackReasons.push(`packages: ${error instanceof Error ? error.message : String(error)}`); } - return { ...full, packageDigest, packageInputFiles: packageInputs.size, fallbackReasons }; + const packageInputs = packages.map(pkg => digestPackage(pkg.name)); + const packageDigest = gitKey(root, canonical(packageInputs.map(pkg => [pkg.name, pkg.digest]))); + return { ...full, packageDigest, packages: packageInputs, packageInputFiles: allInputs.size, fallbackReasons: [...new Set(fallbackReasons)] }; } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { diff --git a/.github/scripts/ci/ci-speed.test.mts b/.github/scripts/ci/ci-speed.test.mts new file mode 100644 index 00000000000..395f1502afa --- /dev/null +++ b/.github/scripts/ci/ci-speed.test.mts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { parse } from 'yaml'; +import { requireArray, requireRecord, requireString } from '@cssearth/core'; +import { buildCi, ciBuildPlan } from './build-ci.mts'; +import { readCiSteps } from './check-ci.mts'; + +const root = resolve(import.meta.dirname, '../../..'); +const source = readFileSync(resolve(root, '.github/workflows/universe.yml'), 'utf8'); +const jobs = requireRecord(requireRecord(parse(source)).jobs); +const lane = requireRecord(jobs['universe-checks']); +const steps = requireArray(lane.steps).map(value => requireRecord(value)); +const selected = "env.CI_LANE_SKIP != 'true'"; +const testRun = requireString(steps.find(step => step.name === 'Test the selected folder lane')?.run); + +function assertSkipGuards(values: readonly Record[]) { + for (const step of values) { + if (step.uses !== undefined || step.id === 'ci-cache-key') { + assert.ok(requireString(step.if).includes(selected), String(step.name ?? step.uses)); + } else { + assert.match(requireString(step.run), /^if \[ "\$CI_LANE_SKIP" = true \]; then .*exit 0; fi/mu); + } + } +} + +function shell(run: string, env: Record) { + // Execute the workflow's actual Bash selection, while commands record calls instead of building, + // downloading, expanding this checkout or running application tests. + const result = spawnSync('bash', ['-e', '-o', 'pipefail', '-c', ` +node() { printf 'CALL node %s\\n' "$*"; } +pnpm() { printf 'CALL pnpm %s\\n' "$*"; } +git() { printf 'CALL git %s\\n' "$*" >&2; } +${run}`], { env: { ...process.env, CI_TEST_FILES: '', ...env }, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout + result.stderr; +} + +function assertVolumes(run: string) { + for (const packages of ['bake', 'objects', 'volume-viewer', 'all', 'core bake renderer', 'objects core']) { + const output = shell(run, { CI_UNIVERSE_LANE: 'packages', CI_TEST_PACKAGES: packages, CI_LANE_SKIP: 'false' }); + assert.match(output, /CALL node packages\/bake\/cli\/restore-source-inputs.mts --repository-volumes/u, packages); + assert.match(output, /CALL git sparse-checkout add --stdin/u, packages); + assert.match(output, /CALL git sparse-checkout add \/src\/objects\/\*\*\/\*\.shp/u, packages); + assert.match(output, /CALL node \.github\/scripts\/ci\/test-shards\.mts --run/u); + } + for (const [lane, packages] of [['packages', 'core renderer'], ['packages', 'baker'], ['site', 'all']]) { + const output = shell(run, { CI_UNIVERSE_LANE: lane!, CI_TEST_PACKAGES: packages!, CI_LANE_SKIP: 'false' }); + assert.doesNotMatch(output, /repository-volumes|CALL git/u); + assert.match(output, /CALL node \.github\/scripts\/ci\/test-shards\.mts --run/u); + } +} + +test('skipped lanes retain successful jobs without checkout, caches or executable tooling', () => { + assertSkipGuards(steps); + assert.equal(requireRecord(requireRecord(lane.strategy).matrix).include, '${{ fromJSON(needs.changes.outputs.test_matrix).include }}'); + for (const name of ['packages', 'site']) { + for (const step of steps.filter(step => step.run !== undefined && step.if === undefined)) { + assert.doesNotMatch(shell(requireString(step.run), { CI_LANE_SKIP: 'true', CI_UNIVERSE_LANE: name }), /CALL /u); + } + } + for (const [index, step] of steps.entries()) { + const mutation = steps.map((value, at) => at === index ? { ...value, if: undefined, + run: value.run === undefined ? undefined : requireString(value.run).replace(/^if \[ "\$CI_LANE_SKIP" = true \]; then .*exit 0; fi\n/mu, '') } : value); + assert.throws(() => assertSkipGuards(mutation), Error, String(step.name ?? step.uses)); + } +}); + +test('volume restore and sparse expansion follow exact selected package tokens; mutations turn red', () => { + assertVolumes(testRun); + assert.throws(() => assertVolumes(testRun.replace('*" bake "*|', ''))); + assert.throws(() => assertVolumes(testRun.replace('node packages/bake/cli/restore-source-inputs.mts --repository-volumes', ':'))); + assert.throws(() => assertVolumes(testRun.replace('case " $CI_TEST_PACKAGES " in', 'case " all " in'))); +}); + +function assertOrdering(text: string) { + const commands = readCiSteps(text, 'universe'); + for (const name of ['packages', 'site']) { + const run = commands.filter(step => step.env.CI_UNIVERSE_LANE === name && step.env.CI_TEST_SHARD === '1').map(step => step.run).join('\n'); + assert.equal([...run.matchAll(/build-ci\.mts packages/gu)].length, 1); + assert.equal([...run.matchAll(/build-ci\.mts generators/gu)].length, 1); + assert.doesNotMatch(run, /build-ci\.mts full/u); + assert.ok(run.indexOf('build-ci.mts packages') < run.indexOf('prepare-ci-inputs.mts universe')); + assert.ok(run.indexOf('prepare-ci-inputs.mts universe') < run.indexOf('build-ci.mts generators')); + assert.ok(run.indexOf('setup:asset-data') < run.indexOf('build-ci.mts generators')); + assert.ok(run.indexOf('build-ci.mts generators') < run.indexOf('prepare-feature-index.mts')); + } + const cache = text.indexOf(' id: prepared-cache'); + assert.ok(cache < text.indexOf('build-ci.mts packages')); +} + +test('universe compiles before input restoration and generates exactly once after it', () => { + assertOrdering(source); + assert.throws(() => assertOrdering(source.replace('build-ci.mts packages', 'build-ci.mts full'))); + assert.throws(() => assertOrdering(source.replace(' node .github/scripts/ci/build-ci.mts generators', ' node .github/scripts/ci/build-ci.mts generators\n node .github/scripts/ci/build-ci.mts generators'))); + for (const job of ['typecheck', 'typecheck-tests', 'universe-preparation-checks']) { + assert.equal(readCiSteps(source, job).filter(step => step.run.includes('build-ci.mts full')).length, 1); + } +}); + +test('split build preserves every full command and generator dependency, with packages completed first', async () => { + const full = ciBuildPlan(root, 'full'), packages = ciBuildPlan(root, 'packages'), generators = ciBuildPlan(root, 'generators'); + assert.deepEqual(full.map(task => task.id), ['packages', 'titles', 'catalog', 'solar', 'icons', 'navigation', 'world', 'moon-labels', 'world-presentation']); + assert.deepEqual(packages, full.filter(task => task.id === 'packages')); + assert.deepEqual(generators, full.filter(task => task.id !== 'packages').map(task => ({ ...task, after: task.after.filter(id => id !== 'packages') }))); + const completed = new Set(); + await buildCi({ root, mode: 'generators', run: async task => { + assert.ok(task.after.every(id => completed.has(id)), task.id); + completed.add(task.id); + } }); + assert.deepEqual([...completed].sort(), generators.map(task => task.id).sort()); +}); + +test('required aggregate succeeds for successful lanes and fails for any failed lane', () => { + const aggregate = requireRecord(jobs.universe); + assert.equal(aggregate.name, 'Prepared universe and shared renderer'); + assert.deepEqual(aggregate.needs, ['changes', 'universe-checks']); + const step = requireRecord(requireArray(aggregate.steps)[0]); + assert.equal(requireRecord(step.env).RESULT, '${{ needs.universe-checks.result }}'); + for (const result of ['success', 'failure', 'cancelled', 'skipped']) { + const command = spawnSync('bash', ['-e', '-c', requireString(step.run)], { env: { ...process.env, RESULT: result } }); + assert.equal(command.status === 0, result === 'success'); + } + const broken = source.replace('run: test "$RESULT" = success', 'run: true'); + assert.throws(() => readCiSteps(broken, 'universe'), /fail-closed/u); +}); + +test('safety timeouts retain twice the supplied observed durations', () => { + for (const [job, minutes, seconds] of [['typecheck', 12, 291], ['typecheck-tests', 12, 249], + ['universe-preparation-checks', 10, 210], ['nebula', 10, 255], ['astroquery', 12, 0]] as const) { + assert.equal(requireRecord(jobs[job])['timeout-minutes'], minutes); + assert.ok(minutes * 60 >= seconds * 2); + } +}); diff --git a/.github/scripts/ci/compiled-packages.test.mts b/.github/scripts/ci/compiled-packages.test.mts new file mode 100644 index 00000000000..907dce5e590 --- /dev/null +++ b/.github/scripts/ci/compiled-packages.test.mts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { test, type TestContext } from 'node:test'; +import { parse } from 'yaml'; +import type { CacheRuntime } from './ci-cache-key.mts'; +import type { CiBuildTask } from './build-ci.mts'; + +const moduleRoot = process.env.CI_CACHE_TEST_MODULES ?? import.meta.dirname; +const { compiledCiCacheKeys } = await import(pathToFileURL(resolve(moduleRoot, 'ci-cache-key.mts')).href) as typeof import('./ci-cache-key.mts'); +const { buildCi, packageRebuildClosure, compiledOutputsValid } = await import(pathToFileURL(resolve(moduleRoot, 'build-ci.mts')).href) as typeof import('./build-ci.mts'); +const runtime: CacheRuntime = { node: '22.23.2', platform: 'linux', arch: 'x64', environment: {} }; +const put = (root: string, path: string, text: string) => { + mkdirSync(dirname(resolve(root, path)), { recursive: true }); writeFileSync(resolve(root, path), text); +}; +function fixture(t: TestContext) { + const root = mkdtempSync(resolve(tmpdir(), 'compiled-packages-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + execFileSync('git', ['init', '-q'], { cwd: root }); + put(root, 'package.json', JSON.stringify({ packageManager: 'pnpm@10.30.3', devDependencies: { typescript: '5.9.3' } })); + put(root, 'tsconfig.base.json', '{"compilerOptions":{"target":"es2022"}}'); + put(root, 'site/unrelated.mts', 'export const value = 1;'); + put(root, 'docs/unrelated.md', '# Guide'); + for (const [name, dependencies] of [['a', []], ['b', ['a']], ['c', ['b']], ['d', []]] as const) { + put(root, `packages/${name}/package.json`, JSON.stringify({ name, scripts: { build: 'tsup' }, + main: './dist/index.js', types: './dist/index.d.ts', dependencies: Object.fromEntries(dependencies.map(dep => [dep, 'workspace:*'])) })); + put(root, `packages/${name}/src/index.ts`, 'export const value = 1;'); + // These configs MUST NOT execute (no installed tsup, and a deliberate throw). + put(root, `packages/${name}/tsup.config.ts`, "import { defineConfig } from 'tsup';\nthrow new Error('config executed');\nexport default defineConfig(() => ({entry:['src/index.ts'],shims:true,removeNodeProtocol:false}));"); + } + put(root, 'pnpm-lock.yaml', JSON.stringify({ lockfileVersion: '9.0', importers: Object.fromEntries(['.', ...['a', 'b', 'c', 'd'].map(name => `packages/${name}`)].map(name => [name, {}])), packages: {}, snapshots: {} })); + execFileSync('git', ['add', '.'], { cwd: root }); + return root; +} +const digests = (result: Awaited>) => Object.fromEntries(result.packages.map(pkg => [pkg.name, pkg.digest])); + +test('unrelated site and docs edits leave every package digest unchanged', async t => { + const root = fixture(t), before = await compiledCiCacheKeys({ root, runtime }); + put(root, 'site/unrelated.mts', 'export const value = 2;'); + put(root, 'docs/unrelated.md', '# Changed'); + const after = await compiledCiCacheKeys({ root, runtime }); + assert.deepEqual(digests(after), digests(before)); + assert.equal(after.packageDigest, before.packageDigest); + assert.deepEqual(after.fallbackReasons, []); +}); +test('package source edits change only that package and its transitive dependents', async t => { + const root = fixture(t), before = digests(await compiledCiCacheKeys({ root, runtime })); + put(root, 'packages/a/src/index.ts', 'export const value = 2;'); + const after = digests(await compiledCiCacheKeys({ root, runtime })); + for (const name of ['a', 'b', 'c']) assert.notEqual(after[name], before[name], name); + assert.equal(after.d, before.d); + put(root, 'packages/d/src/index.ts', 'export const value = 3;'); + const last = digests(await compiledCiCacheKeys({ root, runtime })); + for (const name of ['a', 'b', 'c']) assert.equal(last[name], after[name], name); + assert.notEqual(last.d, after.d); +}); +test('root config input changes every package digest', async t => { + const root = fixture(t), before = digests(await compiledCiCacheKeys({ root, runtime })); + put(root, 'tsconfig.base.json', '{"compilerOptions":{"target":"esnext"}}'); + const after = digests(await compiledCiCacheKeys({ root, runtime })); + for (const name of ['a', 'b', 'c', 'd']) assert.notEqual(after[name], before[name], name); +}); +test('declaration and sourcemap modes use distinct package identities', async t => { + const root = fixture(t), before = digests(await compiledCiCacheKeys({ root, runtime })); + for (const environment of [{ CSSEARTH_SKIP_DECLARATIONS: '1' }, { CSSEARTH_PERFORMANCE_SOURCEMAPS: '1' }]) { + const after = digests(await compiledCiCacheKeys({ root, runtime: { ...runtime, environment } })); + for (const name of ['a', 'b', 'c', 'd']) assert.notEqual(after[name], before[name]); + } +}); +test('literal config imports are hashed without execution and dynamic imports stay package-local', async t => { + const root = fixture(t); + put(root, 'build-options.mts', 'export default {shims:true};'); + put(root, 'packages/d/tsup.config.ts', "import options from '../../build-options.mts'; export default () => options; import(process.env.CONFIG);"); + execFileSync('git', ['add', '.'], { cwd: root }); + const before = digests(await compiledCiCacheKeys({ root, runtime })); + put(root, 'build-options.mts', 'export default {shims:false};'); + const after = digests(await compiledCiCacheKeys({ root, runtime })); + assert.notEqual(after.d, before.d); + for (const name of ['a', 'b', 'c']) assert.equal(after[name], before[name]); +}); +test('lockfile closure excludes unrelated importers and includes transitive external resolutions', async t => { + const root = fixture(t); + const lock = JSON.parse(readFileSync(resolve(root, 'pnpm-lock.yaml'), 'utf8')); + lock.importers['packages/d'] = { dependencies: { external: { version: '1.0.0', specifier: '1.0.0' } } }; + lock.packages['external@1.0.0'] = { resolution: { tarball: 'local-one' } }; + lock.snapshots['external@1.0.0'] = {}; + put(root, 'pnpm-lock.yaml', JSON.stringify(lock)); + const before = digests(await compiledCiCacheKeys({ root, runtime })); + lock.packages['external@1.0.0'].resolution.tarball = 'local-two'; + put(root, 'pnpm-lock.yaml', JSON.stringify(lock)); + const after = digests(await compiledCiCacheKeys({ root, runtime })); + assert.notEqual(after.d, before.d); + for (const name of ['a', 'b', 'c']) assert.equal(after[name], before[name]); +}); +test('equal receipts skip builds; mismatches and incomplete restores rebuild exactly the dependent closure', async t => { + const root = fixture(t), { packages } = await compiledCiCacheKeys({ root, runtime }); + const calls: CiBuildTask[] = [], first = () => calls[0]!; + const run = async (task: CiBuildTask) => { + calls.push(task); + for (const output of task.outputs ?? []) { + const name = output.path.split('/')[1]!; + if (!task.args.includes(name)) continue; + for (const path of output.required) put(root, `${output.path}/${path}`, 'compiled'); + } + }; + await buildCi({ root, mode: 'packages', packages, run }); + assert.deepEqual(calls[0]!.args, ['-r', '--filter', 'a', '--filter', 'b', '--filter', 'c', '--filter', 'd', 'build']); + calls.splice(0); + assert.equal((await buildCi({ root, mode: 'packages', packages, run }))[0]!.cached, true); + assert.deepEqual(calls, []); + const receiptFile = resolve(root, 'packages/a/dist/.ci-build-files.json'); + const original = readFileSync(receiptFile, 'utf8'), receipt = JSON.parse(original); + receipt.digest = 'wrong'; writeFileSync(receiptFile, JSON.stringify(receipt)); + await buildCi({ root, mode: 'packages', packages, run }); + assert.deepEqual(first().args, ['-r', '--filter', 'a', '--filter', 'b', '--filter', 'c', 'build']); + calls.splice(0); + put(root, 'packages/a/dist/extra.js', 'stale'); + assert.equal(compiledOutputsValid(root, { path: 'packages/a/dist', required: ['index.js'] }, packages[0]!.digest), false); + rmSync(resolve(root, 'packages/a/dist/extra.js')); + rmSync(resolve(root, 'packages/a/dist/index.d.ts')); + await buildCi({ root, mode: 'packages', packages, run }); + assert.deepEqual(first().args, ['-r', '--filter', 'a', '--filter', 'b', '--filter', 'c', 'build']); + assert.deepEqual(packageRebuildClosure(packages, ['a']), ['a', 'b', 'c']); +}); + +test('a command that returns successfully without compiled outputs never writes valid receipts', async t => { + const root = fixture(t), { packages } = await compiledCiCacheKeys({ root, runtime }); + await assert.rejects(buildCi({ root, mode: 'packages', packages, run: async () => {} }), /ENOENT/); + assert.equal(compiledOutputsValid(root, { path: 'packages/a/dist', required: ['index.js'] }, packages[0]!.digest), false); +}); + +const record = (value: unknown): Record => { + assert.ok(value !== null && typeof value === 'object' && !Array.isArray(value)); return value as Record; +}; +test('all package consumers restore v3; exactly one lane saves; nebula uses receipts; aggregator remains strict', () => { + const workflow = record(parse(readFileSync(resolve(import.meta.dirname, '../../workflows/universe.yml'), 'utf8'))); + const jobs = record(workflow.jobs); + let consumers = 0, savers = 0; + for (const [name, value] of Object.entries(jobs)) { + const job = record(value), steps = job.steps; + assert.ok(Array.isArray(steps)); + for (const value of steps) { + const step = record(value); + if (!step.with || record(step.with).path !== 'packages/*/dist') continue; + const options = record(step.with); + assert.equal(options.key, 'compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }}'); + if (String(step.uses).startsWith('actions/cache/save@')) { savers++; assert.equal(name, 'lint'); } + else { consumers++; assert.match(String(step.uses), /^actions\/cache\/restore@/u); assert.equal(options['restore-keys'], 'compiled-packages-v3-'); } + } + } + assert.equal(consumers, 6); assert.equal(savers, 1); + const nebula = record(jobs.nebula); + assert.ok((nebula.steps as unknown[]).some(value => record(value).run === 'node .github/scripts/ci/build-ci.mts packages')); + const aggregator = record(jobs.universe); + assert.equal(aggregator.name, 'Prepared universe and shared renderer'); + assert.deepEqual(aggregator.needs, ['changes', 'universe-checks']); + assert.equal(record((aggregator.steps as unknown[])[0]).run, 'test "$RESULT" = success'); +}); + +test('source mutations remove closure, root input and package isolation and each turns a regression red', { skip: Boolean(process.env.CI_CACHE_TEST_MODULES) }, t => { + mkdirSync(resolve(import.meta.dirname, '../../../output'), { recursive: true }); + const directory = mkdtempSync(resolve(import.meta.dirname, '../../../output/ci-cache-mutants-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const cacheSource = readFileSync(resolve(import.meta.dirname, 'ci-cache-key.mts'), 'utf8'); + const buildSource = readFileSync(resolve(import.meta.dirname, 'build-ci.mts'), 'utf8'); + for (const [label, owner, from, to, pattern] of [ + ['closure', 'build', 'let changed = true;', 'let changed = false;', 'equal receipts'], + ['root input', 'cache', 'selected: new Set(shared)', 'selected: new Set()', 'root config input'], + ['whole tree', 'cache', 'const digest = gitKey(root, canonical([identity, [...pkg.selected].sort().map(inputState), lockInputs(lock, pkg.directory), upstream]));', 'const digest = ciCacheKeys({ root, runtime }).buildDigest;', 'unrelated site'], + ]) { + const source = owner === 'build' ? buildSource : cacheSource; + assert.ok(source.includes(from!), label); + writeFileSync(resolve(directory, 'ci-cache-key.mts'), owner === 'cache' ? source.replace(from!, to!) : cacheSource); + writeFileSync(resolve(directory, 'build-ci.mts'), owner === 'build' ? source.replace(from!, to!) : buildSource); + const childEnvironment: NodeJS.ProcessEnv = { ...process.env, CI_CACHE_TEST_MODULES: directory }; + delete childEnvironment.NODE_TEST_CONTEXT; + const result = spawnSync(process.execPath, ['--test', `--test-name-pattern=${pattern}`, resolve(import.meta.dirname, 'compiled-packages.test.mts')], + { encoding: 'utf8', env: childEnvironment }); + assert.equal(result.status, 1, `${label}: ${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /not ok/u, label); + assert.doesNotMatch(result.stderr, /SyntaxError|ERR_MODULE_NOT_FOUND/u, label); + } +}); diff --git a/.github/scripts/ci/kernel-cache.test.mts b/.github/scripts/ci/kernel-cache.test.mts index ed865e1c3f3..44b99d662b7 100644 --- a/.github/scripts/ci/kernel-cache.test.mts +++ b/.github/scripts/ci/kernel-cache.test.mts @@ -9,7 +9,7 @@ const root = new URL('../../../', import.meta.url); test('every solar preparation CI job caches kernel files under a pin-content key', async () => { const workflow = requireRecord(parse(await readFile(new URL('.github/workflows/universe.yml', root), 'utf8'))); const jobs = Object.values(requireRecord(workflow.jobs)).map(job => requireRecord(job)); - const fetching = jobs.filter(job => requireArray(job.steps).some(step => /build-ci\.mts (?:lint|full)/u.test(String(requireRecord(step).run)))); + const fetching = jobs.filter(job => requireArray(job.steps).some(step => /build-ci\.mts (?:lint|full|generators)/u.test(String(requireRecord(step).run)))); assert.ok(fetching.length >= 5); const banks = (await readdir(new URL('src/spice/', root), { withFileTypes: true })).filter(entry => entry.isDirectory()); const pins = await Promise.all(banks.map(async bank => ({ path: `src/spice/${bank.name}/manifest.json`, contents: await readFile(new URL(`src/spice/${bank.name}/manifest.json`, root), 'utf8') }))); @@ -23,7 +23,7 @@ test('every solar preparation CI job caches kernel files under a pin-content key const options = requireRecord(cache.with), key = requireString(options.key); assert.equal(requireString(options.path).trim(), 'src/spice/*/*/'); assert.match(key, /hashFiles\('src\/spice\/\*\/manifest\.json'\)/u); - assert.ok(cacheIndex < steps.findIndex(step => /build-ci\.mts (?:lint|full)/u.test(String(step.run)))); + assert.ok(cacheIndex < steps.findIndex(step => /build-ci\.mts (?:lint|full|generators)/u.test(String(step.run)))); const expressions = [...key.matchAll(/hashFiles\(([^)]*)\)/gu)]; assert.ok(expressions.length > 0); const globs = expressions.flatMap(expression => [...expression[1]!.matchAll(/'([^']+)'/gu)].map(match => match[1]!)); diff --git a/.github/scripts/ci/test-shards.mts b/.github/scripts/ci/test-shards.mts new file mode 100644 index 00000000000..4e72fcadd8e --- /dev/null +++ b/.github/scripts/ci/test-shards.mts @@ -0,0 +1,54 @@ +/** Deterministic universe matrix and native Node file sharding; no duration history or verdict cache. */ +import { appendFileSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { scriptTestFiles } from '../../../packages/core/src/node/script-test-files.ts'; + +export interface TestShard { lane: 'packages' | 'site'; shard: number; total: number; } +export interface TestSelection { packages: string; files: string; } +const root = resolve(import.meta.dirname, '../../..'); + +export function selectedTestFiles(directory: string, lane: TestShard['lane'], selection: TestSelection): string[] { + const manifest: unknown = JSON.parse(readFileSync(resolve(directory, 'package.json'), 'utf8')); + const files = scriptTestFiles(directory, manifest, [`test:${lane}`]).get(`test:${lane}`)!; + if (lane === 'site' || selection.packages === 'all') return [...files]; + const packages = new Set(selection.packages.split(/\s+/u).filter(Boolean)); + const extra = new Set(selection.files.split(/\s+/u).filter(Boolean)); + for (const file of extra) if (!files.includes(file)) throw new TypeError(`Selected test is outside the packages lane: ${file}`); + return files.filter(file => packages.has(/^packages\/([^/]+)\//u.exec(file)?.[1] ?? '') || extra.has(file)); +} + +export function universeTestMatrix(directory: string, selection: TestSelection): TestShard[] { + const total = selection.packages === 'all' ? 3 : selectedTestFiles(directory, 'packages', selection).length <= 40 ? 1 : 2; + return [...Array.from({ length: total }, (_, index): TestShard => ({ lane: 'packages', shard: index + 1, total })), + ...Array.from({ length: 3 }, (_, index): TestShard => ({ lane: 'site', shard: index + 1, total: 3 }))]; +} + +/** Mirrors Node 22's sorted file-index modulo partition, independent of scheduling/completion times. */ +export function shardFiles(files: readonly string[], shard: number, total: number): string[] { + if (!Number.isInteger(total) || total < 1 || !Number.isInteger(shard) || shard < 1 || shard > total) throw new TypeError('Invalid test shard.'); + return [...new Set(files)].sort().filter((_, index) => index % total === shard - 1); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const selection = { packages: process.env.CI_TEST_PACKAGES ?? 'all', files: process.env.CI_TEST_FILES ?? '' }; + if (process.argv.length === 3 && process.argv[2] === '--matrix') { + const matrix = JSON.stringify({ include: universeTestMatrix(root, selection) }); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `test_matrix=${matrix}\n`); + console.log(matrix); + } else if (process.argv.length === 3 && process.argv[2] === '--run') { + const lane = process.env.CI_UNIVERSE_LANE; + if (lane !== 'site' && lane !== 'packages') throw new TypeError('Unknown universe test lane.'); + const shard = Number(process.env.CI_TEST_SHARD), total = Number(process.env.CI_TEST_SHARDS); + const files = selectedTestFiles(root, lane, selection); + const selected = shardFiles(files, shard, total); + if (!universeTestMatrix(root, selection).some(row => row.lane === lane && row.shard === shard && row.total === total)) throw new TypeError('Unexpected universe test shard.'); + console.log(`${lane} shard ${shard}/${total}: ${selected.length}/${files.length} test files`); + if (!selected.length) throw new TypeError('Selected shard has no test files.'); + // Pass the entire sorted set: Node owns the actual partition, with the existing imports/mocks/timeouts. + const result = spawnSync('pnpm', ['test:run', `--test-shard=${shard}/${total}`, ...files], { cwd: root, stdio: 'inherit' }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; + } else throw new TypeError('Usage: node .github/scripts/ci/test-shards.mts --matrix|--run'); +} diff --git a/.github/scripts/ci/test-shards.test.mts b/.github/scripts/ci/test-shards.test.mts new file mode 100644 index 00000000000..88ff766719e --- /dev/null +++ b/.github/scripts/ci/test-shards.test.mts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { test } from 'node:test'; +import { parse, stringify } from 'yaml'; +import { requireArray, requireRecord } from '@cssearth/core'; +import { readCiSteps } from './check-ci.mts'; +import { selectedTestFiles, shardFiles, universeTestMatrix } from './test-shards.mts'; + +const root = resolve(import.meta.dirname, '../../..'); +const source = readFileSync(resolve(root, '.github/workflows/universe.yml'), 'utf8'); +const all = { packages: 'all', files: '' }; +const expected = [ + { lane: 'packages', shard: 1, total: 3 }, { lane: 'packages', shard: 2, total: 3 }, { lane: 'packages', shard: 3, total: 3 }, + { lane: 'site', shard: 1, total: 3 }, { lane: 'site', shard: 2, total: 3 }, { lane: 'site', shard: 3, total: 3 }, +]; + +function contract(text: string) { + const workflow = requireRecord(parse(text)), jobs = requireRecord(workflow.jobs); + const changes = requireRecord(jobs.changes), outputs = requireRecord(changes.outputs); + assert.equal(outputs.test_matrix, '${{ steps.shards.outputs.test_matrix }}'); + const step = requireArray(changes.steps).map(value => requireRecord(value)).find(step => step.id === 'shards'); + assert.ok(step); + assert.equal(step.run, 'node .github/scripts/ci/test-shards.mts --matrix'); + assert.deepEqual(step.env, { CI_TEST_PACKAGES: '${{ steps.affected.outputs.test_packages }}', CI_TEST_FILES: '${{ steps.affected.outputs.test_files }}' }); + const job = requireRecord(jobs['universe-checks']); + assert.equal(job.name, 'Universe / ${{ matrix.lane }} / ${{ matrix.shard }} of ${{ matrix.total }}'); + assert.equal(requireRecord(job.env).CI_TEST_SHARD, '${{ matrix.shard }}'); + assert.equal(requireRecord(job.env).CI_TEST_SHARDS, '${{ matrix.total }}'); + const steps = readCiSteps(text); + const tests = steps.filter(step => step.run.includes('node .github/scripts/ci/test-shards.mts --run')); + assert.deepEqual(tests.map(step => ({ lane: step.env.CI_UNIVERSE_LANE, shard: Number(step.env.CI_TEST_SHARD), total: Number(step.env.CI_TEST_SHARDS) })), expected); + assert.ok(tests.every(step => !step.run.includes('pnpm "test:'))); + assert.equal(requireRecord(jobs.universe).name, 'Prepared universe and shared renderer'); + return tests; +} + +test('workflow and local CI execute exactly all five native shards with the maintained required aggregate', () => { + contract(source); + assert.deepEqual(universeTestMatrix(root, all), expected); + const subset = { packages: 'core', files: '' }; + const rows = universeTestMatrix(root, subset); + const local = readCiSteps(source, 'universe', { '${{ needs.changes.outputs.test_packages }}': subset.packages }); + assert.equal(local.filter(step => step.run.includes('test-shards.mts --run')).length, rows.length); +}); + +test('real script discovery partitions every selected file exactly once and ignores input/timing order', () => { + for (const selection of [all, { packages: 'core renderer', files: 'integration/prepared-object-mount/navigable-object-mount.test.mts' }]) { + for (const lane of ['packages', 'site'] as const) { + const files = selectedTestFiles(root, lane, selection); + assert.ok(files.length > 0); + const rows = universeTestMatrix(root, selection).filter(row => row.lane === lane); + const partition = rows.map(row => shardFiles(files, row.shard, row.total)); + assert.deepEqual(partition.flat().sort(), files, 'union equals the unsharded set'); + assert.equal(new Set(partition.flat()).size, files.length, 'no duplicate file'); + assert.deepEqual(rows.map(row => shardFiles([...files].reverse(), row.shard, row.total)), partition, 'completion order cannot change membership'); + assert.notDeepEqual(partition.slice(1).flat().sort(), files, 'deleting a shard loses coverage'); + } + } +}); + +test('subset threshold is deterministic at 40/41 files and overlapping explicit files run once', () => { + const directory = mkdtempSync(resolve(tmpdir(), 'ci-shard-selection-')); + try { + writeFileSync(resolve(directory, 'package.json'), JSON.stringify({ scripts: { 'test:packages': 'pnpm test:run "*.test.mts"', 'test:site': 'pnpm test:run "*.test.mts"' } })); + for (let index = 0; index < 41; index++) writeFileSync(resolve(directory, `${index}.test.mts`), ''); + const selection = (count: number) => ({ packages: '', files: Array.from({ length: count }, (_, index) => `${index}.test.mts`).join(' ') }); + for (const [count, shards] of [[0, 1], [40, 1], [41, 2]]) + assert.equal(universeTestMatrix(directory, selection(count!)).filter(row => row.lane === 'packages').length, shards); + assert.equal(universeTestMatrix(directory, all).filter(row => row.lane === 'packages').length, 3); + assert.throws(() => selectedTestFiles(directory, 'packages', { packages: '', files: 'missing.test.mts' }), /outside/); + } finally { rmSync(directory, { recursive: true, force: true }); } + const file = selectedTestFiles(root, 'packages', { packages: 'core', files: '' })[0]!; + assert.ok(file); + assert.deepEqual(selectedTestFiles(root, 'packages', { packages: 'core', files: file }), selectedTestFiles(root, 'packages', { packages: 'core', files: '' })); +}); + +test('native Node sharding agrees with sorted-index partition despite reversed creation and delayed tests', () => { + const directory = mkdtempSync(resolve(tmpdir(), 'ci-native-shards-')); + try { + const names = Array.from({ length: 7 }, (_, index) => `${index}.test.mts`); + for (const [index, name] of [...names].reverse().entries()) + writeFileSync(resolve(directory, name), `import { test } from 'node:test'; test('${name}', async () => { await new Promise(r => setTimeout(r, ${index * 3})); });\n`); + const environment = { ...process.env }; + delete environment.NODE_TEST_CONTEXT; + for (const total of [1, 2, 3]) for (let shard = 1; shard <= total; shard++) { + const output = execFileSync(process.execPath, ['--test', '--test-reporter=tap', `--test-shard=${shard}/${total}`, '*.test.mts'], { cwd: directory, encoding: 'utf8', env: environment }); + const actual = [...output.matchAll(/^# Subtest: (\d+\.test\.mts)$/gmu)].map(match => match[1]!).sort(); + assert.deepEqual(actual, shardFiles(names, shard, total)); + } + writeFileSync(resolve(directory, '0.test.mts'), "import { test } from 'node:test'; test('deliberate failure', () => { throw Error('mutation'); });\n"); + assert.notEqual(spawnSync(process.execPath, ['--test', '--test-shard=1/2', '*.test.mts'], { cwd: directory, env: environment }).status, 0); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); + +test('deleting a matrix shard or ignoring the shard aggregate makes the same contract red', () => { + const workflow = requireRecord(parse(source)), jobs = requireRecord(workflow.jobs); + const checks = requireRecord(jobs['universe-checks']), strategy = requireRecord(checks.strategy); + const original = strategy.matrix; + strategy.matrix = { include: expected.slice(1) }; + assert.throws(() => contract(stringify(workflow)), /maintained lane matrices/); + strategy.matrix = original; + const aggregate = requireRecord(jobs.universe), step = requireRecord(requireArray(aggregate.steps)[0]); + step.run = 'true'; + assert.throws(() => contract(stringify(workflow)), /fail-closed/); + step.run = 'test "$RESULT" = success'; + step.env = { RESULT: 'success' }; + assert.throws(() => contract(stringify(workflow)), /fail-closed/); + const guard = 'test "$RESULT" = success'; + for (const result of ['failure', 'cancelled', 'skipped', '']) + assert.notEqual(spawnSync('bash', ['-c', guard], { env: { ...process.env, RESULT: result } }).status, 0); + assert.equal(spawnSync('bash', ['-c', guard], { env: { ...process.env, RESULT: 'success' } }).status, 0); + contract(source); +}); + + +test('production CLI passes native shard options and a deleted generated shard turns the matrix assertion red', () => { + const directory = realpathSync(mkdtempSync(resolve(tmpdir(), 'ci-shard-cli-'))); + try { + const environment: NodeJS.ProcessEnv = { ...process.env, CI_TEST_PACKAGES: 'all', CI_TEST_FILES: '', CI_UNIVERSE_LANE: 'site', CI_TEST_SHARD: '2', CI_TEST_SHARDS: '3', + PATH: `${directory}:${process.env.PATH}`, SHARD_ARGUMENTS: resolve(directory, 'arguments.json'), GITHUB_OUTPUT: '' }; + delete environment.NODE_TEST_CONTEXT; + writeFileSync(resolve(directory, 'pnpm'), `#!/usr/bin/env node\nrequire('node:fs').writeFileSync(process.env.SHARD_ARGUMENTS, JSON.stringify(process.argv.slice(2)));\n`); + chmodSync(resolve(directory, 'pnpm'), 0o755); + const run = (file: string, flag: string) => execFileSync(process.execPath, [file, flag], { env: environment, encoding: 'utf8' }); + run(resolve(root, '.github/scripts/ci/test-shards.mts'), '--run'); + assert.deepEqual(JSON.parse(readFileSync(resolve(directory, 'arguments.json'), 'utf8')), ['test:run', '--test-shard=2/3', ...selectedTestFiles(root, 'site', all)]); + const original = readFileSync(resolve(root, '.github/scripts/ci/test-shards.mts'), 'utf8'); + const relocated = original.replace("'../../../packages/core/src/node/script-test-files.ts'", JSON.stringify(resolve(root, 'packages/core/src/node/script-test-files.ts'))) + .replace("resolve(import.meta.dirname, '../../..')", JSON.stringify(root)); + const mutant = resolve(directory, 'mutant.mts'); + writeFileSync(mutant, relocated.replace("length: 3 }, (_, index): TestShard => ({ lane: 'site'", "length: 2 }, (_, index): TestShard => ({ lane: 'site'")); + const matrix = JSON.parse(run(mutant, '--matrix')); + assert.throws(() => assert.deepEqual(matrix.include, expected), /AssertionError/, 'deleting site shard 2 must fail the canonical matrix contract'); + writeFileSync(mutant, relocated.replace('`--test-shard=${shard}/${total}`', "'--test-concurrency=4'")); + run(mutant, '--run'); + assert.throws(() => assert.deepEqual(JSON.parse(readFileSync(resolve(directory, 'arguments.json'), 'utf8')), ['test:run', '--test-shard=2/3', ...selectedTestFiles(root, 'site', all)]), /AssertionError/); + } finally { rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/.github/workflows/universe.yml b/.github/workflows/universe.yml index bb7f2f16a79..fc2bb1decf5 100644 --- a/.github/workflows/universe.yml +++ b/.github/workflows/universe.yml @@ -4,6 +4,7 @@ name: Shared universe # all merge-required checks finish, including queue/setup time (not per job). # Keep affected correctness checks blocking; keep exhaustive sweeps and release # qualification outside the routine PR path. Measure added work against this budget. +# Universe file shards overlap tests but repeat isolated-runner setup; measure both in hosted runs. # Job timeouts below are safety limits, not the agreed feedback budget. # Maintenance rules and failure recovery: docs/ci-cd.md. @@ -39,6 +40,7 @@ jobs: test_packages: ${{ steps.affected.outputs.test_packages }} test_files: ${{ steps.affected.outputs.test_files }} test_site: ${{ steps.affected.outputs.test_site }} + test_matrix: ${{ steps.shards.outputs.test_matrix }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -85,6 +87,12 @@ jobs: # changed (.github/scripts/ci/affected-tests.mts). A push to main, or shared configuration, tests everything. id: affected run: node .github/scripts/ci/affected-tests.mts ${GITHUB_BASE_REF:+"origin/$GITHUB_BASE_REF"} + - name: Choose deterministic universe test shards + id: shards + env: + CI_TEST_PACKAGES: ${{ steps.affected.outputs.test_packages }} + CI_TEST_FILES: ${{ steps.affected.outputs.test_files }} + run: node .github/scripts/ci/test-shards.mts --matrix - name: Check that every commit is one Conventional Commits line with no attribution # Required with the rest of this job. A local commit-msg hook gives the same answer earlier, but a hook can be # skipped; this reads the pull request's own commits (merge base to head), so the merge itself is never counted. @@ -154,11 +162,12 @@ jobs: - name: Hash tracked build and compiler inputs id: ci-cache-key run: node .github/scripts/ci/ci-cache-key.mts - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: package-cache with: path: packages/*/dist - key: compiled-packages-v2-${{ steps.ci-cache-key.outputs.package_digest }} + key: compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }} + restore-keys: compiled-packages-v3- - name: Cache pinned NAIF kernel banks # A miss restores from each manifest's NAIF origin; no external mirror is written. uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -167,10 +176,13 @@ jobs: src/spice/*/*/ key: naif-kernels-${{ runner.os }}-${{ hashFiles('src/spice/*/manifest.json') }} - name: Generate the registry and renderer - env: - CI_PACKAGE_CACHE_HIT: ${{ steps.package-cache.outputs.cache-hit }} - CI_PACKAGE_DIGEST: ${{ steps.ci-cache-key.outputs.package_digest }} run: node .github/scripts/ci/build-ci.mts lint + # One writer per digest set: every other lane restores and verifies package receipts. + - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + if: ${{ steps.package-cache.outputs.cache-hit != 'true' }} + with: + path: packages/*/dist + key: compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }} - name: Mutation-check stale reference guard run: node --test .github/scripts/checks/check-stale-references.test.mts - name: Check live references to retired paths @@ -193,7 +205,7 @@ jobs: needs: changes if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.run_typecheck == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 12 permissions: contents: read steps: @@ -219,11 +231,12 @@ jobs: - name: Hash tracked build and compiler inputs id: ci-cache-key run: node .github/scripts/ci/ci-cache-key.mts - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: package-cache with: path: packages/*/dist - key: compiled-packages-v2-${{ steps.ci-cache-key.outputs.package_digest }} + key: compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }} + restore-keys: compiled-packages-v3- - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: # Every tsc invocation in typecheck:pr writes its own .tsbuildinfo (never astro check, which never reads @@ -243,9 +256,6 @@ jobs: src/spice/*/*/ key: naif-kernels-${{ runner.os }}-${{ hashFiles('src/spice/*/manifest.json') }} - name: Build shared packages and affected entry points - env: - CI_PACKAGE_CACHE_HIT: ${{ steps.package-cache.outputs.cache-hit }} - CI_PACKAGE_DIGEST: ${{ steps.ci-cache-key.outputs.package_digest }} run: node .github/scripts/ci/build-ci.mts full - name: Restore only compiler inputs and generate real shell data run: pnpm prepare:typecheck @@ -263,7 +273,7 @@ jobs: # Source signatures can break unchanged tests, so use the same owner selection as application types. if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.run_typecheck == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 12 permissions: contents: read steps: @@ -287,11 +297,12 @@ jobs: - name: Hash tracked build and compiler inputs id: ci-cache-key run: node .github/scripts/ci/ci-cache-key.mts - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: package-cache with: path: packages/*/dist - key: compiled-packages-v2-${{ steps.ci-cache-key.outputs.package_digest }} + key: compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }} + restore-keys: compiled-packages-v3- - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: # Keep this lane separate: caches are immutable, so sharing the application's key lets the @@ -309,9 +320,6 @@ jobs: src/spice/*/*/ key: naif-kernels-${{ runner.os }}-${{ hashFiles('src/spice/*/manifest.json') }} - name: Build shared packages and affected entry points - env: - CI_PACKAGE_CACHE_HIT: ${{ steps.package-cache.outputs.cache-hit }} - CI_PACKAGE_DIGEST: ${{ steps.ci-cache-key.outputs.package_digest }} run: node .github/scripts/ci/build-ci.mts full - name: Restore only compiler inputs and generate real shell data run: pnpm prepare:typecheck @@ -323,13 +331,15 @@ jobs: run: pnpm typecheck:tests --extendedDiagnostics universe-checks: - name: Universe / ${{ matrix.lane }} + name: Universe / ${{ matrix.lane }} / ${{ matrix.shard }} of ${{ matrix.total }} strategy: fail-fast: false matrix: - lane: [packages, site] + include: ${{ fromJSON(needs.changes.outputs.test_matrix).include }} env: CI_UNIVERSE_LANE: ${{ matrix.lane }} + CI_TEST_SHARD: ${{ matrix.shard }} + CI_TEST_SHARDS: ${{ matrix.total }} CI_TEST_PACKAGES: ${{ needs.changes.outputs.test_packages }} CI_TEST_FILES: ${{ needs.changes.outputs.test_files }} # A lane with nothing to test still reports, so the required check passes, but does no work. @@ -342,16 +352,20 @@ jobs: contents: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + if: env.CI_LANE_SKIP != 'true' with: filter: blob:none sparse-checkout-cone-mode: false sparse-checkout: *code-and-text - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + if: env.CI_LANE_SKIP != 'true' - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: env.CI_LANE_SKIP != 'true' with: node-version: '22' cache: pnpm - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + if: env.CI_LANE_SKIP != 'true' with: path: node_modules key: node-modules-${{ hashFiles('pnpm-lock.yaml') }} @@ -362,27 +376,25 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts - name: Hash tracked build and compiler inputs id: ci-cache-key + if: env.CI_LANE_SKIP != 'true' run: node .github/scripts/ci/ci-cache-key.mts - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + if: env.CI_LANE_SKIP != 'true' id: package-cache with: path: packages/*/dist - key: compiled-packages-v2-${{ steps.ci-cache-key.outputs.package_digest }} + key: compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }} + restore-keys: compiled-packages-v3- - name: Cache pinned NAIF kernel banks # A miss restores from each manifest's NAIF origin; no external mirror is written. uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + if: env.CI_LANE_SKIP != 'true' with: path: | src/spice/*/*/ key: naif-kernels-${{ runner.os }}-${{ hashFiles('src/spice/*/manifest.json') }} - - name: Build shared packages and affected entry points - env: - CI_PACKAGE_CACHE_HIT: ${{ steps.package-cache.outputs.cache-hit }} - CI_PACKAGE_DIGEST: ${{ steps.ci-cache-key.outputs.package_digest }} - run: | - if [ "$CI_LANE_SKIP" = true ]; then exit 0; fi - node .github/scripts/ci/build-ci.mts full - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + if: env.CI_LANE_SKIP != 'true' id: prepared-cache with: # Every prepared file of every object, not texture banks. Caching two file names of them left each job to @@ -391,13 +403,13 @@ jobs: path: src/objects/*/prepared key: prepared-files-v2-${{ hashFiles('src/objects/*/inventory.json') }} restore-keys: prepared-files-v2- + - name: Build shared packages and affected entry points + run: | + if [ "$CI_LANE_SKIP" = true ]; then exit 0; fi + node .github/scripts/ci/build-ci.mts packages - name: Restore prepared assets and object JSON # Every object's JSON remains SHA-verified. Only tests that consume real prepared images restore them; # application texture banks are not inputs to these runtime and renderer assertions. - env: - # The packages compiled by the build step above; the second build only regenerates the site's prepared data. - CI_PACKAGE_CACHE_HIT: 'true' - CI_PACKAGE_DIGEST: ${{ steps.ci-cache-key.outputs.package_digest }} run: | if [ "$CI_LANE_SKIP" = true ]; then exit 0; fi if [ "${GITHUB_EVENT_NAME:-}" = "push" ]; then @@ -405,14 +417,13 @@ jobs: else node .github/scripts/ci/prepare-ci-inputs.mts universe fi - # The catalogue, navigation and world context read the restored banks, and restoring the Sun's published - # assets replaces the context the first build wrote: generate them again from this checkout (packages are cached). - node .github/scripts/ci/build-ci.mts full + # Generate once after restoring the Sun's published world context and the other selected banks. + node .github/scripts/ci/build-ci.mts generators node site/build/prepare/prepare-feature-index.mts - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 # Main saves the restored files once per inventory state; pull requests read main's copy and never save their # own (a few of those would fill the repository's 10 GB of cache). - if: ${{ github.event_name == 'push' && env.CI_LANE_SKIP != 'true' && steps.prepared-cache.outputs.cache-hit != 'true' }} + if: ${{ github.event_name == 'push' && matrix.lane == 'site' && matrix.shard == 1 && env.CI_LANE_SKIP != 'true' && steps.prepared-cache.outputs.cache-hit != 'true' }} with: path: src/objects/*/prepared key: prepared-files-v2-${{ hashFiles('src/objects/*/inventory.json') }} @@ -429,21 +440,18 @@ jobs: # The volume packages' previews and compact inputs, as the nebula job keeps them, the tracked density sources # the volume tests decode (52 MB) and the tracked geology shapefiles: before the affected packages run too, since # the bake's volume tests read them whenever the bake is among them. - git grep -l 'cssearth-volume-source-manifest@2' -- 'src/objects/*/source/manifest.json' | - while IFS= read -r manifest; do printf '/%s/**\n' "${manifest%/source/manifest.json}"; done | git sparse-checkout add --stdin - git sparse-checkout add '/src/objects/**/*.shp' '/src/objects/**/*.ktx2' - node packages/bake/cli/restore-source-inputs.mts --repository-volumes - if [ "$CI_TEST_PACKAGES" != all ]; then - tests=(); for name in $CI_TEST_PACKAGES; do tests+=("packages/$name/**/*.test.{ts,mts}"); done - # Tests in offline tools or integration that exercise a changed package (discovered from the root globs and imports by affected-tests.mts). - for file in $CI_TEST_FILES; do tests+=("$file"); done - pnpm test:run "${tests[@]}" - exit 0 - fi + case " $CI_TEST_PACKAGES " in + *" bake "*|*" objects "*|*" volume-viewer "*|*" all "*) + git grep -l 'cssearth-volume-source-manifest@2' -- 'src/objects/*/source/manifest.json' | + while IFS= read -r manifest; do printf '/%s/**\n' "${manifest%/source/manifest.json}"; done | git sparse-checkout add --stdin + git sparse-checkout add '/src/objects/**/*.shp' '/src/objects/**/*.ktx2' + node packages/bake/cli/restore-source-inputs.mts --repository-volumes + ;; + esac else pnpm typecheck:oracles fi - pnpm "test:$CI_UNIVERSE_LANE" + node .github/scripts/ci/test-shards.mts --run universe: name: Prepared universe and shared renderer needs: [changes, universe-checks] @@ -467,7 +475,7 @@ jobs: needs: changes if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.run_universe_preparation == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 10 permissions: contents: read steps: @@ -491,11 +499,12 @@ jobs: - name: Hash tracked build and compiler inputs id: ci-cache-key run: node .github/scripts/ci/ci-cache-key.mts - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 id: package-cache with: path: packages/*/dist - key: compiled-packages-v2-${{ steps.ci-cache-key.outputs.package_digest }} + key: compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }} + restore-keys: compiled-packages-v3- - name: Cache pinned NAIF kernel banks # A miss restores from each manifest's NAIF origin; no external mirror is written. uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -504,9 +513,6 @@ jobs: src/spice/*/*/ key: naif-kernels-${{ runner.os }}-${{ hashFiles('src/spice/*/manifest.json') }} - name: Build shared packages and affected entry points - env: - CI_PACKAGE_CACHE_HIT: ${{ steps.package-cache.outputs.cache-hit }} - CI_PACKAGE_DIGEST: ${{ steps.ci-cache-key.outputs.package_digest }} run: node .github/scripts/ci/build-ci.mts full - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: @@ -543,7 +549,7 @@ jobs: needs: changes if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.run_nebula == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 10 permissions: contents: read steps: @@ -583,8 +589,14 @@ jobs: restore-keys: | tsbuildinfo-nebula-${{ steps.ci-cache-key.outputs.tsconfig_digest }}-${{ github.head_ref || github.ref_name }}- tsbuildinfo-nebula-${{ steps.ci-cache-key.outputs.tsconfig_digest }}-main- + - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: package-cache + with: + path: packages/*/dist + key: compiled-packages-v3-${{ steps.ci-cache-key.outputs.package_digest }} + restore-keys: compiled-packages-v3- - name: Build shared TypeScript dependencies - run: pnpm -r --filter "./packages/**" build + run: node .github/scripts/ci/build-ci.mts packages - name: Restore repository volume inputs used by provenance closure run: node packages/bake/cli/restore-source-inputs.mts --repository-volumes - name: Install the browser used by isolated control tests @@ -603,7 +615,7 @@ jobs: needs: changes if: ${{ needs.changes.outputs.run_astroquery == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 12 env: CSS_EARTH_ASTROQUERY_CACHE: /home/runner/.cache/css-earth/astroquery steps: diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 8548d3840fc..6b8b0b1cb49 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -149,6 +149,104 @@ later introduced the current pattern and documented its checked consumers and original nebula exception. Neither an older timing nor the smaller checkout alone proves that today's required feedback meets the budget; measure the current workflow. +### Per-package compiled cache + +Shared lanes and nebula restore `compiled-packages-v3-`, with +`compiled-packages-v3-` accepting an older entry. The build runner verifies each +package's input digest and complete output listing in its dist receipt. It rebuilds +only invalid packages and their transitive dependents, using manifest dependency +ordering. An exact restore still runs every selected check. Contract lint is the +single saver; other jobs never race to save that digest set. + +Each digest covers the package's tracked files, root compiler/build policy and +root build tools, its frozen external dependency closure, literal authored imports +and workspace dependency digests. Configs are parsed rather than executed, so +function-style tsup configs, hooks, `shims` and `removeNodeProtocol` need no option +allowlist. Dynamic or unresolved imports retain the complete package and root +configuration boundary, never the whole application tree. Declaration mode is +part of the identity. Generated astronomy sources are recreated on warm restores. + +The expected effect is reuse across unrelated site/documentation edits and partial +rebuilds after package edits. This is not a measured speedup: record hosted cold +and warm runs, `compiledCacheHits`, rebuilt package names and build-ci seconds per +job, plus total required-check latency. Receipt and input regressions include +source mutations removing dependency closure, root configuration and package +isolation. Required check names and test selection remain unchanged. + +## Single generation in the universe matrix + +The universe matrix restores its prepared-file cache, compiles packages with +`build-ci.mts packages`, restores and verifies selected inputs with +`prepare-ci-inputs.mts universe` (main retains `setup:asset-data`), then runs +`build-ci.mts generators` and the feature index once. The `full` and `lint` +plans used by other jobs are unchanged. Package compilation still validates +exact cache receipts and regenerates astronomy TypeScript data on a hit. + +The split plan runs exactly the same generator commands and arguments as +`full`, retaining every dependency between generators. Its only removed +prerequisite is the package compilation already completed by the previous +step. The [workflow regression](../.github/scripts/ci/ci-speed.test.mts) pins that +closure, execution order, single invocation and the unchanged full-build jobs. +Script inspection gives the output-equivalence argument: + +- The catalog reads restored runtime cameras, controls and arrival billboards + through `prepare-object-discovery.mts`; these affect the prepared catalog's + discovery facts. Its context asset modules read inventories, and its other + tables read descriptors and source records. These outputs are regenerated + from the same restored inputs as the former second pass. +- Solar geometry reads the catalog, astronomy records, saved epoch ephemerides + and authored rotation sources. Navigation's catalog-only pass writes marker + presentation metadata from the catalog and source recipes; it does not bake + images. Neither requires an earlier generator pass. +- Spatial preparation rewrites the Sun's `prepared/world-context.json`, member + and place files, orbit and plain-star banks, and system views from source + navigation, geometry, descriptors and packaged-star sources. It also removes + obsolete world files. Restoring published assets used to overwrite the first + pass's context; the retained pass now runs only after that restore. +- Moon labels read the rewritten Sun context and saved Horizons responses. + World presentation reads the regenerated catalog and world files plus source + tables. The feature index still follows them and reads restored feature, + place and runtime records. Shell titles and icons copy source-owned artwork + and produce their modules independently of restored assets. + +No generator consumes its previous output as scientific input. The old second +pass replaced the first pass's outputs; the new sequence retains that second +pass's commands and inputs, including its cleanup. This is script-level +reasoning, not a measured byte comparison of a hosted build. + +Only the packages matrix lane expands sparse volume/geology inputs and restores +repository volumes, and only when `CI_TEST_PACKAGES` contains the exact token +`bake`, `objects`, `volume-viewer` or `all`. The test commands, selected file +arguments and source-qualified test policy stay unchanged. Nebula's own volume +restore stays unconditional. Mutation tests execute the actual Bash block with +recording commands, proving required selections restore inputs and unrelated +selections avoid both restore and sparse expansion. + +Only the universe matrix has `CI_LANE_SKIP`; the other heavy jobs are already +excluded by their job conditions. Its checkout, package-manager/Node setup, +all cache actions and cache-key script now skip with the lane. Shell steps still +return success for an empty lane, keeping both matrix entries and the required +`Prepared universe and shared renderer` aggregate. Regression mutations remove +each guard; aggregate tests require every non-success result to fail. + +Safety limits changed as follows; they are not feedback targets: + +| Job | Previous minutes | New minutes | Supplied observed seconds | +| --- | --- | --- | --- | +| Typecheck | 25 | 12 | 291 | +| Typecheck test files | 25 | 12 | 249 | +| Preparation / publication | 25 | 10 | 210 | +| Astroquery | 25 | 12 | Not supplied | +| Internal nebula | 15 | 10 | 255 | + +Every supplied duration is below half its new ceiling. Expected effects are +less duplicated generator work, fewer unnecessary volume transfers and no +checkout/cache cost for empty matrix lanes. No speedup is estimated here. +The orchestrator must measure cold and warm hosted runs, generator counts, +selected test/verdict parity, checkout/cache and restore durations, and total +required-check latency; confirm astroquery's longest observed run fits its new +limit before merging. + ## Server answer comparison The local [server answers safety net](server-answers.md) records and compares real preview middleware and built @@ -168,6 +266,34 @@ paths; do not move them into a bespoke wrapper to make CI faster. runs `packages/` and `integration/`; `test:site` runs `site/`, `src/` and `.github/`. The lab CLI owns lab-test discovery; `pnpm test:lab` also runs its assets stage first. Distinguish source-dependent skips from executed checks. +The universe matrix runs site in three shards and all packages in three. For a +package subset, the sorted, deduplicated files selected by `CI_TEST_PACKAGES` plus +`CI_TEST_FILES` use one shard at 40 files or fewer, otherwise two. Empty lanes +retain their explicit no-work success; selected shards must contain files. The +classification checkout computes this matrix from the existing script globs. +Each shard calls `pnpm test:run --test-shard=i/n` with the same complete sorted +file set. Node assigns sorted file indexes modulo the shard count; execution +durations never affect membership. Discovery and native-runner regressions prove +exact coverage, stable membership and failure propagation. The local CI reader +expands the same matrix, including selected subsets. The required **Prepared +universe and shared renderer** aggregate still requires the entire matrix result +to be success; failure, cancellation or unexpected skipping fails it, with +`fail-fast: false` so sibling results remain visible. + +Every isolated shard retains the existing install, verified package cache, +prepared restore and prerequisite generation; source-volume setup remains only +in packages and oracle compilation only in site. No extra build or restore is +added within a shard. Only site shard 1 saves the shared prepared cache on main. +The expected benefit is overlapping test execution; setup is repeated on each +runner. The supplied baseline is 541 s for site and 87 s for packages, but no +hosted step timings are available locally: **there is no measured per-shard setup +time assumption yet**. Workflow commands alone cannot separate setup from tests. +The orchestrator must measure checkout/install, both package/preparation steps, +restores, browser install, oracle/source setup, test duration, queue delay and +aggregate latency on representative cold and warm hosted runs. If measured setup +dominates and three site shards do not reduce wall time, report that result and +its step timings; do not claim a speedup from shard counts or local timings. + When changing selection, prove both that a newly matching file is discovered and that a deliberately failing assertion makes the command fail. When changing a safety check, temporarily remove the protected behavior and confirm the regression diff --git a/site/application-world-resources.characterization.test.mts b/site/application-world-resources.characterization.test.mts index 505fbe0ef03..1959fcc7258 100644 --- a/site/application-world-resources.characterization.test.mts +++ b/site/application-world-resources.characterization.test.mts @@ -87,7 +87,7 @@ function isolatedTest(name: string, run: () => Promise, phone = false) { if (process.env.RESOURCE_CASE === name) { await run(); return; } const env: NodeJS.ProcessEnv = { ...process.env, RESOURCE_CASE: name, RESOURCE_PHONE: String(phone) }; delete env.NODE_TEST_CONTEXT; // Start an independent runner, not an IPC child of this one. - const child = spawnSync(process.execPath, [...process.execArgv, '--import', pathToFileURL(join(hookDirectory, 'register.mjs')).href, '--test', '--test-name-pattern', name, fileURLToPath(import.meta.url)], + const child = spawnSync(process.execPath, [...process.execArgv.filter(argument => !argument.startsWith('--test-shard')), '--import', pathToFileURL(join(hookDirectory, 'register.mjs')).href, '--test', '--test-name-pattern', name, fileURLToPath(import.meta.url)], { encoding: 'utf8', timeout: 30000, env }); assert.equal(child.status, 0, child.stdout + child.stderr); assert.match(child.stdout, /# pass 1(?:\n|\r)/u, 'the isolated case must actually run');