From 1173c06fa5dc65dfe9e2cd96c6b660c6b4fe85df Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 4 Aug 2026 02:49:44 +0000 Subject: [PATCH] fix(release): package Sharp native runtime --- npm-shrinkwrap.json | 5 + package.json | 2 + .../lib/materialize-sharp-release-runtime.ts | 120 ++++++++++++++++++ scripts/package-release-artifact.ts | 7 + tests/release-package-coverage.test.ts | 12 +- tests/sharp-release-runtime.test.ts | 104 +++++++++++++++ 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 scripts/lib/materialize-sharp-release-runtime.ts create mode 100644 tests/sharp-release-runtime.test.ts diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 5039519f..4ea94268 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -164,6 +164,7 @@ "set-function-length": "^1.2.2", "setprototypeof": "^1.2.0", "sha.js": "^2.4.12", + "sharp": "0.34.5", "side-channel": "^1.1.0", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", @@ -782,6 +783,8 @@ }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -824,6 +827,8 @@ }, "node_modules/@img/sharp-libvips-darwin-arm64": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], diff --git a/package.json b/package.json index 2ae4c8ec..16671b1d 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "package:wordpress-plugin": "tsx scripts/build-wordpress-plugin-zip.ts", "release:package": "tsx scripts/package-release-artifact.ts", "test:release-package-coverage": "tsx tests/release-package-coverage.test.ts", + "test:sharp-release-runtime": "tsx --test tests/sharp-release-runtime.test.ts", "wp-codebox": "node packages/cli/dist/index.js", "wp-codebox:source": "node bin/wp-codebox-source.mjs", "generate:browser-fanout-aggregation-runtime": "tsx scripts/generate-browser-fanout-aggregation-runtime.ts", @@ -477,6 +478,7 @@ "set-function-length": "^1.2.2", "setprototypeof": "^1.2.0", "sha.js": "^2.4.12", + "sharp": "0.34.5", "side-channel": "^1.1.0", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", diff --git a/scripts/lib/materialize-sharp-release-runtime.ts b/scripts/lib/materialize-sharp-release-runtime.ts new file mode 100644 index 00000000..6ce3ac12 --- /dev/null +++ b/scripts/lib/materialize-sharp-release-runtime.ts @@ -0,0 +1,120 @@ +import { execFile } from "node:child_process" +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) +const recursiveRmOptions = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } + +const sharpRuntimePackages: Record = { + "linux-arm64": ["@img/sharp-linux-arm64", "@img/sharp-libvips-linux-arm64"], + "linux-x64": ["@img/sharp-linux-x64", "@img/sharp-libvips-linux-x64"], + "macos-arm64": ["@img/sharp-darwin-arm64", "@img/sharp-libvips-darwin-arm64"], + "macos-x64": ["@img/sharp-darwin-x64", "@img/sharp-libvips-darwin-x64"], + "windows-arm64": ["@img/sharp-win32-arm64"], + "windows-x64": ["@img/sharp-win32-x64"], +} + +export interface LockedReleasePackage { + version?: string + resolved?: string + integrity?: string +} + +interface DependencyManifest { + packages?: Record +} + +export function sharpRuntimePackageNames(platformName: string, archName: string): readonly string[] { + const target = `${platformName}-${archName}` + const packages = sharpRuntimePackages[target] + if (!packages) { + throw new Error(`No Sharp native runtime mapping exists for release target ${target}. Supported targets: ${Object.keys(sharpRuntimePackages).sort().join(", ")}.`) + } + return packages +} + +export async function materializeSharpReleaseRuntime( + root: string, + dependencyManifestPath: string, + platformName: string, + archName: string, +): Promise { + const target = `${platformName}-${archName}` + const packageNames = sharpRuntimePackageNames(platformName, archName) + const manifest = JSON.parse(await readFile(dependencyManifestPath, "utf8")) as DependencyManifest + + for (const packageName of packageNames) { + const lockPath = `node_modules/${packageName}` + const lockedPackage = manifest.packages?.[lockPath] + if (!lockedPackage?.version || !lockedPackage.resolved || !lockedPackage.integrity) { + throw new Error(`Cannot materialize Sharp runtime for release target ${target}: ${lockPath} must have version, resolved, and integrity fields in npm-shrinkwrap.json.`) + } + + try { + await materializeLockedPackage(root, packageName, lockedPackage) + } catch (error) { + throw new Error(`Failed to materialize Sharp runtime package ${packageName} for release target ${target}: ${(error as Error).message}`, { cause: error }) + } + } +} + +async function materializeLockedPackage(root: string, packageName: string, lockedPackage: Required): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), "wp-codebox-sharp-runtime-")) + try { + const { stdout } = await execFileAsync( + "npm", + ["pack", lockedPackage.resolved, "--pack-destination", tempRoot, "--json", "--ignore-scripts"], + { cwd: root, maxBuffer: 1024 * 1024 * 20 }, + ) + const [packed] = JSON.parse(stdout) as Array<{ filename?: string }> + if (!packed?.filename) { + throw new Error("npm pack did not report a tarball filename") + } + + const tarball = join(tempRoot, packed.filename) + await materializeVerifiedPackageTarball(root, packageName, lockedPackage, tarball) + } finally { + await rm(tempRoot, recursiveRmOptions) + } +} + +export async function materializeVerifiedPackageTarball( + root: string, + packageName: string, + lockedPackage: Required>, + tarball: string, +): Promise { + await assertIntegrity(tarball, lockedPackage.integrity) + const packageRoot = join(root, "node_modules", ...packageName.split("/")) + await rm(packageRoot, recursiveRmOptions) + await mkdir(packageRoot, { recursive: true }) + await execFileAsync("tar", ["-xzf", tarball, "-C", packageRoot, "--strip-components=1"], { + cwd: root, + maxBuffer: 1024 * 1024 * 10, + }) + + const packageManifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")) as { name?: string; version?: string } + if (packageManifest.name !== packageName || packageManifest.version !== lockedPackage.version) { + throw new Error(`extracted ${packageManifest.name ?? "unknown"}@${packageManifest.version ?? "unknown"}, expected ${packageName}@${lockedPackage.version}`) + } +} + +async function assertIntegrity(path: string, integrity: string): Promise { + const [algorithm, expected] = integrity.split("-", 2) + if (!algorithm || !expected) { + throw new Error(`unsupported package integrity ${integrity}`) + } + let hash + try { + hash = createHash(algorithm) + } catch { + throw new Error(`unsupported package integrity ${integrity}`) + } + const actual = hash.update(await readFile(path)).digest("base64") + if (actual !== expected) { + throw new Error(`package integrity mismatch: expected ${integrity}, received ${algorithm}-${actual}`) + } +} diff --git a/scripts/package-release-artifact.ts b/scripts/package-release-artifact.ts index 085f74ff..8a7c3c96 100644 --- a/scripts/package-release-artifact.ts +++ b/scripts/package-release-artifact.ts @@ -7,6 +7,7 @@ import { join, resolve } from "node:path" import { promisify } from "node:util" import { assembleWordpressPluginZip } from "./lib/assemble-wordpress-plugin-zip.ts" +import { materializeSharpReleaseRuntime, sharpRuntimePackageNames } from "./lib/materialize-sharp-release-runtime.ts" const execFileAsync = promisify(execFile) const repoRoot = resolve(import.meta.dirname, "..") @@ -15,6 +16,7 @@ const stagingReleaseRoot = await mkdtemp(join(tmpdir(), "wp-codebox-release-")) const packageRoot = join(stagingReleaseRoot, "wp-codebox-cli") const platformName = process.env.WP_CODEBOX_RELEASE_PLATFORM ?? normalizePlatform(platform()) const archName = process.env.WP_CODEBOX_RELEASE_ARCH ?? normalizeArch(arch()) +sharpRuntimePackageNames(platformName, archName) const nodeRuntimeVersion = process.env.WP_CODEBOX_NODE_RUNTIME_VERSION ?? "24.16.0" const artifactName = `wp-codebox-cli-${platformName}-${archName}.tar.gz` const artifactPath = resolve(repoRoot, "dist", artifactName) @@ -48,6 +50,7 @@ try { cwd: packageRoot, maxBuffer: 1024 * 1024 * 20, }) + await materializeSharpReleaseRuntime(packageRoot, join(packageRoot, "npm-shrinkwrap.json"), platformName, archName) await execFileAsync(process.execPath, [resolve(repoRoot, "node_modules", "patch-package", "index.js")], { cwd: packageRoot, maxBuffer: 1024 * 1024 * 20, @@ -79,6 +82,10 @@ exec "\${NODE_BIN}" "\${SCRIPT_DIR}/../packages/cli/dist/index.js" "$@" `) await chmod(binPath, 0o755) + // npm ci can recreate workspace files using the caller's umask, so enforce + // the public entrypoint contract again at the final archive boundary. + await chmod(join(packageRoot, "packages", "cli", "dist", "index.js"), 0o755) + await rm(releaseRoot, recursiveRmOptions) await mkdir(releaseRoot, { recursive: true }) await cp(packageRoot, join(releaseRoot, "wp-codebox-cli"), { recursive: true }) diff --git a/tests/release-package-coverage.test.ts b/tests/release-package-coverage.test.ts index de1283d5..0d09e6ba 100644 --- a/tests/release-package-coverage.test.ts +++ b/tests/release-package-coverage.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import { execFile } from "node:child_process" -import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" import { createHash } from "node:crypto" import { tmpdir } from "node:os" import { join, resolve } from "node:path" @@ -107,6 +107,11 @@ try { const cliEntrypoint = join(root, "packages", "cli", "dist", "index.js") assert.equal((await lstat(cliEntrypoint)).mode & 0o777, 0o755, `${cliEntrypoint} must be executable after extraction`) + assert.deepEqual( + (await readdir(join(root, "node_modules", "@img"))).filter((name) => name.startsWith("sharp-")).sort(), + ["sharp-libvips-linux-x64", "sharp-linux-x64"], + "release package must contain only the Sharp native runtime for its declared target", + ) const { stdout: version } = await execFileAsync(process.execPath, [cliEntrypoint, "--version"]) assert.match(version, /^\d+\.\d+\.\d+\s*$/) await execFileAsync(process.execPath, [cliEntrypoint, "commands"]) @@ -157,9 +162,10 @@ try { maxBuffer: 1024 * 1024 * 20, }) const [packed] = JSON.parse(packOutput) as Array<{ filename: string }> - const { stdout: packedEntries } = await execFileAsync("tar", ["-tzf", join(packRoot, packed.filename)]) + const packedTarball = join(packRoot, packed.filename) + const { stdout: packedEntries } = await execFileAsync("tar", ["-tzf", packedTarball]) assert.ok(packedEntries.split("\n").includes("package/npm-shrinkwrap.json"), "npm package must include its deterministic dependency manifest") - await execFileAsync("npm", ["install", "--global", "--prefix", installRoot, join(packRoot, packed.filename), "--omit=dev", "--no-audit", "--no-fund"], { + await execFileAsync("npm", ["install", "--global", "--prefix", installRoot, packedTarball, "--omit=dev", "--no-audit", "--no-fund"], { cwd: consumerRoot, maxBuffer: 1024 * 1024 * 20, }) diff --git a/tests/sharp-release-runtime.test.ts b/tests/sharp-release-runtime.test.ts new file mode 100644 index 00000000..5baf37cd --- /dev/null +++ b/tests/sharp-release-runtime.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { createHash } from "node:crypto" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import test from "node:test" +import { promisify } from "node:util" + +import { materializeSharpReleaseRuntime, materializeVerifiedPackageTarball, sharpRuntimePackageNames } from "../scripts/lib/materialize-sharp-release-runtime.ts" + +const execFileAsync = promisify(execFile) +const repositoryRoot = resolve(import.meta.dirname, "..") + +test("maps supported release targets to only their Sharp runtime packages", () => { + assert.deepEqual(sharpRuntimePackageNames("linux", "x64"), ["@img/sharp-linux-x64", "@img/sharp-libvips-linux-x64"]) + assert.deepEqual(sharpRuntimePackageNames("linux", "arm64"), ["@img/sharp-linux-arm64", "@img/sharp-libvips-linux-arm64"]) + assert.deepEqual(sharpRuntimePackageNames("macos", "x64"), ["@img/sharp-darwin-x64", "@img/sharp-libvips-darwin-x64"]) + assert.deepEqual(sharpRuntimePackageNames("macos", "arm64"), ["@img/sharp-darwin-arm64", "@img/sharp-libvips-darwin-arm64"]) + assert.deepEqual(sharpRuntimePackageNames("windows", "x64"), ["@img/sharp-win32-x64"]) + assert.deepEqual(sharpRuntimePackageNames("windows", "arm64"), ["@img/sharp-win32-arm64"]) +}) + +test("all supported runtime packages have immutable shrinkwrap provenance", async () => { + const shrinkwrap = JSON.parse(await readFile(join(repositoryRoot, "npm-shrinkwrap.json"), "utf8")) as { + packages: Record + } + for (const [platformName, archName] of [ + ["linux", "x64"], ["linux", "arm64"], + ["macos", "x64"], ["macos", "arm64"], + ["windows", "x64"], ["windows", "arm64"], + ]) { + for (const packageName of sharpRuntimePackageNames(platformName, archName)) { + const lockedPackage = shrinkwrap.packages[`node_modules/${packageName}`] + assert.ok(lockedPackage?.version, `${packageName} must have a locked version`) + assert.match(lockedPackage.resolved ?? "", /^https:\/\/registry\.npmjs\.org\//, `${packageName} must have a locked tarball`) + assert.match(lockedPackage.integrity ?? "", /^sha512-/, `${packageName} must have locked SRI`) + } + } +}) + +test("runtime-playground and the published aggregate package own Sharp", async () => { + const rootPackage = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as { dependencies: Record } + const playgroundPackage = JSON.parse(await readFile(join(repositoryRoot, "packages", "runtime-playground", "package.json"), "utf8")) as { dependencies: Record } + assert.equal(rootPackage.dependencies.sharp, "0.34.5") + assert.equal(playgroundPackage.dependencies.sharp, "0.34.5") +}) + +test("rejects an unsupported release target clearly", () => { + assert.throws( + () => sharpRuntimePackageNames("linux", "ia32"), + /No Sharp native runtime mapping exists for release target linux-ia32\. Supported targets: linux-arm64, linux-x64, macos-arm64, macos-x64, windows-arm64, windows-x64\./, + ) +}) + +test("materializes an integrity-verified package tarball without network access", async () => { + const root = await mkdtemp(join(tmpdir(), "wp-codebox-sharp-runtime-success-")) + try { + const fixture = join(root, "fixture") + const packed = join(root, "packed") + await mkdir(fixture, { recursive: true }) + await mkdir(packed, { recursive: true }) + await writeFile(join(fixture, "package.json"), `${JSON.stringify({ name: "@img/test-runtime", version: "1.0.0", files: ["runtime.node"] })}\n`) + await writeFile(join(fixture, "runtime.node"), "native fixture") + const { stdout } = await execFileAsync("npm", ["pack", fixture, "--pack-destination", packed, "--json", "--ignore-scripts"]) + const [result] = JSON.parse(stdout) as Array<{ filename: string }> + const tarball = join(packed, result.filename) + const integrity = `sha512-${createHash("sha512").update(await readFile(tarball)).digest("base64")}` + + await materializeVerifiedPackageTarball(root, "@img/test-runtime", { version: "1.0.0", integrity }, tarball) + assert.equal(await readFile(join(root, "node_modules", "@img", "test-runtime", "runtime.node"), "utf8"), "native fixture") + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test("rejects a package tarball whose bytes do not match locked SRI", async () => { + const root = await mkdtemp(join(tmpdir(), "wp-codebox-sharp-runtime-integrity-")) + try { + const tarball = join(root, "tampered.tgz") + await writeFile(tarball, "tampered package bytes") + await assert.rejects( + materializeVerifiedPackageTarball(root, "@img/test-runtime", { version: "1.0.0", integrity: `sha512-${Buffer.alloc(64).toString("base64")}` }, tarball), + /package integrity mismatch/, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test("rejects a supported target whose locked runtime cannot be materialized", async () => { + const root = await mkdtemp(join(tmpdir(), "wp-codebox-sharp-runtime-test-")) + try { + await mkdir(join(root, "node_modules"), { recursive: true }) + const manifestPath = join(root, "npm-shrinkwrap.json") + await writeFile(manifestPath, `${JSON.stringify({ packages: {} })}\n`) + await assert.rejects( + materializeSharpReleaseRuntime(root, manifestPath, "linux", "x64"), + /Cannot materialize Sharp runtime for release target linux-x64: node_modules\/@img\/sharp-linux-x64 must have version, resolved, and integrity fields/, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +})