fix(scripts): keep the Python runtime payload out of its own deploy root

pnpm's legacy deploy links the deployed project into the target's node_modules
when the target sits inside that project. Materializing that link copies the
payload into itself and fails with ERR_FS_CP_EINVAL, which broke the
release-shaped Python runtime matrix on Linux and Windows. The staged payload
never carries the project source, so the staging tree's owner drops the entry
and leaves the deploy root's files alone.
This commit is contained in:
Tianyi Cui committed 2026-10-07 12:58:25 +08:00
1 parent 28c74ba0e8
commit 4260fd2451
3 files changed
+127 -36

No files matched your search

@@ -0,0 +1,66 @@
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts'
const temporaryDirectories: string[] = []
const directoryLinkType = process.platform === 'win32' ? 'junction' : 'dir'
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map(directory => rm(directory, { recursive: true, force: true })))
})
async function fixture(prefix: string) {
const workspace = await mkdtemp(join(tmpdir(), prefix))
temporaryDirectories.push(workspace)
return workspace
}
/** List every symbolic link below a directory. */
async function listSymlinks(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true })
const nested = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name)
if (entry.isSymbolicLink()) return [path]
if (entry.isDirectory()) return listSymlinks(path)
return []
}))
return nested.flat()
}
it('drops the deploy root link that points back into the payload', async () => {
const workspace = await fixture('dsh-python-staging-root-')
const project = join(workspace, 'python', 'sdk-runtime')
const staging = join(project, 'src', 'runtime', 'node')
await mkdir(join(staging, 'node_modules'), { recursive: true })
await writeFile(join(project, 'package.json'), '{"name":"dsh-python-runtime-closure"}\n')
await symlink(project, join(staging, 'node_modules', 'dsh-python-runtime-closure'), directoryLinkType)
await materializeStagedLinks(staging)
expect(existsSync(join(staging, 'node_modules', 'dsh-python-runtime-closure'))).toBe(false)
await expect(readFile(join(project, 'package.json'), 'utf8')).resolves.toContain('dsh-python-runtime-closure')
})
it('copies workspace package links as files and removes command shims', async () => {
const workspace = await fixture('dsh-python-staging-packages-')
const staging = join(workspace, 'runtime', 'node')
const packageDirectory = join(workspace, 'packages', 'dsh-base')
await mkdir(join(packageDirectory, 'lib'), { recursive: true })
await writeFile(join(packageDirectory, 'package.json'), '{"name":"@deepseek-ai/dsh-base"}\n')
await writeFile(join(packageDirectory, 'lib', 'index.js'), 'export {}\n')
await mkdir(join(staging, 'node_modules', '@deepseek-ai'), { recursive: true })
await mkdir(join(staging, 'node_modules', '.bin'), { recursive: true })
await writeFile(join(staging, 'package.json'), '{"name":"dsh-python-runtime-closure"}\n')
await symlink(packageDirectory, join(staging, 'node_modules', '@deepseek-ai', 'dsh-base'), directoryLinkType)
await symlink(packageDirectory, join(staging, 'node_modules', '.bin', 'dsh'), directoryLinkType)
await materializeStagedLinks(staging)
expect(existsSync(join(staging, 'node_modules', '.bin'))).toBe(false)
await expect(readFile(join(staging, 'node_modules', '@deepseek-ai', 'dsh-base', 'lib', 'index.js'), 'utf8'))
.resolves.toBe('export {}\n')
expect(await listSymlinks(staging)).toEqual([])
})
@@ -0,0 +1,58 @@
/** Turn the deploy-time package links of the Python runtime payload into real files. */
import { cp, lstat, readdir, realpath, rm } from 'node:fs/promises'
import { join, sep } from 'node:path'
/** Return the first symbolic link below a directory, if one exists. */
async function findSymlink(directory: string): Promise<string | undefined> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
const metadata = await lstat(path)
if (metadata.isSymbolicLink()) return path
if (metadata.isDirectory()) {
const nested = await findSymlink(path)
if (nested !== undefined) return nested
}
}
return undefined
}
/**
* Replace every deploy-time package link below the staged payload with real files.
*
* Legacy deploy links workspace packages into the target's `node_modules`, and
* those links must survive packaging as files. One link is not a package: when
* the deploy target sits inside the deployed project, pnpm links that project
* into its own `node_modules`. Dereferencing it would copy the payload into
* itself, and the packaged payload never carries the project source, so the
* entry is dropped instead.
* @param staging - Deployed payload root whose `node_modules` becomes symlink-free.
*/
export async function materializeStagedLinks(staging: string): Promise<void> {
const payload = await realpath(staging)
const nodeModules = join(staging, 'node_modules')
let remaining = await findSymlink(nodeModules)
while (remaining !== undefined) {
const segments = remaining.slice(nodeModules.length + 1).split(sep)
const binIndex = segments.lastIndexOf('.bin')
if (binIndex >= 0) {
await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true })
remaining = await findSymlink(nodeModules)
continue
}
const destination = remaining
const source = await realpath(destination)
if (payload === source || payload.startsWith(source + sep)) {
await rm(destination, { recursive: true, force: true })
remaining = await findSymlink(nodeModules)
continue
}
const nestedNodeModules = join(source, 'node_modules')
await rm(destination, { recursive: true, force: true })
await cp(source, destination, {
recursive: true,
dereference: true,
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
})
remaining = await findSymlink(nodeModules)
}
}
+3 -36
View File
@@ -8,12 +8,13 @@
import { spawn } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
import { materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts'
import { preparePrimaryRuntime, smokePrimaryRuntime, type PrimaryRuntimeTarget } from './primary-runtime/prepare.ts'
const root = resolve(import.meta.dirname, '..')
@@ -372,41 +373,7 @@ class SingleExeBuild {
console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links')
return
}
const nodeModules = join(this.staging, 'node_modules')
let remaining = await this.findSymlink(nodeModules)
while (remaining !== undefined) {
const segments = remaining.slice(nodeModules.length + 1).split(sep)
const binIndex = segments.lastIndexOf('.bin')
if (binIndex >= 0) {
await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true })
remaining = await this.findSymlink(nodeModules)
continue
}
const destination = remaining
const source = await realpath(destination)
const nestedNodeModules = join(source, 'node_modules')
await rm(destination, { recursive: true, force: true })
await cp(source, destination, {
recursive: true,
dereference: true,
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
})
remaining = await this.findSymlink(nodeModules)
}
}
/** Return the first symbolic link below a directory, if one exists. */
private async findSymlink(directory: string): Promise<string | undefined> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
const metadata = await lstat(path)
if (metadata.isSymbolicLink()) return path
if (metadata.isDirectory()) {
const nested = await this.findSymlink(path)
if (nested !== undefined) return nested
}
}
return undefined
await materializeStagedLinks(this.staging)
}
/** Add the executable entry and pkg assets to the staged manifest. */