|
| 1 | +#!/usr/bin/env node |
| 2 | +// Prepublish gate for the @pnpm/<platform> artifact packages. Runs from the |
| 3 | +// package directory (cwd contains the built pnpm binary). Verifies: |
| 4 | +// 1. The binary exists with the expected filename for the target. |
| 5 | +// 2. If the host can execute the target, `pnpm -v` returns a semver. |
| 6 | +// |
| 7 | +// Existence alone is not sufficient — @pnpm/[email protected] shipped a binary |
| 8 | +// that was present but crashed with a native SEA deserialization assertion on |
| 9 | +// any invocation. Executing -v would have caught it on the Linux CI host. |
| 10 | +// |
| 11 | +// Each platform package ships only the SEA binary (no dist/ or node_modules), |
| 12 | +// but the SEA's CJS entry (pnpm.cjs) loads dist/pnpm.mjs from |
| 13 | +// dirname(process.execPath). To run the binary in place we symlink |
| 14 | +// ./dist -> ../exe/dist (the sibling @pnpm/exe package's staged bundle) for |
| 15 | +// the duration of the test, then remove the symlink on exit. The platform |
| 16 | +// package's "files" whitelist is "pnpm" only, so a stale symlink would never |
| 17 | +// reach the published tarball, but we clean up anyway to leave the tree |
| 18 | +// untouched for subsequent tools. |
| 19 | +import { execFileSync } from 'node:child_process' |
| 20 | +import fs from 'node:fs' |
| 21 | +import path from 'node:path' |
| 22 | +import process from 'node:process' |
| 23 | + |
| 24 | +// Resolves via the pnpm CLI's own node_modules (which always contains |
| 25 | +// symlink-dir — the CLI depends on it directly). symlink-dir handles Windows |
| 26 | +// junctions internally, so the verifier doesn't need its own elevation / |
| 27 | +// link-type branching. |
| 28 | +import { symlinkDirSync } from 'symlink-dir' |
| 29 | + |
| 30 | +const [targetOs, targetArch, targetLibc] = process.argv.slice(2) |
| 31 | +if (!targetOs || !targetArch) { |
| 32 | + console.error('Usage: verify-binary.mjs <os> <arch> [libc]') |
| 33 | + process.exit(2) |
| 34 | +} |
| 35 | + |
| 36 | +const binName = targetOs === 'win32' ? 'pnpm.exe' : 'pnpm' |
| 37 | +if (!fs.existsSync(binName)) { |
| 38 | + console.error(`Error: ${binName} is missing in ${process.cwd()}`) |
| 39 | + process.exit(1) |
| 40 | +} |
| 41 | + |
| 42 | +// Node populates header.glibcVersionRuntime only on glibc hosts, so its |
| 43 | +// presence is a reliable glibc/musl discriminator without shelling out. |
| 44 | +function detectHostLibc () { |
| 45 | + if (process.platform !== 'linux') return null |
| 46 | + const header = process.report.getReport().header |
| 47 | + return header.glibcVersionRuntime ? 'glibc' : 'musl' |
| 48 | +} |
| 49 | +const hostLibc = detectHostLibc() |
| 50 | + |
| 51 | +// Cross-platform or cross-libc targets can't be executed from the publish |
| 52 | +// host. Existence is the best we can verify — skip the -v check instead of |
| 53 | +// failing, so a musl artifact published from a glibc CI still goes through. |
| 54 | +const osMatches = process.platform === targetOs |
| 55 | +const archMatches = process.arch === targetArch |
| 56 | +const libcMatches = targetOs !== 'linux' || !targetLibc || targetLibc === hostLibc |
| 57 | + |
| 58 | +if (!osMatches || !archMatches || !libcMatches) { |
| 59 | + const targetLabel = [targetOs, targetArch, targetLibc].filter(Boolean).join('/') |
| 60 | + const hostLabel = [process.platform, process.arch, hostLibc].filter(Boolean).join('/') |
| 61 | + console.log(`Skipping ${binName} -v: host ${hostLabel} cannot execute target ${targetLabel}`) |
| 62 | + process.exit(0) |
| 63 | +} |
| 64 | + |
| 65 | +const distLinkPath = path.resolve('dist') |
| 66 | +const distLinkTarget = path.join('..', 'exe', 'dist') |
| 67 | +let distLinkCreated = false |
| 68 | +// Remove a prior symlink from an aborted run so cleanup ownership is always |
| 69 | +// well-defined. A real dist/ directory (unlikely in a platform package, but |
| 70 | +// possible during development) is preserved — we treat it as external and |
| 71 | +// skip cleanup. |
| 72 | +try { |
| 73 | + if (fs.lstatSync(distLinkPath).isSymbolicLink()) fs.unlinkSync(distLinkPath) |
| 74 | +} catch (err) { |
| 75 | + if (err.code !== 'ENOENT') throw err |
| 76 | +} |
| 77 | +const distPreexists = fs.existsSync(distLinkPath) |
| 78 | + |
| 79 | +process.on('exit', () => { |
| 80 | + if (!distLinkCreated) return |
| 81 | + try { fs.unlinkSync(distLinkPath) } catch { /* nothing to clean up */ } |
| 82 | +}) |
| 83 | + |
| 84 | +// Relocation check: before staging dist/, confirm the binary reads its bundle |
| 85 | +// path from process.execPath at runtime and not from a build-time constant. |
| 86 | +// A pnpm.cjs shim that accidentally captured __filename or a cwd-relative |
| 87 | +// path during packaging would keep working on the build machine but break on |
| 88 | +// every end-user machine. Asserting the error references the *runtime* cwd |
| 89 | +// catches that regression here instead of after publish. |
| 90 | +// |
| 91 | +// Skipped when a real dist/ is already present (developer layout); in that |
| 92 | +// case we can't distinguish a correctly-resolved dist from a hardcoded one. |
| 93 | +if (!distPreexists) { |
| 94 | + const expectedRuntimeDist = path.join(fs.realpathSync(process.cwd()), 'dist', 'pnpm.mjs') |
| 95 | + let sansDistStdout |
| 96 | + try { |
| 97 | + sansDistStdout = execFileSync(`./${binName}`, ['-v'], { |
| 98 | + encoding: 'utf8', |
| 99 | + timeout: 30_000, |
| 100 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 101 | + }) |
| 102 | + } catch (err) { |
| 103 | + const stderr = String(err?.stderr ?? '') |
| 104 | + // Expected: binary tried to require the runtime dist path and failed |
| 105 | + // because it isn't there. Anything else (a spawn error, crash signal, |
| 106 | + // timeout) is NOT evidence of a non-relocatable pnpm.cjs — it's an |
| 107 | + // unrelated failure that would hide the regression we actually care |
| 108 | + // about. Surface it with the raw diagnostic so the operator can tell |
| 109 | + // which one they're looking at. |
| 110 | + if (!stderr.includes(expectedRuntimeDist)) { |
| 111 | + const status = err?.status ?? 'none' |
| 112 | + const signal = err?.signal ?? 'none' |
| 113 | + const code = err?.code ?? 'none' |
| 114 | + console.error(`Error: ${binName} -v without dist/ did not fail with a missing-runtime-dist error. Either pnpm.cjs regressed to a non-relocatable form, or the binary failed for an unrelated reason. status=${status} signal=${signal} code=${code}\nstderr:\n${stderr}`) |
| 115 | + process.exit(1) |
| 116 | + } |
| 117 | + } |
| 118 | + if (sansDistStdout !== undefined) { |
| 119 | + console.error(`Error: ${binName} -v unexpectedly succeeded without dist/ alongside the binary. Output: ${JSON.stringify(sansDistStdout.trim())}. pnpm.cjs is loading a bundle from somewhere other than dirname(process.execPath); the published binary would ignore the dist shipped in @pnpm/exe.`) |
| 120 | + process.exit(1) |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +// Only stage the symlink when nothing's there already — symlink-dir will |
| 125 | +// atomically rename away any existing dir/file, which would silently drop a |
| 126 | +// developer's staged dist/ directory. |
| 127 | +if (!distPreexists) { |
| 128 | + try { |
| 129 | + symlinkDirSync(distLinkTarget, distLinkPath) |
| 130 | + distLinkCreated = true |
| 131 | + } catch (err) { |
| 132 | + console.error(`Error: could not stage dist/ symlink: ${String(err)}`) |
| 133 | + process.exit(1) |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +let stdout |
| 138 | +try { |
| 139 | + stdout = execFileSync(`./${binName}`, ['-v'], { encoding: 'utf8', timeout: 30_000 }).trim() |
| 140 | +} catch (err) { |
| 141 | + console.error(`Error: ${binName} -v failed: ${String(err)}`) |
| 142 | + process.exit(1) |
| 143 | +} |
| 144 | + |
| 145 | +// Accept SemVer 2 with optional prerelease and build-metadata suffixes so a |
| 146 | +// future `11.0.0-rc.4+sha.<hash>` release doesn't fail this gate spuriously. |
| 147 | +if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(stdout)) { |
| 148 | + console.error(`Error: ${binName} -v produced unexpected output: ${JSON.stringify(stdout)}`) |
| 149 | + process.exit(1) |
| 150 | +} |
| 151 | + |
| 152 | +console.log(`${binName} -v OK (${stdout})`) |
0 commit comments