diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 4efffe0..a5fa118 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -92,7 +92,7 @@ Prepare the self-contained arm64 release candidate: npm run release:rc:arm64 ``` -That command prepares portable Python, the pinned core pack, bundled FFmpeg/FFprobe, the frontend, and the release-configured Electron app. It runs the packaged runtime, backend, optional-capability, DMG, signing-readiness, and release-metadata gates, then writes `dist/release-candidate/SHA256SUMS.txt`, `dist/release-candidate/release-manifest.json`, and `dist/release-candidate/RELEASE_NOTES.md`. +That command prepares portable Python, the pinned core pack, bundled FFmpeg/FFprobe, the frontend, and the release-configured Electron app. The packaged Python is an execution-only private runtime: build/install tooling such as pip/ensurepip, development headers, venv/IDLE, and Tcl/Tk GUI assets are removed after the core pack has been assembled and verified. It runs the packaged runtime, backend, optional-capability, DMG, signing-readiness, and release-metadata gates, then writes `dist/release-candidate/SHA256SUMS.txt`, `dist/release-candidate/release-manifest.json`, and `dist/release-candidate/RELEASE_NOTES.md`. Run the extended native model gate explicitly when requested: diff --git a/scripts/check-packaged-runtime.js b/scripts/check-packaged-runtime.js index 6c482a9..76ea57d 100644 --- a/scripts/check-packaged-runtime.js +++ b/scripts/check-packaged-runtime.js @@ -153,6 +153,13 @@ function inspectPackage(appPath) { fail(`runtime manifest is not valid JSON: ${error.message}`); } const normalized = validateRuntimeManifest(manifest, { expectedTarget: 'darwin-arm64' }); + if ( + normalized.python.profile !== 'execution-only' + || normalized.python.buildToolingBundled !== false + || normalized.python.guiToolkitBundled !== false + ) { + fail('packaged Python must declare the execution-only runtime profile without build or GUI tooling'); + } const constraints = manifest.packs?.constraints; if (!constraints || typeof constraints.source !== 'string' || !/^[a-f0-9]{64}$/.test(constraints.sha256 || '')) { fail('runtime manifest is missing valid core dependency constraints provenance'); @@ -195,6 +202,7 @@ function inspectPackage(appPath) { } const pythonPath = resolveResourcePath(resourcesPath, normalized.python.executable, 'Python executable'); + const portablePythonRoot = path.dirname(path.dirname(pythonPath)); const backendRoot = resolveResourcePath(resourcesPath, normalized.backend.root, 'backend root'); const corePackRoot = resolveResourcePath(resourcesPath, normalized.packs.core, 'core pack root'); assertExecutable(pythonPath, 'Bundled Python'); @@ -233,6 +241,20 @@ function inspectPackage(appPath) { if (!/arm64/i.test(architecture)) fail(`bundled Python architecture is not arm64: ${architecture.trim()}`); const forbidden = findForbiddenResourceEntry(resourcesPath); if (forbidden) fail(`packaged Resources contains forbidden developer/cache data: ${forbidden}`); + for (const relativePath of [ + 'include', + 'lib/python3.11/site-packages', + 'lib/python3.11/ensurepip', + 'lib/python3.11/idlelib', + 'lib/python3.11/tkinter', + 'lib/libtcl9.0.dylib', + 'lib/libtcl9tk9.0.dylib', + 'bin/pip', + ]) { + if (fs.existsSync(path.join(portablePythonRoot, ...relativePath.split('/')))) { + fail(`packaged portable Python still contains build-only tooling: ${relativePath}`); + } + } const bundledModel = findResourceFile(resourcesPath, 'base.pt'); if (bundledModel) fail(`packaged Resources contains the Whisper model weight: ${path.relative(resourcesPath, bundledModel)}`); @@ -249,6 +271,7 @@ function inspectPackage(appPath) { console.log(`Backend: ${backendSize} bytes`); console.log(`FFmpeg/bin: ${binSize} bytes`); console.log(`Manifest: ${normalized.schema}, ${normalized.target.id}, Python ${normalized.python.version}`); + console.log('Python profile: execution-only; build tooling: No; GUI toolkit: No'); console.log(`Core constraints: ${constraints.source}, SHA-256 ${constraints.sha256}`); console.log(`Core dependency lock: ${dependencyLock.source}, SHA-256 ${dependencyLock.sha256}, requireHashes=true, buildIsolation=false`); console.log(`Model manifest: ${modelManifest.id}, ${modelManifest.revision}, ${modelManifest.expectedBytes} bytes`); diff --git a/scripts/prepare-python-runtime.js b/scripts/prepare-python-runtime.js index ba6eec9..f005a54 100644 --- a/scripts/prepare-python-runtime.js +++ b/scripts/prepare-python-runtime.js @@ -28,6 +28,37 @@ const coreConstraintsPath = path.join(root, 'runtime', 'core-darwin-arm64-py311. const coreLockPath = path.join(root, 'runtime', 'core-darwin-arm64-py311.lock.txt'); // Torch imports torch.testing through its normal autograd import path. const RUNTIME_REQUIRED_TEST_DIRECTORIES = new Set(['torch/testing']); +const PORTABLE_RUNTIME_BUILD_ONLY_PATHS = [ + 'include', + 'lib/pkgconfig', + 'lib/python3.11/site-packages', + 'lib/python3.11/ensurepip', + 'lib/python3.11/idlelib', + 'lib/python3.11/pydoc_data', + 'lib/python3.11/tkinter', + 'lib/python3.11/turtledemo', + 'lib/python3.11/lib2to3', + 'lib/python3.11/venv', + 'lib/python3.11/lib-dynload/_tkinter.cpython-311-darwin.so', + 'lib/tcl9.0', + 'lib/tcl9', + 'lib/tk9.0', + 'lib/itcl4.3.8', + 'lib/thread3.0.6', + 'lib/libtcl9.0.dylib', + 'lib/libtcl9tk9.0.dylib', + 'bin/2to3', + 'bin/2to3-3.11', + 'bin/idle3', + 'bin/idle3.11', + 'bin/pip', + 'bin/pip3', + 'bin/pip3.11', + 'bin/pydoc3', + 'bin/pydoc3.11', + 'bin/python3-config', + 'bin/python3.11-config', +]; function getOption(name) { const index = process.argv.indexOf(name); @@ -280,6 +311,9 @@ function writeManifest(relativeInterpreter, version) { build: PYTHON_RUNTIME_ARTIFACT.build, source: PYTHON_RUNTIME_ARTIFACT.source, archiveSha256: PYTHON_RUNTIME_ARTIFACT.sha256, + profile: 'execution-only', + buildToolingBundled: false, + guiToolkitBundled: false, }, backend: { root: 'backend' }, packs: { @@ -394,6 +428,23 @@ function prunePackageTestArtifacts(directory) { return removed; } +function prunePortableRuntimeBuildTooling() { + const removed = { paths: 0, files: 0, bytes: 0 }; + for (const relativePath of PORTABLE_RUNTIME_BUILD_ONLY_PATHS) { + const entryPath = path.join(runtimeRoot, ...relativePath.split('/')); + if (!fs.lstatSync(entryPath, { throwIfNoEntry: false })) fail(`expected portable build-only path is missing: ${relativePath}`); + const stats = directoryStats(entryPath); + fs.rmSync(entryPath, { recursive: true, force: false }); + if (fs.lstatSync(entryPath, { throwIfNoEntry: false })) { + fail(`portable build-only path still exists after pruning: ${relativePath}`); + } + removed.paths += 1; + removed.files += stats.files; + removed.bytes += stats.bytes; + } + return removed; +} + async function main() { verifyTarget(); fs.mkdirSync(buildRoot, { recursive: true }); @@ -408,9 +459,11 @@ async function main() { console.log(`Core pack bytes before pruning: ${coreBefore.bytes}`); const headersRemoved = pruneNonRuntimeCoreArtifacts(); const testsRemoved = prunePackageTestArtifacts(corePackRoot); + const portableBuildToolingRemoved = prunePortableRuntimeBuildTooling(); const coreAfter = directoryStats(corePackRoot); console.log(`Torch headers removed: ${headersRemoved.files}`); console.log(`Package test artifacts removed: ${testsRemoved.directories} directories, ${testsRemoved.files} files, ${testsRemoved.bytes} bytes`); + console.log(`Portable Python build-only tooling removed: ${portableBuildToolingRemoved.paths} paths, ${portableBuildToolingRemoved.files} files, ${portableBuildToolingRemoved.bytes} bytes`); console.log(`Core pack files after pruning: ${coreAfter.files}`); console.log(`Core pack bytes after pruning: ${coreAfter.bytes}`); verifyCore(interpreterPath, relativeInterpreter); diff --git a/scripts/smoke-runtime-artifacts.js b/scripts/smoke-runtime-artifacts.js index 49c6161..ace151b 100644 --- a/scripts/smoke-runtime-artifacts.js +++ b/scripts/smoke-runtime-artifacts.js @@ -36,7 +36,21 @@ const runtimePreparation = fs.readFileSync(path.join(__dirname, 'prepare-python- assert.match(runtimePreparation, /function\s+pruneNonRuntimeCoreArtifacts\s*\(/); assert.match(runtimePreparation, /function\s+prunePythonBytecode\s*\(/); assert.match(runtimePreparation, /function\s+prunePackageTestArtifacts\s*\(/); +assert.match(runtimePreparation, /function\s+prunePortableRuntimeBuildTooling\s*\(/); +assert.match(runtimePreparation, /profile:\s*['"]execution-only['"]/); +assert.match(runtimePreparation, /buildToolingBundled:\s*false/); +assert.match(runtimePreparation, /guiToolkitBundled:\s*false/); assert.match(runtimePreparation, /RUNTIME_REQUIRED_TEST_DIRECTORIES\s*=\s*new Set\(\[['"]torch\/testing['"]\]\)/); +for (const expectedBuildOnlyPath of [ + 'lib/python3.11/site-packages', + 'lib/python3.11/ensurepip', + 'lib/python3.11/tkinter', + 'lib/python3.11/lib-dynload/_tkinter.cpython-311-darwin.so', + 'lib/libtcl9.0.dylib', + 'lib/libtcl9tk9.0.dylib', +]) { + assert.ok(runtimePreparation.includes(`'${expectedBuildOnlyPath}'`), `portable runtime prune is missing ${expectedBuildOnlyPath}`); +} assert.match(runtimePreparation, /['"]--no-compile['"]/); assert.match(runtimePreparation, /['"]--no-build-isolation['"]/); assert.match(runtimePreparation, /['"]--require-hashes['"]/);