From 3136e2200c3152cdb591bcfbe7f9c931273e5b81 Mon Sep 17 00:00:00 2001 From: YiJie Date: Mon, 17 Aug 2026 13:35:11 +0800 Subject: [PATCH] fix(release): parse noisy pnpm pack output --- scripts/__tests__/npm-publish-guard.spec.ts | 67 +++++++++++++++-- scripts/npm-publish-guard.d.mts | 9 ++- scripts/npm-publish-guard.mjs | 83 +++++++++++++++++++-- 3 files changed, 146 insertions(+), 13 deletions(-) diff --git a/scripts/__tests__/npm-publish-guard.spec.ts b/scripts/__tests__/npm-publish-guard.spec.ts index cecdf973..1f7701ce 100644 --- a/scripts/__tests__/npm-publish-guard.spec.ts +++ b/scripts/__tests__/npm-publish-guard.spec.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' @@ -9,6 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { evaluateNpmPublishMode, executeFrozenPublish, + extractPnpmPackRecord, freezeApprovedTarballs, npmOidcAudience, preparePublishWorkspaceDependencies, @@ -268,25 +269,79 @@ describe('npm publish guard', () => { ).resolves.toMatchObject({ complete: false }) }) - it('freezes local pnpm-pack bytes for a source package and its alias with absolute filenames', async () => { + it('extracts exactly one documented pnpm pack record from lifecycle output', () => { + const metadata = { + name: item.name, + version: item.version, + filename: '/tmp/package.tgz', + files: [] + } + expect(extractPnpmPackRecord(`\u001B[32mlifecycle output\u001B[0m\n${JSON.stringify(metadata)}`, item)).toEqual( + metadata + ) + expect(extractPnpmPackRecord(`\u001B[36mlifecycle output\u001B[0m${JSON.stringify(metadata)}`, item)).toEqual( + metadata + ) + expect(extractPnpmPackRecord(`[prepack warning${JSON.stringify(metadata)}`, item)).toEqual(metadata) + expect(extractPnpmPackRecord(`lifecycle output\n${JSON.stringify([metadata])}\nmore lifecycle output`, item)) + .toEqual( + metadata + ) + expect(() => extractPnpmPackRecord(`${JSON.stringify(metadata)}\n${JSON.stringify(metadata)}`, item)).toThrow( + 'exactly one documented JSON result' + ) + expect(() => extractPnpmPackRecord('{"filename":"package.tgz"}', item)).toThrow( + 'exactly one documented JSON result' + ) + expect(() => extractPnpmPackRecord('{ '.repeat(10_000), item)).toThrow('incomplete JSON output') + }) + + it('freezes lifecycle-noisy source and alias tarballs, then publishes those exact bytes', async () => { const directory = await mkdtemp(path.join(tmpdir(), 'npm-publish-guard-pack-')) + const outputDirectory = await mkdtemp(path.join(tmpdir(), 'npm-publish-guard-output-')) const source = { name: 'source-package', version: '1.0.0', dir: directory, publishAliasFor: null } const alias = { ...source, name: 'alias-package', publishAliasFor: 'source-package' } + const calls: string[][] = [] try { await writeFile( path.join(directory, 'package.json'), JSON.stringify({ name: source.name, version: source.version, - bin: { 'source-package': './cli.js' } + bin: { 'source-package': './cli.js' }, + scripts: { prepack: 'node prepack.cjs' } }) ) await writeFile(path.join(directory, 'cli.js'), 'console.log("ok")\n') - const frozen = await freezeApprovedTarballs({ items: [source, alias] }) - expect(frozen.get(source.name)?.sha512).toHaveLength(128) - expect(frozen.get(alias.name)?.sha512).toHaveLength(128) + await writeFile( + path.join(directory, 'prepack.cjs'), + "process.stdout.write('\\u001B[32mnoisy lifecycle output\\u001B[0m[prepack warning')\n" + ) + const frozen = await freezeApprovedTarballs({ items: [source, alias], outputDir: outputDirectory }) + const canonicalOutputDirectory = await realpath(outputDirectory) + const sourceTarball = frozen.get(source.name) + const aliasTarball = frozen.get(alias.name) + expect(sourceTarball?.filePath?.startsWith(`${canonicalOutputDirectory}${path.sep}`)).toBe(true) + expect(aliasTarball?.filePath?.startsWith(`${canonicalOutputDirectory}${path.sep}`)).toBe(true) + expect(sourceTarball?.filePath).not.toBe(aliasTarball?.filePath) + expect(sourceTarball?.sha512).toHaveLength(128) + expect(aliasTarball?.sha512).toHaveLength(128) + await expect(executeFrozenPublish({ + items: [source, alias], + approvedTarballs: frozen, + publishTag: 'rc', + dryRun: true, + preflightMetadata: new Map([[source.name, { versions: {} }], [alias.name, { versions: {} }]]), + fetchImpl: async () => new Response(null, { status: 404 }), + runCommand: (_command: string, args: string[]) => { + calls.push(args) + return { status: 0, stdout: '', stderr: '' } + } + })).resolves.toEqual({ attempts: [{ name: source.name, status: 0 }, { name: alias.name, status: 0 }] }) + expect(calls.map(args => args[1])).toEqual([sourceTarball?.filePath, aliasTarball?.filePath]) } finally { await rm(directory, { recursive: true, force: true }) + await rm(outputDirectory, { recursive: true, force: true }) } }) diff --git a/scripts/npm-publish-guard.d.mts b/scripts/npm-publish-guard.d.mts index 97a6267a..b8839b2e 100644 --- a/scripts/npm-publish-guard.d.mts +++ b/scripts/npm-publish-guard.d.mts @@ -14,6 +14,10 @@ export declare const npmRegistryPropagationAttempts: number export declare const npmRegistryPropagationDelayMs: number export declare const npmPublishAuthModes: Set export declare function redactNpmPublishSecrets(value: unknown, secrets?: string[]): string +export declare function extractPnpmPackRecord( + stdout: unknown, + item: { name: string; version: string } +): { name: string; version: string; filename: string; files: unknown[] } export declare function evaluateNpmPublishMode( input: { mode: NpmPublishAuthMode @@ -45,7 +49,10 @@ export declare function waitForNpmRegistryVersions( } ): Promise<{ attemptsUsed: number }> export declare function freezeApprovedTarballs( - input: { items: Array<{ name: string; version: string }> } + input: { + items: Array<{ name: string; version: string }> + outputDir?: string + } ): Promise> export declare function preparePublishWorkspaceDependencies( input: { diff --git a/scripts/npm-publish-guard.mjs b/scripts/npm-publish-guard.mjs index cca0e616..c63e922f 100644 --- a/scripts/npm-publish-guard.mjs +++ b/scripts/npm-publish-guard.mjs @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer' import { spawnSync } from 'node:child_process' import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' @@ -155,6 +155,80 @@ export async function proveOidcExchangesBeforePublish({ selectedItems, requestTo return { exchangedIdentityCount: selectedItems.length } } +const pnpmPackRecord = (value, item) => { + const records = Array.isArray(value) ? value : [value] + if (records.length !== 1) return null + const record = records[0] + if ( + record == null || typeof record !== 'object' || record.name !== item.name || record.version !== item.version || + typeof record.filename !== 'string' || !record.filename || !Array.isArray(record.files) + ) return null + return record +} + +const isPlausibleJsonRoot = (output, start) => { + const character = output[start] + const next = output[start + 1] + if (character === '{') return next === '"' || next === '}' || /\s/.test(next) + return character === '[' && (next === '{' || next === ']' || /\s/.test(next)) +} + +export function extractPnpmPackRecord(stdout, item) { + const output = String(stdout) + const records = [] + for (let start = 0; start < output.length; start += 1) { + if (!isPlausibleJsonRoot(output, start)) continue + let depth = 0 + let inString = false + let escaped = false + for (let end = start; end < output.length; end += 1) { + const character = output[end] + if (inString) { + if (escaped) escaped = false + else if (character === '\\') escaped = true + else if (character === '"') inString = false + continue + } + if (character === '"') { + inString = true + continue + } + if (character === '{' || character === '[') depth += 1 + else if (character === '}' || character === ']') depth -= 1 + if (depth !== 0) continue + try { + const record = pnpmPackRecord(JSON.parse(output.slice(start, end + 1)), item) + if (record != null) records.push(record) + } catch { + // Lifecycle output can contain braces. Only complete JSON documents matching pnpm's pack schema count. + } + start = end + break + } + if (depth !== 0) throw new Error(`${item.name} local pack returned incomplete JSON output.`) + } + if (records.length !== 1) { + throw new Error(`${item.name} local pack did not return exactly one documented JSON result.`) + } + return records[0] +} + +const readPackedTarball = async ({ item, outputDir, filename }) => { + const candidatePath = path.resolve(outputDir, filename) + const [canonicalOutputDir, canonicalCandidatePath] = await Promise.all([realpath(outputDir), realpath(candidatePath)]) + const relativePath = path.relative(canonicalOutputDir, canonicalCandidatePath) + if ( + !relativePath || relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) || + path.extname(canonicalCandidatePath) !== '.tgz' + ) throw new Error(`${item.name} local pack returned a tarball outside the frozen output directory.`) + const candidateStats = await lstat(candidatePath) + if (!candidateStats.isFile() || candidateStats.isSymbolicLink()) { + throw new Error(`${item.name} local pack returned an unsafe tarball path.`) + } + return { bytes: await readFile(canonicalCandidatePath), filePath: canonicalCandidatePath } +} + const defaultPackPackage = async ({ item, outputDir }) => { const restore = stagePublishAliasManifest(item) try { @@ -164,11 +238,8 @@ const defaultPackPackage = async ({ item, outputDir }) => { stdio: 'pipe' }) if (result.status !== 0) throw new Error(`${item.name} local pack failed.`) - const output = JSON.parse(String(result.stdout)) - const filename = Array.isArray(output) ? output[0]?.filename : output?.filename - if (!filename) throw new Error(`${item.name} local pack did not return a filename.`) - const filePath = path.isAbsolute(filename) ? filename : path.join(outputDir, filename) - return { bytes: await readFile(filePath), filePath } + const { filename } = extractPnpmPackRecord(result.stdout, item) + return readPackedTarball({ item, outputDir, filename }) } finally { restore?.() }