返回 CodeWhale
stage-compiled-host.mjs
根目录 / scripts / release / stage-compiled-host.mjs
1 #!/usr/bin/env node
2 // Release preparation only. Uses an exact local Bun, never installs a runtime.
3 import { spawnSync } from 'node:child_process'
4 import { readFileSync, statSync, lstatSync, realpathSync, mkdirSync, copyFileSync, chmodSync, writeFileSync, existsSync } from 'node:fs'
5 import { dirname, resolve, join, isAbsolute } from 'node:path'
6 import { fileURLToPath } from 'node:url'
7 import { createRequire } from 'node:module'
8
9 const require = createRequire(import.meta.url)
10 const hosts = require('../../npm/codewhale/scripts/compiled-hosts.js')
11 const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
12 const args = process.argv.slice(2)
13 const option = (name) => {
14 const i = args.indexOf(name)
15 if (i < 0 || !args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`required ${name} VALUE`)
16 return args[i + 1]
17 }
18 const json = (file) => {
19 if (statSync(file).size > 64 * 1024) throw new Error(`metadata exceeds 64 KiB: ${file}`)
20 return JSON.parse(readFileSync(file, 'utf8'))
21 }
22 const regular = (file) => {
23 if (lstatSync(file).isSymbolicLink()) throw new Error(`symlink input refused: ${file}`)
24 const selected = realpathSync(file)
25 if (!statSync(selected).isFile()) throw new Error(`not a regular local input: ${file}`)
26 return selected
27 }
28 const cleanEnv = { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '', BUN_BE_BUN: '0', BUN_JSC_useShadowRealm: '0' }
29 const run = (binary, argv, extra = {}) => {
30 const result = spawnSync(binary, argv, { cwd: root, env: cleanEnv, encoding: 'utf8', timeout: 600_000, maxBuffer: 8 * 1024 * 1024, ...extra })
31 if (result.error || result.status !== 0) throw result.error ?? new Error(`${binary} failed (${result.status}): ${result.stderr}`)
32 return result.stdout
33 }
34
35 if (args.includes('--collect')) {
36 const input = resolve(option('--collect'))
37 const output = resolve(option('--output-dir'))
38 const catalogs = []
39 for (const target of Object.keys(hosts.TARGETS)) {
40 const directory = join(input, `codewhale-compiled-host-${target}`)
41 const file = join(directory, hosts.HOST_CATALOG)
42 if (!existsSync(file)) continue
43 const catalog = hosts.parseCatalog(json(file))
44 if (catalog.hosts.length !== 1 || catalog.hosts[0].target !== target) throw new Error(`wrong target receipt at ${file}`)
45 hosts.verifyDirectory(directory, catalog)
46 catalogs.push({ directory, catalog })
47 }
48 if (!catalogs.length) throw new Error('compiled-host delivery enabled but no qualified matching-native image exists')
49 const catalog = { ...catalogs[0].catalog, hosts: catalogs.flatMap(({ catalog }) => catalog.hosts) }
50 for (const entry of catalogs) for (const key of ['version', 'source_sha', 'bundle_sha256']) {
51 if (entry.catalog[key] !== catalog[key]) throw new Error(`mixed release ${key}`)
52 }
53 hosts.parseCatalog(catalog)
54 mkdirSync(output, { recursive: true })
55 for (const entry of catalogs) for (const file of hosts.assets(entry.catalog).filter((name) => name !== hosts.HOST_CATALOG)) copyFileSync(join(entry.directory, file), join(output, file))
56 writeFileSync(join(output, hosts.HOST_CATALOG), JSON.stringify(catalog, null, 2) + '\n')
57 hosts.verifyDirectory(output, catalog)
58 console.log(`Collected ${catalog.hosts.length} qualified compiled-host targets`)
59 } else {
60 const target = option('--target')
61 const names = hosts.names(target)
62 if (hosts.TARGETS[target][0] !== process.platform || hosts.TARGETS[target][1] !== process.arch) throw new Error('compiled host requires a matching native build/qualification runner; no implicit cross-architecture runtime')
63 const bunArg = option('--bun')
64 if (!isAbsolute(bunArg)) throw new Error('--bun must be an absolute exact local executable')
65 const bun = regular(bunArg)
66 const closureFile = regular(option('--runtime-closure'))
67 const closure = json(closureFile)
68 const version = option('--version')
69 const sourceSha = option('--source-sha')
70 const bundle = join(root, 'crates/tui/extension-host/dist/codewhale-extension-host.mjs')
71 const digest = hosts.sha256(readFileSync(bundle))
72 const runtimeHash = hosts.sha256(readFileSync(bun))
73 const revision = run(bun, ['--revision']).trim()
74 if (closure.schema !== 1 || typeof closure.runtime_revision !== 'string' || !/^[a-f0-9]{40}$/.test(closure.runtime_revision) || closure.bundle_sha256 !== digest || closure.source_commit !== sourceSha || closure.runtime_sha256 !== runtimeHash || revision !== `${closure.runtime_version}+${closure.runtime_revision.slice(0, 9)}`) throw new Error('exact local Bun revision/digest differs from reviewed runtime closure')
75 // Executed runtime facts distinguish native ARM from an x64 Bun under
76 // emulation. The runner label and a hash alone cannot establish image arch.
77 const runtimeInfo = JSON.parse(run(bun, ['--no-install', '--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '--no-addons', '-p', 'JSON.stringify({platform:process.platform,arch:process.arch})']))
78 if (runtimeInfo.platform !== process.platform || runtimeInfo.arch !== process.arch) throw new Error('selected Bun process identity differs from native qualification target; emulation proof does not transfer')
79 const input = (field) => regular(resolve(dirname(closureFile), closure[field]))
80 const notices = input('notices')
81 const source = input('relink_source')
82 hosts.verifyBytes(readFileSync(notices), closure.notices_sha256, 'runtime notices')
83 hosts.verifyBytes(readFileSync(source), closure.source_sha256, 'corresponding relink source')
84 if (closure.license_closure !== 'complete' || closure.corresponding_source !== 'complete') throw new Error('runtime copyright/license texts and LGPL corresponding source/relink inputs remain incomplete; delivery refused')
85 // This reviewed input must contain actual matching texts, not component
86 // names or links alone. The exact-build source bundle is a separate input.
87 const requiredComponents = ['bun', 'JavaScriptCore', 'WebCore', 'tinycc', 'boringssl', 'brotli', 'libarchive', 'lolhtml', 'lshpack', 'lsqpack', 'lsquic', 'mimalloc', 'picohttpparser', 'zstd', 'simdutf', 'usockets', 'uwebsockets', 'zlib', 'cares', 'icu', 'libbase64', 'libuv', 'libdeflate', 'libjpeg-turbo', 'libspng', 'libwebp', 'highway', 'hdrhistogram', 'uucode', 'tigerbeetle-io', 'llvm-libcxxabi', 'embedded-polyfills', 'rust-dependencies']
88 if (!Array.isArray(closure.components) || !requiredComponents.every((name) => closure.components.includes(name)) || !closure.notice_components || typeof closure.notice_components !== 'object') throw new Error('incomplete runtime component closure')
89 const allNotices = readFileSync(notices)
90 for (const component of requiredComponents) {
91 const part = closure.notice_components[component]
92 if (!part || typeof part.file !== 'string' || typeof part.sha256 !== 'string') throw new Error(`missing matching notice text for ${component}`)
93 const file = regular(resolve(dirname(closureFile), part.file))
94 if (statSync(file).size > 1024 * 1024) throw new Error(`oversized component notice ${component}`)
95 const text = readFileSync(file)
96 hosts.verifyBytes(text, part.sha256, component)
97 if (!text.length || !allNotices.includes(text)) throw new Error(`combined notices omit actual ${component} text`)
98 }
99 if (!Array.isArray(closure.source_inputs) || !['bun', 'webkit', 'tinycc', 'relink-recipe'].every((name) => closure.source_inputs.some((entry) => entry.name === name && typeof entry.root === 'string'))) throw new Error('missing LGPL corresponding source and relink recipe inventory')
100 const members = run('tar', ['-tzf', source]).trim().split('\n')
101 if (members.some((name) => name.startsWith('/') || name.split('/').includes('..') || name.includes('\\'))) throw new Error('unsafe corresponding-source archive paths')
102 for (const entry of closure.source_inputs) {
103 if (!/^[A-Za-z0-9_.\/-]+$/.test(entry.root) || entry.root.startsWith('/') || entry.root.split('/').includes('..') || !members.some((member) => member === entry.root || member.startsWith(entry.root.replace(/\/$/, '') + '/'))) throw new Error(`corresponding-source archive omits ${entry.name}`)
104 }
105 if (closure.source_inputs.find((entry) => entry.name === 'bun').revision !== closure.runtime_revision || closure.source_inputs.find((entry) => entry.name === 'webkit').revision !== closure.webkit_revision) throw new Error('corresponding source revisions differ from the actual runtime')
106 const selectedImage = regular(option('--compiled-image'))
107 const imageHash = hosts.sha256(readFileSync(selectedImage))
108 const selectedInfo = JSON.parse(run(selectedImage, ['--codewhale-host-info']))
109 if (selectedInfo.kind !== 'codewhale-extension-host' || selectedInfo.runtime !== 'bun' || selectedInfo.bundle_sha256 !== digest || selectedInfo.version !== closure.runtime_version || selectedInfo.platform !== runtimeInfo.platform || selectedInfo.arch !== runtimeInfo.arch) throw new Error('executed compiled image identity differs from selected Bun/native qualification target')
110 const native = json(regular(option('--native-receipt')))
111 if (typeof native.log !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(native.log) || native.log === '.' || native.log === '..') throw new Error('Native qualification log must be a contained basename')
112 const nativeLog = regular(resolve(dirname(option('--native-receipt')), native.log))
113 hosts.verifyBytes(readFileSync(nativeLog), native.log_sha256, 'Native compiled-host qualification log')
114 if (native.scope !== 'native-compiled-host' || native.source_sha !== sourceSha || native.bundle_sha256 !== digest || native.runtime_sha256 !== runtimeHash || native.host_sha256 !== imageHash || native.platform !== process.platform || native.arch !== process.arch || (!Number.isSafeInteger(native.passed) || native.passed < hosts.nativeMinimum(target)) || native.failed !== 0 || native.skipped !== 0) throw new Error('missing exact-source matching Native compiled-image containment and memory qualification; fake Core alone is insufficient')
115 if (process.platform === 'linux' && (native.libc?.scope !== 'native-runner-observed' || native.libc?.family !== closure.libc)) throw new Error('Linux runtime libc claim is not qualified by this actual Native runner; glibc proof does not qualify musl')
116 const output = resolve(option('--output-dir'))
117 mkdirSync(output, { recursive: true })
118 const binary = join(output, hosts.HOST_NAME + (process.platform === 'win32' ? '.exe' : ''))
119 if (selectedImage !== binary) copyFileSync(selectedImage, binary)
120 if (hosts.sha256(readFileSync(binary)) !== imageHash) throw new Error('compiled image changed during staging')
121 if (process.platform !== 'win32') chmodSync(binary, 0o755)
122 const testLog = run(process.execPath, ['--test', '--test-reporter=tap', 'test/compiled-host.test.mjs'], { cwd: join(root, 'crates/tui/extension-host'), env: { ...cleanEnv, CODEWHALE_COMPILED_HOST_TEST_BINARY: binary, CODEWHALE_BUN_TEST_BINARY: bun } })
123 writeFileSync(join(output, 'compiled-host-qualification.log'), testLog)
124 const count = (name) => Number(testLog.match(new RegExp(`^# ${name} (\\d+)$`, 'm'))?.[1] ?? -1)
125 if (count('pass') !== hosts.compiledMinimum(target) || count('fail') !== 0 || count('skipped') !== 0) throw new Error('compiled-image suite did not run every required case successfully')
126 const info = JSON.parse(run(binary, ['--codewhale-host-info']))
127 if (info.kind !== 'codewhale-extension-host' || info.bundle_sha256 !== digest || info.version !== closure.runtime_version || info.runtime !== 'bun' || info.platform !== runtimeInfo.platform || info.arch !== runtimeInfo.arch) throw new Error('compiled identity differs after actual image qualification')
128 if (hosts.sha256(readFileSync(binary)) !== imageHash) throw new Error('qualified image changed during the acceptance probes')
129 copyFileSync(binary, join(output, names.binary))
130 // These are complete runtime + canonical JS dependency notices and complete
131 // corresponding source, not a URL-only promise or a fabricated MIT label.
132 writeFileSync(join(output, names.notices), Buffer.concat([readFileSync(notices), Buffer.from('\n\nCodewhale extension host dependencies:\n'), readFileSync(join(root, 'crates/tui/extension-host/dist/LICENSES.txt'))]))
133 copyFileSync(source, join(output, names.source))
134 const catalog = { schema: 1, version, source_sha: sourceSha, bundle_sha256: digest, hosts: [{
135 target, asset: names.binary, sha256: hosts.sha256(readFileSync(binary)),
136 notices_asset: names.notices, notices_sha256: hosts.sha256(readFileSync(join(output, names.notices))),
137 source_asset: names.source, source_sha256: hosts.sha256(readFileSync(source)),
138 runtime_version: closure.runtime_version, runtime_revision: closure.runtime_revision, runtime_sha256: runtimeHash,
139 webkit_revision: closure.webkit_revision, bundle_sha256: digest, source_commit: sourceSha,
140 native_platform: info.platform, native_arch: info.arch, libc: closure.libc,
141 passed: count('pass'), failed: 0, skipped: 0, test_log_sha256: hosts.sha256(Buffer.from(testLog)),
142 native_passed: native.passed, native_failed: 0, native_skipped: 0, native_log_sha256: native.log_sha256,
143 native_runner_libc: native.libc ?? null,
144 license_closure: 'complete', relink_source: 'complete',
145 }] }
146 hosts.parseCatalog(catalog, version)
147 writeFileSync(join(output, hosts.HOST_CATALOG), JSON.stringify(catalog, null, 2) + '\n')
148 hosts.verifyDirectory(output, catalog)
149 console.log(`Staged opt-in ${target}; Node remains default`)
150 }
151
151 lines Plain Text